Compare commits

...

31 Commits

Author SHA1 Message Date
Kamil Gabryjelski
592969820f verbose col name in banner 2025-12-19 17:24:03 +01:00
Kamil Gabryjelski
8d40dbd422 better chart loading state 2025-12-19 16:27:32 +01:00
Kamil Gabryjelski
4eb5ab4641 New harry potter background colors 2025-12-19 15:51:09 +01:00
Kamil Gabryjelski
7e802ef7b9 New rainbow colors 2025-12-19 15:45:49 +01:00
Kamil Gabryjelski
2ae11fc2b8 Verbose col names 2025-12-19 15:45:33 +01:00
Kamil Gabryjelski
5881b60492 Fix mixed timeseries second query 2025-12-19 12:45:07 +01:00
Kamil Gabryjelski
d37f73490d Load simulations from url and whatif management page 2025-12-19 11:55:18 +01:00
Kamil Gabryjelski
106baa67e5 Saving simulations 2025-12-19 10:03:35 +01:00
Kamil Gabryjelski
45f1982407 Smol layout fixes 2025-12-19 00:54:56 +01:00
Kamil Gabryjelski
f7bb0c8ed3 Insights collapsible sections 2025-12-18 23:47:52 +01:00
Kamil Gabryjelski
888e14eb0c Code cleanup + HARRY POTTER 2025-12-18 23:29:22 +01:00
Kamil Gabryjelski
15c5740b77 MAGIC WAND 2025-12-18 22:32:06 +01:00
Kamil Gabryjelski
1370810a9c Filters UI 2025-12-18 19:40:49 +01:00
Kamil Gabryjelski
790f15d8f2 optimize select queries 2025-12-18 18:11:53 +01:00
Kamil Gabryjelski
fd43d2facd toggle panel 2025-12-18 16:55:39 +01:00
Kamil Gabryjelski
6737ec8282 fix dashboard edit mode layout 2025-12-18 14:51:33 +01:00
Kamil Gabryjelski
ab8144a501 Abort signal for /interpret 2025-12-18 14:32:18 +01:00
Kamil Gabryjelski
4eaf707aab Handle sql expressions 2025-12-18 14:25:53 +01:00
Kamil Gabryjelski
a5ef75cc06 Fix ai insights not refreshing 2025-12-18 13:10:39 +01:00
Kamil Gabryjelski
d86628918b Fix race condition and double loading spinner 2025-12-18 12:19:36 +01:00
Kamil Gabryjelski
fce4fc039f AI column modifications 2025-12-18 11:35:15 +01:00
Kamil Gabryjelski
4a1471aef5 AI insights 2025-12-18 10:28:51 +01:00
Kamil Gabryjelski
0a6bba1a14 backend for filters 2025-12-17 13:28:54 +01:00
Kamil Gabryjelski
5d6a697e32 fun border 2025-12-16 23:14:43 +01:00
Kamil Gabryjelski
990f6b2f03 perf 2025-12-16 22:51:48 +01:00
Kamil Gabryjelski
54518daea0 banner 2025-12-16 22:39:06 +01:00
Kamil Gabryjelski
3df0521b2d BETTER CHART LOADING 2025-12-16 21:29:37 +01:00
Kamil Gabryjelski
48fca802dc FIX TABLE RERENDER 2025-12-16 18:11:53 +01:00
Kamil Gabryjelski
b4165736d5 Implement UI 2025-12-16 18:11:17 +01:00
Kamil Gabryjelski
263e20e439 initial frontend logic 2025-12-16 15:22:51 +01:00
Kamil Gabryjelski
4dab58f8c0 feat: WHAT IF - backend 2025-12-16 14:31:09 +01:00
63 changed files with 9640 additions and 85 deletions

View File

@@ -66,6 +66,7 @@ import {
ExclamationCircleOutlined,
ExclamationCircleFilled,
ExpandOutlined,
ExperimentOutlined,
EyeOutlined,
EyeInvisibleOutlined,
FallOutlined,
@@ -76,6 +77,7 @@ import {
FileOutlined,
FileTextOutlined,
FireOutlined,
FolderOpenOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
@@ -204,6 +206,7 @@ const AntdIcons = {
ExclamationCircleOutlined,
ExclamationCircleFilled,
ExpandOutlined,
ExperimentOutlined,
EyeOutlined,
EyeInvisibleOutlined,
FacebookOutlined,
@@ -215,6 +218,7 @@ const AntdIcons = {
FileOutlined,
FileTextOutlined,
FireOutlined,
FolderOpenOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -32,7 +32,7 @@ export const buildTimeRangeString = (since: string, until: string): string =>
const formatDateEndpoint = (dttm: string, isStart?: boolean): string =>
dttm.replace('T00:00:00', '') || (isStart ? '-∞' : '∞');
export const formatTimeRange = (
export const formatTimeRangeLabel = (
timeRange: string,
columnPlaceholder = 'col',
) => {
@@ -86,7 +86,7 @@ export const fetchTimeRange = async (
response?.json?.result[0]?.until || '',
);
return {
value: formatTimeRange(timeRangeString, columnPlaceholder),
value: formatTimeRangeLabel(timeRangeString, columnPlaceholder),
};
}
const timeRanges = response?.json?.result.map((result: any) =>

View File

@@ -26,5 +26,9 @@ export {
getTimeOffset,
computeCustomDateTime,
} from './getTimeOffset';
export { SEPARATOR, fetchTimeRange } from './fetchTimeRange';
export {
SEPARATOR,
fetchTimeRange,
formatTimeRangeLabel,
} from './fetchTimeRange';
export { customTimeRangeDecode } from './customTimeRangeDecode';

View File

@@ -167,7 +167,21 @@ const MessageSpan = styled.span`
text-align: center;
margin: ${({ theme }) => theme.sizeUnit * 4}px auto;
width: fit-content;
color: ${({ theme }) => theme.colorText};
color: ${({ theme }) => theme.colorPrimary};
`;
const LoadingOverlay = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: ${({ theme }) => theme.colorBgBase}cc;
z-index: 10;
`;
class Chart extends PureComponent<ChartProps, {}> {
@@ -314,6 +328,22 @@ class Chart extends PureComponent<ChartProps, {}> {
);
}
renderLoadingOverlay(databaseName: string | undefined) {
const message = databaseName
? t('Waiting on %s', databaseName)
: t('Waiting on database...');
return (
<LoadingOverlay>
<Loading
position="inline-centered"
size={this.props.dashboardId ? 's' : 'm'}
/>
<MessageSpan>{message}</MessageSpan>
</LoadingOverlay>
);
}
render() {
const {
height,
@@ -373,6 +403,30 @@ class Chart extends PureComponent<ChartProps, {}> {
);
}
const hasExistingData =
ensureIsArray(queriesResponse).length > 0 &&
queriesResponse?.some(response => response?.data);
const isFirstLoad = isLoading && !hasExistingData;
if (isFirstLoad) {
return (
<ErrorBoundary
onError={this.handleRenderContainerFailure}
showMessage={false}
>
<Styles
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
width={width}
>
{this.renderSpinner(databaseName)}
</Styles>
</ErrorBoundary>
);
}
return (
<ErrorBoundary
onError={this.handleRenderContainerFailure}
@@ -385,9 +439,8 @@ class Chart extends PureComponent<ChartProps, {}> {
height={height}
width={width}
>
{isLoading
? this.renderSpinner(databaseName)
: this.renderChartContainer()}
{this.renderChartContainer()}
{isLoading && this.renderLoadingOverlay(databaseName)}
</Styles>
</ErrorBoundary>
);

View File

@@ -279,8 +279,14 @@ class ChartRenderer extends Component {
render() {
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
// Skip chart rendering
if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
// Skip chart rendering for errors
if (chartAlert) {
return null;
}
const hasData =
this.mutableQueriesResponse && this.mutableQueriesResponse.length > 0;
if (!hasData && (chartStatus === 'loading' || chartStatus === null)) {
return null;
}

View File

@@ -603,6 +603,22 @@ export function refreshChart(chartKey, force, dashboardId) {
};
}
// What-If caching actions
export const SAVE_ORIGINAL_CHART_DATA = 'SAVE_ORIGINAL_CHART_DATA';
export function saveOriginalChartData(key) {
return { type: SAVE_ORIGINAL_CHART_DATA, key };
}
export const RESTORE_ORIGINAL_CHART_DATA = 'RESTORE_ORIGINAL_CHART_DATA';
export function restoreOriginalChartData(key) {
return { type: RESTORE_ORIGINAL_CHART_DATA, key };
}
export const CLEAR_ORIGINAL_CHART_DATA = 'CLEAR_ORIGINAL_CHART_DATA';
export function clearOriginalChartData(key) {
return { type: CLEAR_ORIGINAL_CHART_DATA, key };
}
export const getDatasourceSamples = async (
datasourceType,
datasourceId,

View File

@@ -177,6 +177,33 @@ export default function chartReducer(
annotationQuery,
};
},
[actions.SAVE_ORIGINAL_CHART_DATA](state) {
// Only save if we don't already have cached data
if (state.originalQueriesResponse) {
return state;
}
return {
...state,
originalQueriesResponse: state.queriesResponse,
};
},
[actions.RESTORE_ORIGINAL_CHART_DATA](state) {
if (!state.originalQueriesResponse) {
return state;
}
return {
...state,
queriesResponse: state.originalQueriesResponse,
originalQueriesResponse: null,
chartStatus: 'success',
};
},
[actions.CLEAR_ORIGINAL_CHART_DATA](state) {
return {
...state,
originalQueriesResponse: null,
};
},
};
/* eslint-disable no-param-reassign */

View File

@@ -774,6 +774,22 @@ export function clearAllChartStates() {
return { type: CLEAR_ALL_CHART_STATES };
}
// What-If Analysis actions
export const SET_WHAT_IF_MODIFICATIONS = 'SET_WHAT_IF_MODIFICATIONS';
export function setWhatIfModifications(modifications) {
return { type: SET_WHAT_IF_MODIFICATIONS, modifications };
}
export const CLEAR_WHAT_IF_MODIFICATIONS = 'CLEAR_WHAT_IF_MODIFICATIONS';
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) {

View File

@@ -312,6 +312,7 @@ export const hydrateDashboard =
dashboardState?.datasetsStatus || ResourceStatus.Loading,
chartStates: chartStates || dashboardState?.chartStates || {},
chartCustomizationItems,
whatIfModifications: [],
},
dashboardLayout,
},

View File

@@ -40,6 +40,7 @@ const TABS_KEYS = {
const BuilderComponentPane = ({ topOffset = 0 }) => (
<div
className="dashboard-builder-sidepane"
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;

View File

@@ -19,6 +19,7 @@
/* eslint-env browser */
import cx from 'classnames';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { addAlpha, JsonObject, t, useElementOnScreen } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/ui';
import { useDispatch, useSelector } from 'react-redux';
@@ -42,6 +43,7 @@ import {
import {
setDirectPathToChild,
setEditMode,
toggleWhatIfPanel,
} from 'src/dashboard/actions/dashboardState';
import {
deleteTopLevelTabs,
@@ -55,6 +57,8 @@ import {
DashboardStandaloneMode,
} from 'src/dashboard/util/constants';
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
import WhatIfPanel from 'src/dashboard/components/WhatIfDrawer';
import WhatIfBanner from 'src/dashboard/components/WhatIfBanner';
import { useUiConfig } from 'src/components/UiConfigContext';
import ResizableSidebar from 'src/components/ResizableSidebar';
import {
@@ -272,12 +276,16 @@ const DashboardContentWrapper = styled.div`
const StyledDashboardContent = styled.div<{
editMode: boolean;
marginLeft: number;
marginRight: number;
hasWhatIfPanel: boolean;
}>`
${({ theme, editMode, marginLeft }) => css`
${({ theme, editMode, marginLeft, marginRight, hasWhatIfPanel }) => css`
background-color: ${theme.colorBgLayout};
display: flex;
flex-direction: row;
flex-wrap: nowrap;
display: grid;
grid-template-columns: 1fr ${hasWhatIfPanel ? 'auto' : ''} ${editMode
? 'auto'
: ''};
grid-template-rows: auto 1fr;
height: auto;
flex: 1;
@@ -287,27 +295,23 @@ const StyledDashboardContent = styled.div<{
}
.grid-container {
/* without this, the grid will not get smaller upon toggling the builder panel on */
width: 0;
flex: 1;
grid-column: 1;
grid-row: 2;
position: relative;
margin: ${theme.sizeUnit * 4}px;
margin-left: ${marginLeft}px;
${editMode &&
`
max-width: calc(100% - ${
BUILDER_SIDEPANEL_WIDTH + theme.sizeUnit * 16
}px);
`}
margin-right: ${marginRight}px;
min-width: 0; /* Prevent grid blowout */
/* this is the ParentSize wrapper */
& > div:first-child {
& > div:first-child {
height: 100% !important;
}
}
.dashboard-builder-sidepane {
grid-column: 2;
grid-row: 1 / -1; /* Span all rows */
width: ${BUILDER_SIDEPANEL_WIDTH}px;
z-index: 1;
}
@@ -385,6 +389,35 @@ const DashboardBuilder = () => {
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
({ dashboardInfo }) => dashboardInfo.filterBarOrientation,
);
const whatIfPanelOpen = useSelector<RootState, boolean>(
({ dashboardState }) => dashboardState.whatIfPanelOpen ?? false,
);
// Read simulation ID from URL query parameter
const location = useLocation();
const history = useHistory();
const initialSimulationId = useMemo(() => {
const params = new URLSearchParams(location.search);
const simParam = params.get('simulation');
return simParam ? parseInt(simParam, 10) : null;
}, [location.search]);
// Auto-open What-If panel if simulation ID is in URL
useEffect(() => {
if (initialSimulationId && !whatIfPanelOpen) {
dispatch(toggleWhatIfPanel(true));
}
}, [initialSimulationId, dispatch, whatIfPanelOpen]);
const handleCloseWhatIfPanel = useCallback(() => {
dispatch(toggleWhatIfPanel(false));
// Clear simulation param from URL when closing
const params = new URLSearchParams(location.search);
if (params.has('simulation')) {
params.delete('simulation');
history.replace({ search: params.toString() });
}
}, [dispatch, location.search, history]);
const handleChangeTab = useCallback(
({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
@@ -563,6 +596,8 @@ const DashboardBuilder = () => {
? theme.sizeUnit * 4
: theme.sizeUnit * 8;
const dashboardContentMarginRight = theme.sizeUnit * 4;
const renderChild = useCallback(
adjustedWidth => {
const filterBarWidth = dashboardFiltersOpen
@@ -674,7 +709,10 @@ const DashboardBuilder = () => {
className="dashboard-content"
editMode={editMode}
marginLeft={dashboardContentMarginLeft}
marginRight={dashboardContentMarginRight}
hasWhatIfPanel={!editMode && whatIfPanelOpen}
>
{!editMode && <WhatIfBanner topOffset={barTopOffset} />}
{showDashboard ? (
missingInitialFilters.length > 0 ? (
<div
@@ -684,6 +722,8 @@ const DashboardBuilder = () => {
align-items: center;
justify-content: center;
flex: 1;
grid-column: 1;
grid-row: 2;
& div {
width: 500px;
}
@@ -705,6 +745,14 @@ const DashboardBuilder = () => {
) : (
<Loading />
)}
{!editMode && (
<WhatIfPanel
visible={whatIfPanelOpen}
onClose={handleCloseWhatIfPanel}
topOffset={barTopOffset}
initialSimulationId={initialSimulationId}
/>
)}
{editMode && <BuilderComponentPane topOffset={barTopOffset} />}
</StyledDashboardContent>
</DashboardContentWrapper>

View File

@@ -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;
@@ -195,6 +216,7 @@ const Header = () => {
maxUndoHistoryExceeded,
editMode,
lastModifiedTime,
whatIfPanelOpen,
} = useSelector(
state => ({
expandedSlices: state.dashboardState.expandedSlices,
@@ -210,6 +232,7 @@ const Header = () => {
maxUndoHistoryExceeded: !!state.dashboardState.maxUndoHistoryExceeded,
editMode: !!state.dashboardState.editMode,
lastModifiedTime: state.lastModifiedTime,
whatIfPanelOpen: !!state.dashboardState.whatIfPanelOpen,
}),
shallowEqual,
);
@@ -715,6 +738,17 @@ const Header = () => {
) : (
<div css={actionButtonsStyle}>
{NavExtension && <NavExtension />}
<Tooltip title={t('What-if playground')}>
<button
type="button"
css={whatIfButtonStyle}
onClick={() => dispatch(toggleWhatIfPanel(!whatIfPanelOpen))}
data-test="what-if-button"
aria-label={t('What-if playground')}
>
<Icons.StarFilled iconSize="m" />
</button>
</Tooltip>
{userCanEdit && (
<Button
buttonStyle="secondary"
@@ -749,6 +783,7 @@ const Header = () => {
undoLength,
userCanEdit,
userCanSaveAs,
whatIfPanelOpen,
],
);

View File

@@ -0,0 +1,152 @@
/**
* 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 } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { Button } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { clearWhatIfModifications } from 'src/dashboard/actions/dashboardState';
import { restoreOriginalChartData } from 'src/components/Chart/chartAction';
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
import { useNumericColumns } from 'src/dashboard/util/useNumericColumns';
import { RootState, WhatIfModification } from 'src/dashboard/types';
const EMPTY_MODIFICATIONS: WhatIfModification[] = [];
/**
* Banner container positioned at top of dashboard content, next to the What-If panel.
*
* Layout strategy:
* - Grid positioning: column 1, row 1 (above dashboard content, next to panel)
* - position: sticky with top: topOffset to stick below the dashboard header
* - z-index: 10 to stay above chart content while scrolling
* - align-self: start prevents the banner from stretching vertically
*/
const BannerContainer = styled.div<{ topOffset: number }>`
grid-column: 1;
grid-row: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: ${({ theme }) => theme.sizeUnit * 3}px;
padding: ${({ theme }) => theme.sizeUnit * 2}px
${({ theme }) => theme.sizeUnit * 4}px;
margin-bottom: 0;
background-color: ${({ theme }) => theme.colorSuccessBg};
border-bottom: 1px solid ${({ theme }) => theme.colorSuccessBorder};
position: sticky;
top: ${({ topOffset }) => topOffset}px;
z-index: 10;
align-self: start;
`;
const BannerContent = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
color: ${({ theme }) => theme.colorSuccess};
font-weight: ${({ theme }) => theme.fontWeightStrong};
`;
const Separator = styled.span`
color: ${({ theme }) => theme.colorSuccess};
opacity: 0.5;
`;
const ExitButton = styled(Button)`
&& {
color: ${({ theme }) => theme.colorSuccess};
border-color: ${({ theme }) => theme.colorSuccess};
background-color: ${({ theme }) => theme.colorSuccessBg};
&:hover {
color: ${({ theme }) => theme.colorSuccessHover};
border-color: ${({ theme }) => theme.colorSuccessHover};
background-color: ${({ theme }) => theme.colorSuccessBgHover};
}
}
`;
interface WhatIfBannerProps {
topOffset: number;
}
const WhatIfBanner = ({ topOffset }: WhatIfBannerProps) => {
const theme = useTheme();
const dispatch = useDispatch();
const whatIfModifications = useSelector<RootState, WhatIfModification[]>(
state => state.dashboardState.whatIfModifications ?? EMPTY_MODIFICATIONS,
);
const { columnToChartIds, numericColumns } = useNumericColumns();
const handleExitWhatIf = useCallback(() => {
const affectedChartIds = new Set<number>();
whatIfModifications.forEach(mod => {
const chartIds = columnToChartIds.get(mod.column) || [];
chartIds.forEach(id => affectedChartIds.add(id));
});
// Clear what-if modifications
dispatch(clearWhatIfModifications());
// Restore original chart data from cache (instant, no re-query needed)
affectedChartIds.forEach(chartId => {
dispatch(restoreOriginalChartData(chartId));
});
}, [dispatch, whatIfModifications, columnToChartIds]);
if (whatIfModifications.length === 0) {
return null;
}
const modification = whatIfModifications[0];
const percentageChange = formatPercentageChange(modification.multiplier);
// Get verbose name from numericColumns if available
const columnInfo = numericColumns.find(
col => col.columnName === modification.column,
);
const displayName = columnInfo?.verboseName || modification.column;
return (
<BannerContainer data-test="what-if-banner" topOffset={topOffset}>
<BannerContent>
<Icons.ExperimentOutlined iconSize="m" iconColor={theme.colorSuccess} />
<span>{t('What-if mode active')}</span>
<Separator>|</Separator>
<span>
{t('Showing simulated data with %s %s', displayName, percentageChange)}
</span>
</BannerContent>
<ExitButton
buttonSize="small"
onClick={handleExitWhatIf}
data-test="exit-what-if-button"
>
<Icons.CloseOutlined iconSize="s" />
{t('Exit what-if mode')}
</ExitButton>
</BannerContainer>
);
};
export default WhatIfBanner;

View File

@@ -0,0 +1,102 @@
/**
* 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 { memo } from 'react';
import { t } from '@superset-ui/core';
import { Tooltip, Popover } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { Datasource } from 'src/dashboard/types';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import AdhocFilterEditPopover from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopover';
import { FilterButtonStyled, FilterPopoverContent } from './styles';
interface FilterButtonProps {
filterPopoverVisible: boolean;
currentAdhocFilter: AdhocFilter | null;
selectedColumn: string | undefined;
selectedDatasource: Datasource | null;
filterColumnOptions: Datasource['columns'];
onOpenFilterPopover: () => void;
onFilterPopoverVisibleChange: (visible: boolean) => void;
onFilterChange: (adhocFilter: AdhocFilter) => void;
onFilterPopoverClose: () => void;
onFilterPopoverResize: () => void;
}
/**
* Component for rendering the filter button with popover.
* Uses memo to prevent unnecessary re-renders when parent state changes
* that don't affect this component.
*/
const FilterButton = memo(function FilterButton({
filterPopoverVisible,
currentAdhocFilter,
selectedColumn,
selectedDatasource,
filterColumnOptions,
onOpenFilterPopover,
onFilterPopoverVisibleChange,
onFilterChange,
onFilterPopoverClose,
onFilterPopoverResize,
}: FilterButtonProps) {
return (
<Popover
open={filterPopoverVisible}
onOpenChange={onFilterPopoverVisibleChange}
trigger="click"
placement="left"
destroyOnHidden
content={
currentAdhocFilter && selectedDatasource ? (
<FilterPopoverContent>
<AdhocFilterEditPopover
adhocFilter={currentAdhocFilter}
options={filterColumnOptions}
datasource={selectedDatasource}
onChange={onFilterChange}
onClose={onFilterPopoverClose}
onResize={onFilterPopoverResize}
requireSave
/>
</FilterPopoverContent>
) : null
}
>
<Tooltip
title={
selectedColumn
? t('Add filter to scope the modification')
: t('Select a column first')
}
>
<FilterButtonStyled
onClick={onOpenFilterPopover}
disabled={!selectedColumn || !selectedDatasource}
aria-label={t('Add filter')}
buttonStyle="tertiary"
>
<Icons.FilterOutlined iconSize="m" />
</FilterButtonStyled>
</Tooltip>
</Popover>
);
});
export default FilterButton;

View File

@@ -0,0 +1,491 @@
/**
* 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 { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { keyframes } from '@emotion/react';
import { styled } from '@apache-superset/core/ui';
import { t } from '@superset-ui/core';
// eslint-disable theme-colors/no-literal-colors
// Casting spell motion - dramatic swish and flick!
const castSpell = keyframes`
0% {
transform: rotate(-30deg) translateY(0);
}
15% {
transform: rotate(-45deg) translateY(-5px);
}
30% {
transform: rotate(25deg) translateY(0);
}
45% {
transform: rotate(15deg) translateY(-3px);
}
60% {
transform: rotate(-10deg) translateY(0);
}
75% {
transform: rotate(5deg) translateY(-2px);
}
100% {
transform: rotate(-30deg) translateY(0);
}
`;
// Magic particles floating upward
const floatUp = keyframes`
0% {
opacity: 1;
transform: translateY(0) translateX(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(-100px) translateX(var(--drift-x, 0px)) scale(0);
}
`;
// Spiral magic effect
const spiral = keyframes`
0% {
transform: rotate(0deg) translateX(20px) rotate(0deg);
opacity: 1;
}
100% {
transform: rotate(720deg) translateX(80px) rotate(-720deg);
opacity: 0;
}
`;
// Glowing tip pulse
const glowPulse = keyframes`
0%, 100% {
filter: drop-shadow(0 0 8px #fff) drop-shadow(0 0 15px #87CEEB) drop-shadow(0 0 25px #4169E1);
opacity: 0.9;
}
50% {
filter: drop-shadow(0 0 15px #fff) drop-shadow(0 0 30px #87CEEB) drop-shadow(0 0 45px #4169E1);
opacity: 1;
}
`;
// Stars twinkling
const twinkle = keyframes`
0%, 100% {
opacity: 0;
transform: scale(0) rotate(0deg);
}
25% {
opacity: 1;
transform: scale(1) rotate(90deg);
}
50% {
opacity: 0.5;
transform: scale(0.8) rotate(180deg);
}
75% {
opacity: 1;
transform: scale(1.2) rotate(270deg);
}
`;
// Lumos light burst
const lumosBurst = keyframes`
0% {
transform: scale(0);
opacity: 0;
}
20% {
transform: scale(1.5);
opacity: 0.8;
}
100% {
transform: scale(3);
opacity: 0;
}
`;
const fadeIn = keyframes`
from { opacity: 0; }
to { opacity: 1; }
`;
const textGlow = keyframes`
0%, 100% {
text-shadow: 0 0 10px #87CEEB, 0 0 20px #4169E1, 0 0 30px #4169E1;
}
50% {
text-shadow: 0 0 20px #87CEEB, 0 0 40px #4169E1, 0 0 60px #4169E1, 0 0 80px #6495ED;
}
`;
/* eslint-disable theme-colors/no-literal-colors */
const Overlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(ellipse at center, rgba(20, 20, 40, 0.65) 0%, rgba(10, 10, 25, 0.75) 50%, rgba(0, 0, 10, 0.85) 100%);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 99999;
animation: ${fadeIn} 0.5s ease-out;
overflow: hidden;
`;
const StarsBackground = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
`;
const Star = styled.div<{ size: number; x: number; y: number; delay: number }>`
position: absolute;
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
left: ${({ x }) => x}%;
top: ${({ y }) => y}%;
background: radial-gradient(circle, #fff 0%, transparent 70%);
border-radius: 50%;
animation: ${twinkle} ${({ delay }) => 2 + delay}s ease-in-out infinite;
animation-delay: ${({ delay }) => delay}s;
`;
const WandScene = styled.div`
position: relative;
width: 300px;
height: 300px;
display: flex;
align-items: center;
justify-content: center;
`;
const WandWrapper = styled.div`
position: relative;
animation: ${castSpell} 2.5s ease-in-out infinite;
transform-origin: 85% 85%;
`;
const WandSvg = styled.svg`
width: 180px;
height: 180px;
transform: rotate(-45deg);
`;
const WandTipGlow = styled.div`
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 20px;
background: radial-gradient(circle, #fff 0%, #87CEEB 30%, #4169E1 60%, transparent 80%);
border-radius: 50%;
animation: ${glowPulse} 1s ease-in-out infinite;
`;
const LumosBurst = styled.div`
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 40px;
background: radial-gradient(circle, rgba(255,255,255,0.8) 0%, rgba(135,206,235,0.4) 40%, transparent 70%);
border-radius: 50%;
animation: ${lumosBurst} 2s ease-out infinite;
`;
const MagicParticle = styled.div<{ delay: number; driftX: number; duration: number }>`
position: absolute;
top: 20%;
left: 50%;
width: 6px;
height: 6px;
background: radial-gradient(circle, #fff 0%, #87CEEB 50%, transparent 100%);
border-radius: 50%;
--drift-x: ${({ driftX }) => driftX}px;
animation: ${floatUp} ${({ duration }) => duration}s ease-out infinite;
animation-delay: ${({ delay }) => delay}s;
`;
const SpiralMagic = styled.div<{ delay: number; color: string }>`
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
background: ${({ color }) => color};
border-radius: 50%;
animation: ${spiral} 3s ease-out infinite;
animation-delay: ${({ delay }) => delay}s;
box-shadow: 0 0 10px ${({ color }) => color};
`;
const MagicStar = styled.div<{ x: number; y: number; delay: number; size: number }>`
position: absolute;
left: ${({ x }) => x}%;
top: ${({ y }) => y}%;
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
animation: ${twinkle} 1.5s ease-in-out infinite;
animation-delay: ${({ delay }) => delay}s;
&::before,
&::after {
content: '';
position: absolute;
background: linear-gradient(135deg, #fff 0%, #87CEEB 50%, #4169E1 100%);
}
&::before {
width: 100%;
height: 2px;
top: 50%;
left: 0;
transform: translateY(-50%);
}
&::after {
width: 2px;
height: 100%;
top: 0;
left: 50%;
transform: translateX(-50%);
}
`;
const SpellText = styled.div`
margin-top: 40px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-style: italic;
color: #B8E0F0;
animation: ${textGlow} 2s ease-in-out infinite;
letter-spacing: 3px;
`;
const SubText = styled.div`
margin-top: 16px;
font-size: 14px;
color: #E0E8F0;
letter-spacing: 1px;
`;
const DismissHint = styled.div`
margin-top: 32px;
font-size: 12px;
color: #B0BEC5;
`;
/* eslint-enable theme-colors/no-literal-colors */
// Generate random stars for background
const backgroundStars = Array.from({ length: 50 }, (_, i) => ({
id: i,
size: Math.random() * 3 + 1,
x: Math.random() * 100,
y: Math.random() * 100,
delay: Math.random() * 3,
}));
// Magic particles around wand tip
const particles = Array.from({ length: 12 }, (_, i) => ({
id: i,
delay: i * 0.15,
driftX: (Math.random() - 0.5) * 60,
duration: 1.5 + Math.random() * 0.5,
}));
// Spiral magic colors
/* eslint-disable theme-colors/no-literal-colors */
const spiralColors = ['#87CEEB', '#4169E1', '#6495ED', '#B0C4DE', '#ADD8E6', '#fff'];
/* eslint-enable theme-colors/no-literal-colors */
const spirals = spiralColors.map((color, i) => ({
id: i,
delay: i * 0.5,
color,
}));
// Magic stars around the scene
const magicStars = [
{ x: 15, y: 20, delay: 0, size: 16 },
{ x: 80, y: 25, delay: 0.3, size: 12 },
{ x: 25, y: 70, delay: 0.6, size: 14 },
{ x: 75, y: 65, delay: 0.9, size: 10 },
{ x: 10, y: 45, delay: 0.2, size: 8 },
{ x: 88, y: 50, delay: 0.5, size: 11 },
{ x: 50, y: 10, delay: 0.4, size: 13 },
{ x: 45, y: 85, delay: 0.7, size: 9 },
];
interface HarryPotterWandLoaderProps {
onDismiss?: () => void;
}
const HarryPotterWandLoader = ({ onDismiss }: HarryPotterWandLoaderProps) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onDismiss) {
onDismiss();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onDismiss]);
const content = (
<Overlay data-test="harry-potter-wand-loader" onClick={onDismiss}>
<StarsBackground>
{backgroundStars.map(star => (
<Star
key={star.id}
size={star.size}
x={star.x}
y={star.y}
delay={star.delay}
/>
))}
</StarsBackground>
<WandScene>
{magicStars.map((star, i) => (
<MagicStar
key={i}
x={star.x}
y={star.y}
delay={star.delay}
size={star.size}
/>
))}
{spirals.map(s => (
<SpiralMagic key={s.id} delay={s.delay} color={s.color} />
))}
<WandWrapper>
<LumosBurst />
<WandTipGlow />
{particles.map(p => (
<MagicParticle
key={p.id}
delay={p.delay}
driftX={p.driftX}
duration={p.duration}
/>
))}
<WandSvg viewBox="0 0 100 100" fill="none">
<defs>
{/* Wood grain gradient for authentic wand look */}
<linearGradient id="hpWandWood" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#2C1810" />
<stop offset="20%" stopColor="#4A2C1A" />
<stop offset="40%" stopColor="#3D2314" />
<stop offset="60%" stopColor="#5C3A22" />
<stop offset="80%" stopColor="#3D2314" />
<stop offset="100%" stopColor="#2C1810" />
</linearGradient>
{/* Handle gradient - darker, more ornate */}
<linearGradient id="hpWandHandle" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#1A0F0A" />
<stop offset="30%" stopColor="#2C1810" />
<stop offset="50%" stopColor="#3D2314" />
<stop offset="70%" stopColor="#2C1810" />
<stop offset="100%" stopColor="#1A0F0A" />
</linearGradient>
{/* Glowing tip */}
<radialGradient id="hpWandGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#fff" />
<stop offset="40%" stopColor="#87CEEB" />
<stop offset="100%" stopColor="#4169E1" stopOpacity="0" />
</radialGradient>
</defs>
{/* Wand body - tapered shape like Elder Wand style */}
<path
d="M 50 10
Q 52 15 52 20
L 53 45
Q 54 55 55 65
L 57 80
Q 58 85 56 88
L 54 90
Q 50 92 46 90
L 44 88
Q 42 85 43 80
L 45 65
Q 46 55 47 45
L 48 20
Q 48 15 50 10
Z"
fill="url(#hpWandWood)"
/>
{/* Handle bumps - Elder Wand style nodules */}
<ellipse cx="50" cy="75" rx="6" ry="3" fill="url(#hpWandHandle)" />
<ellipse cx="50" cy="82" rx="5" ry="2.5" fill="url(#hpWandHandle)" />
<ellipse cx="50" cy="88" rx="4" ry="2" fill="url(#hpWandHandle)" />
{/* Wood grain lines */}
<path
d="M 49 25 Q 50 40 49 55"
stroke="#1A0F0A"
strokeWidth="0.5"
fill="none"
opacity="0.3"
/>
<path
d="M 51 30 Q 52 45 51 60"
stroke="#1A0F0A"
strokeWidth="0.5"
fill="none"
opacity="0.3"
/>
{/* Glowing tip */}
<circle cx="50" cy="8" r="6" fill="url(#hpWandGlow)" />
<circle cx="50" cy="8" r="3" fill="#fff" opacity="0.9" />
</WandSvg>
</WandWrapper>
</WandScene>
<SpellText>{t('Revelio...')}</SpellText>
<SubText>{t('Discovering hidden connections')}</SubText>
{onDismiss && (
<DismissHint>{t('Click anywhere or press Esc to cancel')}</DismissHint>
)}
</Overlay>
);
// Use portal to render at document.body level to escape any stacking context issues
return createPortal(content, document.body);
};
export default HarryPotterWandLoader;

View File

@@ -0,0 +1,287 @@
/**
* 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 { useEffect } from 'react';
import { keyframes } from '@emotion/react';
import { css, styled, useTheme } from '@apache-superset/core/ui';
import { t } from '@superset-ui/core';
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`;
const waveWand = keyframes`
0%, 100% {
transform: rotate(-15deg);
}
50% {
transform: rotate(15deg);
}
`;
const sparkle = keyframes`
0%, 100% {
opacity: 0;
transform: scale(0) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
`;
const float = keyframes`
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
`;
const pulse = keyframes`
0%, 100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.2);
opacity: 1;
}
`;
const Overlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${({ theme }) => theme.colorBgMask};
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
animation: ${fadeIn} 0.3s ease-out;
`;
const WandContainer = styled.div`
position: relative;
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
animation: ${float} 2s ease-in-out infinite;
`;
const Wand = styled.div`
position: relative;
animation: ${waveWand} 1s ease-in-out infinite;
transform-origin: bottom center;
`;
const WandSvg = styled.svg`
width: 120px;
height: 120px;
filter: drop-shadow(0 0 20px ${({ theme }) => theme.colorWarning});
`;
const Sparkle = styled.div<{ delay: number; x: number; y: number }>`
position: absolute;
width: 12px;
height: 12px;
animation: ${sparkle} 1.5s ease-in-out infinite;
animation-delay: ${({ delay }) => delay}s;
left: ${({ x }) => x}%;
top: ${({ y }) => y}%;
&::before,
&::after {
content: '';
position: absolute;
background: linear-gradient(
135deg,
${({ theme }) => theme.colorWarning} 0%,
${({ theme }) => theme.colorWhite} 50%,
${({ theme }) => theme.colorWarning} 100%
);
}
&::before {
width: 100%;
height: 2px;
top: 50%;
left: 0;
transform: translateY(-50%);
}
&::after {
width: 2px;
height: 100%;
top: 0;
left: 50%;
transform: translateX(-50%);
}
`;
const GlowOrb = styled.div`
position: absolute;
top: 15%;
left: 50%;
transform: translateX(-50%);
width: 30px;
height: 30px;
background: radial-gradient(
circle,
${({ theme }) => theme.colorWarning} 0%,
${({ theme }) => theme.colorWarningBg} 40%,
transparent 70%
);
border-radius: 50%;
animation: ${pulse} 0.8s ease-in-out infinite;
`;
const LoadingText = styled.div`
margin-top: ${({ theme }) => theme.sizeUnit * 6}px;
color: ${({ theme }) => theme.colorWhite};
font-size: ${({ theme }) => theme.fontSizeLG}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
text-shadow: 0 2px 10px ${({ theme }) => theme.colorBgMask};
text-align: center;
`;
const SubText = styled.div`
margin-top: ${({ theme }) => theme.sizeUnit}px;
color: ${({ theme }) => theme.colorTextLightSolid};
font-size: ${({ theme }) => theme.fontSizeSM}px;
`;
const DismissHint = styled.div`
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
color: ${({ theme }) => theme.colorTextLightSolid};
font-size: ${({ theme }) => theme.fontSizeXS}px;
opacity: 0.7;
`;
const sparklePositions = [
{ x: 20, y: 10, delay: 0 },
{ x: 75, y: 15, delay: 0.3 },
{ x: 85, y: 35, delay: 0.6 },
{ x: 15, y: 30, delay: 0.9 },
{ x: 60, y: 5, delay: 0.2 },
{ x: 40, y: 20, delay: 0.5 },
{ x: 80, y: 25, delay: 0.8 },
{ x: 25, y: 18, delay: 0.4 },
];
// Magic wand SVG colors - using warm brown/gold tones for the wand aesthetic
/* eslint-disable theme-colors/no-literal-colors */
const WAND_COLORS = {
woodDark: '#654321',
woodMid: '#8B4513',
woodLight: '#A0522D',
goldDark: '#B8860B',
goldMid: '#DAA520',
starLight: '#FFF8DC',
};
/* eslint-enable theme-colors/no-literal-colors */
interface MagicWandLoaderProps {
onDismiss?: () => void;
}
const MagicWandLoader = ({ onDismiss }: MagicWandLoaderProps) => {
const theme = useTheme();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onDismiss) {
onDismiss();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onDismiss]);
return (
<Overlay data-test="magic-wand-loader" onClick={onDismiss}>
<WandContainer>
{sparklePositions.map((pos, i) => (
<Sparkle key={i} x={pos.x} y={pos.y} delay={pos.delay} />
))}
<GlowOrb />
<Wand>
<WandSvg viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient
id="wandGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor={WAND_COLORS.woodMid} />
<stop offset="50%" stopColor={WAND_COLORS.woodLight} />
<stop offset="100%" stopColor={WAND_COLORS.woodDark} />
</linearGradient>
</defs>
{/* Thin wand body */}
<rect
x="30.5"
y="16"
width="3"
height="42"
rx="1.5"
fill="url(#wandGradient)"
css={css`
filter: drop-shadow(1px 1px 2px ${theme.colorBgMask});
`}
/>
{/* Small star at tip */}
<circle
cx="32"
cy="12"
r="4"
fill={theme.colorWarning}
css={css`
filter: drop-shadow(0 0 6px ${theme.colorWarning});
`}
/>
</WandSvg>
</Wand>
</WandContainer>
<LoadingText>{t('Analyzing relationships...')}</LoadingText>
<SubText>{t('AI is finding connected columns')}</SubText>
{onDismiss && (
<DismissHint>{t('Click anywhere or press Esc to cancel')}</DismissHint>
)}
</Overlay>
);
};
export default MagicWandLoader;

View File

@@ -0,0 +1,121 @@
/**
* 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 { memo, useState, useCallback } from 'react';
import { t } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/ui';
import { Tag } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
import { ExtendedWhatIfModification } from './types';
import {
ModificationsSection,
ModificationTagsContainer,
AIBadge,
AIReasoningSection,
AIReasoningToggle,
AIReasoningContent,
AIReasoningItem,
} from './styles';
interface ModificationsDisplayProps {
modifications: ExtendedWhatIfModification[];
}
/**
* Component for displaying applied modifications as tags with AI reasoning.
* Uses memo to prevent unnecessary re-renders when modifications haven't changed.
*/
const ModificationsDisplay = memo(function ModificationsDisplay({
modifications,
}: ModificationsDisplayProps) {
const theme = useTheme();
const [showAIReasoning, setShowAIReasoning] = useState(false);
const toggleAIReasoning = useCallback(() => {
setShowAIReasoning(prev => !prev);
}, []);
const hasAIReasoning = modifications.some(mod => mod.reasoning);
if (modifications.length === 0) {
return null;
}
return (
<ModificationsSection>
<ModificationTagsContainer>
{modifications.map((mod, idx) => (
<Tag
key={idx}
css={css`
display: inline-flex;
align-items: center;
gap: ${theme.sizeUnit}px;
margin: 0;
`}
>
<span>{mod.verboseName || mod.column}</span>
{mod.isAISuggested && <AIBadge>{t('AI')}</AIBadge>}
<span
css={css`
font-weight: ${theme.fontWeightStrong};
color: ${mod.multiplier >= 1
? theme.colorSuccess
: theme.colorError};
`}
>
{formatPercentageChange(mod.multiplier, 0)}
</span>
</Tag>
))}
</ModificationTagsContainer>
{hasAIReasoning && (
<AIReasoningSection>
<AIReasoningToggle onClick={toggleAIReasoning}>
{showAIReasoning ? (
<Icons.DownOutlined iconSize="xs" />
) : (
<Icons.RightOutlined iconSize="xs" />
)}
{t('How AI chose these')}
</AIReasoningToggle>
{showAIReasoning && (
<AIReasoningContent>
{modifications
.filter(mod => mod.reasoning)
.map((mod, idx) => (
<AIReasoningItem key={idx}>
<strong>
{mod.verboseName || mod.column}{' '}
{formatPercentageChange(mod.multiplier, 0)}
</strong>
<div>{mod.reasoning}</div>
</AIReasoningItem>
))}
</AIReasoningContent>
)}
</AIReasoningSection>
)}
</ModificationsSection>
);
});
export default ModificationsDisplay;

View File

@@ -0,0 +1,170 @@
/**
* 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, useEffect } from 'react';
import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import { Form, Input } from '@superset-ui/core/components';
import { StandardModal } from 'src/components/Modal';
import { WhatIfModification } from './types';
import {
createSimulation,
updateSimulation,
WhatIfSimulation,
} from './whatIfApi';
const ModalContent = styled.div`
padding: ${({ theme }) => theme.sizeUnit * 4}px;
.ant-form-item {
margin-bottom: ${({ theme }) => theme.sizeUnit * 6}px;
&:last-child {
margin-bottom: 0;
}
}
.ant-form-item-label {
padding-bottom: ${({ theme }) => theme.sizeUnit}px;
}
`;
const { TextArea } = Input;
interface SaveSimulationModalProps {
show: boolean;
onHide: () => void;
onSaved: (simulation: WhatIfSimulation) => void;
dashboardId: number;
modifications: WhatIfModification[];
cascadingEffectsEnabled: boolean;
existingSimulation?: WhatIfSimulation | null;
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
}
const SaveSimulationModal = ({
show,
onHide,
onSaved,
dashboardId,
modifications,
cascadingEffectsEnabled,
existingSimulation,
addSuccessToast,
addDangerToast,
}: SaveSimulationModalProps) => {
const [form] = Form.useForm();
const isUpdate = Boolean(existingSimulation);
useEffect(() => {
if (show) {
form.setFieldsValue({
name: existingSimulation?.name || '',
description: existingSimulation?.description || '',
});
}
}, [show, existingSimulation, form]);
const handleSave = useCallback(async () => {
try {
const values = await form.validateFields();
if (isUpdate && existingSimulation) {
await updateSimulation(existingSimulation.id, {
name: values.name,
description: values.description,
modifications,
cascadingEffectsEnabled,
});
const updatedSimulation: WhatIfSimulation = {
...existingSimulation,
name: values.name,
description: values.description,
modifications,
cascadingEffectsEnabled,
};
onSaved(updatedSimulation);
addSuccessToast(t('Simulation updated successfully'));
} else {
const simulation = await createSimulation({
name: values.name,
description: values.description,
dashboardId,
modifications,
cascadingEffectsEnabled,
});
onSaved(simulation);
addSuccessToast(t('Simulation saved successfully'));
}
onHide();
} catch (error) {
addDangerToast(t('Failed to save simulation'));
}
}, [
form,
isUpdate,
existingSimulation,
modifications,
cascadingEffectsEnabled,
dashboardId,
onSaved,
onHide,
addSuccessToast,
addDangerToast,
]);
const handleCancel = useCallback(() => {
form.resetFields();
onHide();
}, [form, onHide]);
return (
<StandardModal
show={show}
onHide={handleCancel}
onSave={handleSave}
title={isUpdate ? t('Update Simulation') : t('Save Simulation')}
width={500}
saveText={isUpdate ? t('Update') : t('Save')}
>
<ModalContent>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('Name')}
rules={[{ required: true, message: t('Please enter a name') }]}
>
<Input placeholder={t('My What-If Scenario')} />
</Form.Item>
<Form.Item name="description" label={t('Description')}>
<TextArea
placeholder={t('Optional description of this simulation')}
rows={3}
/>
</Form.Item>
</Form>
</ModalContent>
</StandardModal>
);
};
export default SaveSimulationModal;

View File

@@ -0,0 +1,411 @@
/**
* 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, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@superset-ui/core';
import { styled, Alert } from '@apache-superset/core/ui';
import { Icons, IconType } from '@superset-ui/core/components/Icons';
import { Collapse, Skeleton } from '@superset-ui/core/components/';
import { RootState, WhatIfModification } from 'src/dashboard/types';
import { whatIfHighlightStyles } from 'src/dashboard/util/useWhatIfHighlightStyles';
import { fetchWhatIfInterpretation } from './whatIfApi';
import { useChartComparison, useAllChartsLoaded } from './useChartComparison';
import {
GroupedWhatIfInsights,
WhatIfAIStatus,
WhatIfInsightType,
WhatIfInterpretResponse,
} from './types';
// Static Skeleton paragraph configs to avoid recreation on each render
const SKELETON_PARAGRAPH_3 = { rows: 3 };
const SKELETON_PARAGRAPH_2 = { rows: 2 };
// Configuration for each insight type
const INSIGHT_TYPE_CONFIG: Record<
WhatIfInsightType,
{ label: string; icon: React.ComponentType<IconType> }
> = {
observation: { label: t('Observations'), icon: Icons.EyeOutlined },
implication: { label: t('Implications'), icon: Icons.WarningOutlined },
recommendation: {
label: t('Recommendations'),
icon: Icons.CheckCircleOutlined,
},
};
// Order in which insight types should appear
const INSIGHT_TYPE_ORDER: WhatIfInsightType[] = [
'observation',
'implication',
'recommendation',
];
/**
* Create a stable key from modifications for comparison.
* This allows us to detect when modifications have meaningfully changed.
*/
function getModificationsKey(modifications: WhatIfModification[]): string {
return modifications
.map(m => `${m.column}:${m.multiplier}`)
.sort()
.join('|');
}
const InsightsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 3}px;
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
padding-top: ${({ theme }) => theme.sizeUnit * 4}px;
border-top: 1px solid ${({ theme }) => theme.colorBorderSecondary};
`;
const InsightsHeader = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
color: ${({ theme }) => theme.colorText};
`;
const InsightCard = styled.div<{ insightType: string }>`
padding: ${({ theme }) => theme.sizeUnit * 3}px;
background-color: ${({ theme, insightType }) => {
switch (insightType) {
case 'observation':
return theme.colorInfoBg;
case 'implication':
return theme.colorWarningBg;
case 'recommendation':
return theme.colorSuccessBg;
default:
return theme.colorBgElevated;
}
}};
border-radius: ${({ theme }) => theme.borderRadius}px;
border-left: 3px solid
${({ theme, insightType }) => {
switch (insightType) {
case 'observation':
return theme.colorInfo;
case 'implication':
return theme.colorWarning;
case 'recommendation':
return theme.colorSuccess;
default:
return theme.colorBorder;
}
}};
`;
const InsightTitle = styled.div`
font-weight: ${({ theme }) => theme.fontWeightStrong};
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
`;
const InsightDescription = styled.div`
color: ${({ theme }) => theme.colorTextSecondary};
font-size: ${({ theme }) => theme.fontSizeSM}px;
line-height: 1.5;
`;
const Summary = styled.div`
font-size: ${({ theme }) => theme.fontSize}px;
line-height: 1.6;
color: ${({ theme }) => theme.colorText};
padding: ${({ theme }) => theme.sizeUnit * 3}px;
background-color: ${({ theme }) => theme.colorBgElevated};
border-radius: ${({ theme }) => theme.borderRadius}px;
${whatIfHighlightStyles}
`;
const StyledCollapse = styled(Collapse)`
background: transparent;
border: none;
.ant-collapse-item {
border: none;
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
background: ${({ theme }) => theme.colorBgElevated};
border-radius: ${({ theme }) => theme.borderRadius}px !important;
overflow: hidden;
.ant-collapse-header {
padding: ${({ theme }) => theme.sizeUnit * 3}px;
background: transparent;
}
.ant-collapse-content {
border-top: 1px solid ${({ theme }) => theme.colorBorderSecondary};
}
.ant-collapse-content-box {
padding: ${({ theme }) => theme.sizeUnit * 2}px;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
}
}
`;
const CollapsePanelHeader = styled.div<{ insightType: WhatIfInsightType }>`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
color: ${({ theme, insightType }) => {
switch (insightType) {
case 'observation':
return theme.colorInfo;
case 'implication':
return theme.colorWarning;
case 'recommendation':
return theme.colorSuccess;
default:
return theme.colorText;
}
}};
`;
interface WhatIfAIInsightsProps {
affectedChartIds: number[];
modifications: WhatIfModification[];
/** Ref to register the abort function for external control */
abortRef?: React.MutableRefObject<(() => void) | null>;
}
const WhatIfAIInsights = ({
affectedChartIds,
modifications,
abortRef,
}: WhatIfAIInsightsProps) => {
const [status, setStatus] = useState<WhatIfAIStatus>('idle');
const [response, setResponse] = useState<WhatIfInterpretResponse | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const dashboardTitle = useSelector<RootState, string>(
// @ts-ignore
state => state.dashboardInfo?.dashboard_title || 'Dashboard',
);
const chartComparisons = useChartComparison(affectedChartIds);
const allChartsLoaded = useAllChartsLoaded(affectedChartIds);
// AbortController for cancelling in-flight /interpret requests
const abortControllerRef = useRef<AbortController | null>(null);
// Cleanup: cancel any pending requests on unmount
useEffect(
() => () => {
abortControllerRef.current?.abort();
},
[],
);
// Register abort function with external ref for parent control
useEffect(() => {
if (abortRef) {
abortRef.current = () => {
abortControllerRef.current?.abort();
};
}
return () => {
if (abortRef) {
abortRef.current = null;
}
};
}, [abortRef]);
// Track modification changes to reset status when user adjusts the slider
const modificationsKey = getModificationsKey(modifications);
const prevModificationsKeyRef = useRef<string>(modificationsKey);
// Reset status when modifications change (user adjusts the slider)
useEffect(() => {
if (
modificationsKey !== prevModificationsKeyRef.current &&
modifications.length > 0
) {
// Cancel any in-flight request when modifications change
abortControllerRef.current?.abort();
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: resetting state when modifications change
setStatus('idle');
setResponse(null);
prevModificationsKeyRef.current = modificationsKey;
}
}, [modificationsKey, modifications.length]);
const fetchInsights = useCallback(async () => {
if (modifications.length === 0 || chartComparisons.length === 0) {
return;
}
// Cancel any in-flight request before starting a new one
abortControllerRef.current?.abort();
// Create a new AbortController for this request
const abortController = new AbortController();
abortControllerRef.current = abortController;
setStatus('loading');
setError(null);
try {
const result = await fetchWhatIfInterpretation(
{
modifications,
charts: chartComparisons,
dashboardName: dashboardTitle,
},
abortController.signal,
);
setResponse(result);
setStatus('success');
} catch (err) {
// Don't update state if the request was aborted
if (err instanceof Error && err.name === 'AbortError') {
return;
}
setError(
err instanceof Error
? err.message
: t('Failed to generate AI insights'),
);
setStatus('error');
}
}, [modifications, chartComparisons, dashboardTitle]);
// Automatically fetch insights when all affected charts have finished loading.
// We wait for allChartsLoaded to prevent race conditions where we'd send
// stale data before charts have re-queried with the what-if modifications.
// The setState call here is intentional - we're synchronizing with prop changes.
useEffect(() => {
if (
modifications.length > 0 &&
chartComparisons.length > 0 &&
allChartsLoaded &&
status === 'idle'
) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: triggering async fetch based on prop changes
fetchInsights();
}
}, [modifications, chartComparisons, allChartsLoaded, status, fetchInsights]);
// Reset state when modifications are cleared.
// The setState calls here are intentional - we're resetting local state when props change.
useEffect(() => {
if (modifications.length === 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: resetting state when modifications cleared
setStatus('idle');
setResponse(null);
setError(null);
}
}, [modifications]);
// Group insights by type
const insights = response?.insights;
const groupedInsights = useMemo(() => {
if (!insights) return {} as GroupedWhatIfInsights;
return insights.reduce<GroupedWhatIfInsights>((acc, insight) => {
if (!acc[insight.type]) {
acc[insight.type] = [];
}
acc[insight.type].push(insight);
return acc;
}, {} as GroupedWhatIfInsights);
}, [insights]);
// Build collapse items from grouped insights
const collapseItems = useMemo(
() =>
INSIGHT_TYPE_ORDER.filter(type => groupedInsights[type]?.length > 0).map(
type => {
const typeInsights = groupedInsights[type];
const config = INSIGHT_TYPE_CONFIG[type];
const IconComponent = config.icon;
return {
key: type,
label: (
<CollapsePanelHeader insightType={type}>
<IconComponent iconSize="m" />
{config.label}
</CollapsePanelHeader>
),
children: typeInsights.map((insight, index) => (
<InsightCard key={index} insightType={insight.type}>
<InsightTitle>{insight.title}</InsightTitle>
<InsightDescription>{insight.description}</InsightDescription>
</InsightCard>
)),
};
},
),
[groupedInsights],
);
if (modifications.length === 0) {
return null;
}
return (
<InsightsContainer data-test="what-if-ai-insights">
<InsightsHeader>
<Icons.BulbOutlined iconSize="m" />
{t('AI Insights')}
</InsightsHeader>
{status === 'loading' && (
<Skeleton active paragraph={SKELETON_PARAGRAPH_3} />
)}
{status === 'error' && (
<Alert
type="error"
message={t('Failed to generate insights')}
description={error}
showIcon
/>
)}
{status === 'success' && response && (
<>
<Summary>{response.summary}</Summary>
<StyledCollapse
defaultActiveKey={INSIGHT_TYPE_ORDER}
items={collapseItems}
ghost
bordered
/>
</>
)}
{status === 'idle' && !allChartsLoaded && (
<Skeleton active paragraph={SKELETON_PARAGRAPH_2} />
)}
</InsightsContainer>
);
};
export default WhatIfAIInsights;

View File

@@ -0,0 +1,233 @@
/**
* 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 { useState, useCallback, Key } from 'react';
import { t } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/ui';
import { Menu } from '@superset-ui/core/components/Menu';
import {
NoAnimationDropdown,
Button,
Tooltip,
} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { Link } from 'react-router-dom';
import { WhatIfSimulation } from './whatIfApi';
enum MenuKeys {
LoadSimulation = 'load-simulation',
SaveSimulation = 'save-simulation',
SaveAsNew = 'save-as-new',
ManageSimulations = 'manage-simulations',
}
interface WhatIfHeaderMenuProps {
selectedSimulation: WhatIfSimulation | null;
onSelectSimulation: (simulation: WhatIfSimulation | null) => void;
onSaveClick: () => void;
onSaveAsNewClick: () => void;
hasModifications: boolean;
simulations: WhatIfSimulation[];
simulationsLoading: boolean;
}
const VerticalDotsTrigger = () => {
const theme = useTheme();
return (
<Icons.EllipsisOutlined
css={css`
transform: rotate(90deg);
&:hover {
cursor: pointer;
}
`}
iconSize="xl"
iconColor={theme.colorTextLabel}
/>
);
};
const WhatIfHeaderMenu = ({
selectedSimulation,
onSelectSimulation,
onSaveClick,
onSaveAsNewClick,
hasModifications,
simulations,
simulationsLoading,
}: WhatIfHeaderMenuProps) => {
const theme = useTheme();
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const handleMenuClick = useCallback(
({ key }: { key: Key }) => {
const keyStr = String(key);
if (keyStr === MenuKeys.SaveSimulation) {
onSaveClick();
setIsDropdownVisible(false);
} else if (keyStr === MenuKeys.SaveAsNew) {
onSaveAsNewClick();
setIsDropdownVisible(false);
} else if (keyStr.startsWith('load-sim-')) {
const simId = parseInt(keyStr.replace('load-sim-', ''), 10);
const sim = simulations.find(s => s.id === simId);
if (sim) {
onSelectSimulation(sim);
}
setIsDropdownVisible(false);
} else if (keyStr === 'clear-simulation') {
onSelectSimulation(null);
setIsDropdownVisible(false);
}
},
[simulations, onSelectSimulation, onSaveClick, onSaveAsNewClick],
);
const simulationMenuItems =
simulations.length > 0
? [
...(selectedSimulation
? [
{
key: 'clear-simulation',
label: t('Clear current simulation'),
icon: <Icons.CloseOutlined />,
},
{ type: 'divider' as const },
]
: []),
...simulations.map(sim => ({
key: `load-sim-${sim.id}`,
label: (
<div
css={css`
display: flex;
justify-content: space-between;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
`}
>
<span
css={css`
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`}
>
{sim.name}
{selectedSimulation?.id === sim.id && (
<Icons.CheckOutlined
css={css`
margin-left: ${theme.sizeUnit}px;
color: ${theme.colorSuccess};
`}
/>
)}
</span>
{sim.description && (
<Tooltip title={sim.description}>
<Icons.InfoCircleOutlined
onClick={e => e.stopPropagation()}
css={css`
color: ${theme.colorTextSecondary};
font-size: ${theme.fontSizeSM}px;
`}
/>
</Tooltip>
)}
</div>
),
})),
]
: [
{
key: 'no-simulations',
label: t('No saved simulations'),
disabled: true,
},
];
const menuItems = [
{
type: 'submenu' as const,
key: MenuKeys.LoadSimulation,
label: simulationsLoading ? t('Loading...') : t('Load simulation'),
icon: <Icons.FolderOpenOutlined />,
children: simulationMenuItems,
},
{
key: MenuKeys.SaveSimulation,
label: selectedSimulation ? t('Update simulation') : t('Save simulation'),
icon: <Icons.SaveOutlined />,
disabled: !hasModifications,
},
...(selectedSimulation
? [
{
key: MenuKeys.SaveAsNew,
label: t('Save as new'),
icon: <Icons.PlusOutlined />,
disabled: !hasModifications,
},
]
: []),
{ type: 'divider' as const },
{
key: MenuKeys.ManageSimulations,
label: <Link to="/whatif/simulations/">{t('Manage simulations')}</Link>,
icon: <Icons.SettingOutlined />,
},
];
return (
<NoAnimationDropdown
popupRender={() => (
<Menu
onClick={handleMenuClick}
data-test="what-if-header-menu"
selectable={false}
items={menuItems}
/>
)}
trigger={['click']}
placement="bottomRight"
open={isDropdownVisible}
onOpenChange={visible => setIsDropdownVisible(visible)}
>
<Button
buttonStyle="link"
aria-label={t('More Options')}
aria-haspopup="true"
css={css`
padding: 0;
display: flex;
align-items: center;
justify-content: center;
height: 20px;
`}
>
<VerticalDotsTrigger />
</Button>
</NoAnimationDropdown>
);
};
export default WhatIfHeaderMenu;

View File

@@ -0,0 +1,38 @@
/**
* 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.
*/
export const WHAT_IF_PANEL_WIDTH = 340;
export const SLIDER_MIN = -50;
export const SLIDER_MAX = 50;
export const SLIDER_DEFAULT = 0;
// Static slider marks - defined at module level to avoid recreation
export const SLIDER_MARKS: Record<number, string> = {
[SLIDER_MIN]: `${SLIDER_MIN}%`,
0: '0%',
[SLIDER_MAX]: `+${SLIDER_MAX}%`,
};
// Static tooltip formatter - defined at module level for stable reference
export const sliderTooltipFormatter = (value?: number): string =>
value !== undefined ? `${value > 0 ? '+' : ''}${value}%` : '';
// Memoized tooltip config object to prevent Slider re-renders
export const SLIDER_TOOLTIP_CONFIG = { formatter: sliderTooltipFormatter };

View File

@@ -0,0 +1,508 @@
/**
* 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, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/ui';
import {
Select,
Checkbox,
Tooltip,
Tag,
CheckboxChangeEvent,
} from '@superset-ui/core/components';
import Slider from '@superset-ui/core/components/Slider';
import { Icons } from '@superset-ui/core/components/Icons';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { useNumericColumns } from 'src/dashboard/util/useNumericColumns';
import { RootState, Datasource } from 'src/dashboard/types';
import WhatIfAIInsights from './WhatIfAIInsights';
import { useAllChartsLoaded } from './useChartComparison';
import HarryPotterWandLoader from './HarryPotterWandLoader';
import FilterButton from './FilterButton';
import ModificationsDisplay from './ModificationsDisplay';
import WhatIfHeaderMenu from './WhatIfHeaderMenu';
import SaveSimulationModal from './SaveSimulationModal';
import { useWhatIfFilters } from './useWhatIfFilters';
import { useWhatIfApply } from './useWhatIfApply';
import { WhatIfSimulation, fetchSimulations } from './whatIfApi';
import {
SLIDER_MIN,
SLIDER_MAX,
SLIDER_DEFAULT,
SLIDER_MARKS,
SLIDER_TOOLTIP_CONFIG,
WHAT_IF_PANEL_WIDTH,
} from './constants';
import {
PanelContainer,
PanelHeader,
PanelTitle,
CloseButton,
PanelContent,
FormSection,
Label,
SliderContainer,
ApplyButton,
CheckboxContainer,
ColumnSelectRow,
ColumnSelectWrapper,
FiltersSection,
FilterTagsContainer,
HeaderButtonsContainer,
} from './styles';
export { WHAT_IF_PANEL_WIDTH };
interface WhatIfPanelProps {
visible: boolean;
onClose: () => void;
topOffset: number;
initialSimulationId?: number | null;
}
const WhatIfPanel = ({
visible,
onClose,
topOffset,
initialSimulationId,
}: WhatIfPanelProps) => {
const theme = useTheme();
const { addSuccessToast, addDangerToast } = useToasts();
// Get dashboard ID from Redux
const dashboardId = useSelector((state: RootState) => state.dashboardInfo.id);
// Local state for column selection and slider
const [selectedColumn, setSelectedColumn] = useState<string | undefined>(
undefined,
);
const [sliderValue, setSliderValue] = useState<number>(SLIDER_DEFAULT);
const [enableCascadingEffects, setEnableCascadingEffects] = useState(false);
// Simulation state
const [selectedSimulation, setSelectedSimulation] =
useState<WhatIfSimulation | null>(null);
const [saveModalVisible, setSaveModalVisible] = useState(false);
const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]);
const [simulationsLoading, setSimulationsLoading] = useState(false);
// Track if initial simulation from URL has been loaded
const initialSimulationLoadedRef = useRef(false);
// Custom hook for filter management
const {
filters,
filterPopoverVisible,
currentAdhocFilter,
setFilterPopoverVisible,
setFilters,
handleOpenFilterPopover,
handleEditFilter,
handleFilterChange,
handleRemoveFilter,
handleFilterPopoverClose,
handleFilterPopoverResize,
clearFilters,
formatFilterLabel,
} = useWhatIfFilters();
// Custom hook for apply logic and modifications
const {
appliedModifications,
affectedChartIds,
isLoadingSuggestions,
applyCounter,
handleApply,
handleDismissLoader,
aiInsightsModifications,
loadModificationsDirectly,
clearModifications,
interpretAbortRef,
} = useWhatIfApply({
selectedColumn,
sliderValue,
filters,
enableCascadingEffects,
});
// Get numeric columns and datasources
const { numericColumns, columnToChartIds } = useNumericColumns();
const datasources = useSelector((state: RootState) => state.datasources);
// Get all chart IDs that could be affected by what-if analysis
const allDashboardChartIds = useMemo(() => {
const chartIds = new Set<number>();
columnToChartIds.forEach(ids => ids.forEach(id => chartIds.add(id)));
return Array.from(chartIds);
}, [columnToChartIds]);
// Check if all dashboard charts have completed their initial load
const initialChartsLoaded = useAllChartsLoaded(allDashboardChartIds);
// Column options for the select dropdown
const columnOptions = useMemo(
() =>
numericColumns.map(col => ({
value: col.columnName,
label: col.verboseName || col.columnName,
})),
[numericColumns],
);
// Find info about the selected column
const selectedColumnInfo = useMemo(
() => numericColumns.find(col => col.columnName === selectedColumn),
[numericColumns, selectedColumn],
);
// Find the datasource for the selected column
const selectedDatasource = useMemo((): Datasource | null => {
if (!selectedColumnInfo) return null;
// Find datasource by ID - keys are in format "id__type"
const datasourceEntry = Object.entries(datasources).find(([key]) => {
const [idStr] = key.split('__');
return parseInt(idStr, 10) === selectedColumnInfo.datasourceId;
});
return datasourceEntry ? datasourceEntry[1] : null;
}, [datasources, selectedColumnInfo]);
// Get all columns from the selected datasource for filter options
const filterColumnOptions = useMemo(() => {
if (!selectedDatasource?.columns) return [];
return selectedDatasource.columns;
}, [selectedDatasource]);
// Handle column selection change - also clears filters
const handleColumnChange = useCallback(
(value: string | undefined) => {
setSelectedColumn(value);
// Clear filters when column changes since they're tied to the datasource
clearFilters();
},
[clearFilters],
);
const handleSliderChange = useCallback((value: number) => {
setSliderValue(value);
}, []);
const handleCascadingEffectsChange = useCallback((e: CheckboxChangeEvent) => {
setEnableCascadingEffects(e.target.checked);
}, []);
// Handle loading a saved simulation
const handleLoadSimulation = useCallback(
(simulation: WhatIfSimulation | null) => {
setSelectedSimulation(simulation);
if (simulation && simulation.modifications.length > 0) {
const firstMod = simulation.modifications[0];
setSelectedColumn(firstMod.column);
setSliderValue((firstMod.multiplier - 1) * 100);
setEnableCascadingEffects(simulation.cascadingEffectsEnabled);
// Load filters from the first modification
if (firstMod.filters && firstMod.filters.length > 0) {
setFilters(firstMod.filters);
} else {
clearFilters();
}
// Convert to extended modifications with isAISuggested flag
// First modification is the user's, rest are AI-suggested
const extendedModifications = simulation.modifications.map(
(mod, index) => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters,
isAISuggested: index > 0,
}),
);
// Load all modifications directly and trigger chart queries + /interpret
loadModificationsDirectly(extendedModifications);
} else if (!simulation) {
// Clear state when deselecting
setSelectedColumn(undefined);
setSliderValue(SLIDER_DEFAULT);
setEnableCascadingEffects(false);
clearFilters();
clearModifications();
}
},
[clearFilters, setFilters, loadModificationsDirectly, clearModifications],
);
// Load simulations list from API
const loadSimulations = useCallback(async () => {
if (!dashboardId) return;
setSimulationsLoading(true);
try {
const result = await fetchSimulations(dashboardId);
setSimulations(result);
} catch (error) {
addDangerToast(t('Failed to load saved simulations'));
} finally {
setSimulationsLoading(false);
}
}, [dashboardId, addDangerToast]);
// Fetch simulations when dashboard is ready
useEffect(() => {
if (dashboardId) {
loadSimulations();
}
}, [dashboardId, loadSimulations]);
// Load initial simulation from URL parameter
// Wait until:
// 1. simulations are loaded (from the earlier useEffect)
// 2. columnToChartIds is populated (chart metadata is available)
// 3. initialChartsLoaded is true (charts have finished their initial queries)
// This ensures we can properly save original chart data before applying what-if modifications
useEffect(() => {
if (
initialSimulationId &&
!initialSimulationLoadedRef.current &&
simulations.length > 0 &&
columnToChartIds.size > 0 &&
initialChartsLoaded
) {
initialSimulationLoadedRef.current = true;
const simulation = simulations.find(s => s.id === initialSimulationId);
if (simulation) {
handleLoadSimulation(simulation);
} else {
addDangerToast(t('Simulation not found'));
}
}
}, [
initialSimulationId,
simulations,
addDangerToast,
columnToChartIds.size,
initialChartsLoaded,
handleLoadSimulation,
]);
// Handle saving a simulation
const handleSaveSimulation = useCallback(
(simulation: WhatIfSimulation) => {
setSelectedSimulation(simulation);
// Refresh the simulations list to include the newly saved simulation
loadSimulations();
},
[loadSimulations],
);
// Track if we're saving as new (vs updating existing)
const [isSavingAsNew, setIsSavingAsNew] = useState(false);
const handleOpenSaveModal = useCallback(() => {
setIsSavingAsNew(false);
setSaveModalVisible(true);
}, []);
const handleOpenSaveAsNewModal = useCallback(() => {
setIsSavingAsNew(true);
setSaveModalVisible(true);
}, []);
const handleCloseSaveModal = useCallback(() => {
setSaveModalVisible(false);
setIsSavingAsNew(false);
}, []);
const isApplyDisabled =
!selectedColumn || sliderValue === SLIDER_DEFAULT || isLoadingSuggestions;
return (
<PanelContainer
data-test="what-if-panel"
topOffset={topOffset}
visible={visible}
>
<PanelHeader>
<PanelTitle>
<Icons.StarFilled
iconSize="m"
css={css`
color: ${theme.colorWarning};
`}
/>
{t('What-if playground')}
</PanelTitle>
<HeaderButtonsContainer>
<WhatIfHeaderMenu
selectedSimulation={selectedSimulation}
onSelectSimulation={handleLoadSimulation}
onSaveClick={handleOpenSaveModal}
onSaveAsNewClick={handleOpenSaveAsNewModal}
hasModifications={appliedModifications.length > 0}
simulations={simulations}
simulationsLoading={simulationsLoading}
/>
<CloseButton onClick={onClose} aria-label={t('Close')}>
<Icons.CloseOutlined iconSize="m" />
</CloseButton>
</HeaderButtonsContainer>
</PanelHeader>
<PanelContent>
<FormSection>
<Label>{t('Select column to adjust')}</Label>
<ColumnSelectRow>
<ColumnSelectWrapper>
<Select
value={selectedColumn}
onChange={handleColumnChange}
options={columnOptions}
placeholder={t('Choose a column...')}
allowClear
showSearch
ariaLabel={t('Select column to adjust')}
/>
</ColumnSelectWrapper>
<FilterButton
filterPopoverVisible={filterPopoverVisible}
currentAdhocFilter={currentAdhocFilter}
selectedColumn={selectedColumn}
selectedDatasource={selectedDatasource}
filterColumnOptions={filterColumnOptions}
onOpenFilterPopover={handleOpenFilterPopover}
onFilterPopoverVisibleChange={setFilterPopoverVisible}
onFilterChange={handleFilterChange}
onFilterPopoverClose={handleFilterPopoverClose}
onFilterPopoverResize={handleFilterPopoverResize}
/>
</ColumnSelectRow>
{filters.length > 0 && (
<FiltersSection>
<Label
css={css`
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary};
`}
>
{t('Filters')}
</Label>
<FilterTagsContainer>
{filters.map((filter, index) => (
<Tag
key={`${filter.col}-${filter.op}-${index}`}
closable
onClose={e => handleRemoveFilter(e, index)}
onClick={() => handleEditFilter(index)}
css={css`
cursor: pointer;
margin: 0;
&:hover {
opacity: 0.8;
}
`}
>
{formatFilterLabel(filter)}
</Tag>
))}
</FilterTagsContainer>
</FiltersSection>
)}
</FormSection>
<FormSection>
<Label>{t('Adjust value')}</Label>
<SliderContainer>
<Slider
min={SLIDER_MIN}
max={SLIDER_MAX}
value={sliderValue}
onChange={handleSliderChange}
marks={SLIDER_MARKS}
tooltip={SLIDER_TOOLTIP_CONFIG}
/>
</SliderContainer>
</FormSection>
<CheckboxContainer>
<Checkbox
checked={enableCascadingEffects}
onChange={handleCascadingEffectsChange}
>
{t('Show the bigger picture with AI')}
</Checkbox>
<Tooltip
title={t(
'Automatically includes related metrics and columns affected by this change. AI infers relationships based on how metrics and columns are used across the dashboard.',
)}
>
<Icons.InfoCircleOutlined
iconSize="s"
css={css`
color: ${theme.colorTextSecondary};
cursor: help;
`}
/>
</Tooltip>
</CheckboxContainer>
<ApplyButton
buttonStyle="primary"
onClick={handleApply}
disabled={isApplyDisabled}
loading={isLoadingSuggestions}
>
<Icons.StarFilled iconSize="s" />
{isLoadingSuggestions
? t('Analyzing relationships...')
: t('See what if')}
</ApplyButton>
<ModificationsDisplay modifications={appliedModifications} />
{affectedChartIds.length > 0 && (
<WhatIfAIInsights
key={applyCounter}
affectedChartIds={affectedChartIds}
modifications={aiInsightsModifications}
abortRef={interpretAbortRef}
/>
)}
</PanelContent>
{isLoadingSuggestions && (
<HarryPotterWandLoader onDismiss={handleDismissLoader} />
)}
<SaveSimulationModal
show={saveModalVisible}
onHide={handleCloseSaveModal}
onSaved={handleSaveSimulation}
dashboardId={dashboardId}
modifications={appliedModifications}
cascadingEffectsEnabled={enableCascadingEffects}
existingSimulation={isSavingAsNew ? null : selectedSimulation}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
/>
</PanelContainer>
);
};
export default WhatIfPanel;

View File

@@ -0,0 +1,216 @@
/**
* 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 { styled } from '@apache-superset/core/ui';
import { Button } from '@superset-ui/core/components';
import { WHAT_IF_PANEL_WIDTH } from './constants';
export const PanelContainer = styled.div<{
topOffset: number;
visible: boolean;
}>`
grid-column: 2;
grid-row: 1 / -1; /* Span all rows */
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: ${({ visible }) => (visible ? 'flex' : 'none')};
flex-direction: column;
overflow: hidden;
position: sticky;
top: ${({ topOffset }) => topOffset}px;
height: calc(100vh - ${({ topOffset }) => topOffset}px);
align-self: start;
z-index: 10;
`;
export 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};
`;
export 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;
`;
export 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};
}
`;
export const PanelContent = styled.div`
flex: 1;
overflow-y: auto;
padding: ${({ theme }) => theme.sizeUnit * 4}px;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 5}px;
`;
export const FormSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
export const Label = styled.label`
color: ${({ theme }) => theme.colorText};
`;
export const SliderContainer = styled.div`
padding: 0 ${({ theme }) => theme.sizeUnit}px;
& .ant-slider-mark {
font-size: ${({ theme }) => theme.fontSizeSM}px;
}
`;
export const ApplyButton = styled(Button)`
width: 100%;
min-height: 32px;
`;
export const CheckboxContainer = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit}px;
`;
export const ModificationsSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 5}px;
`;
export const ModificationTagsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
export const AIBadge = styled.span`
font-size: 10px;
padding: 0 4px;
background-color: ${({ theme }) => theme.colorInfo};
color: ${({ theme }) => theme.colorWhite};
border-radius: 16px;
line-height: 1.2;
`;
export const AIReasoningSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit}px;
`;
export const AIReasoningToggle = styled.button`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit}px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: ${({ theme }) => theme.colorTextTertiary};
font-size: ${({ theme }) => theme.fontSizeSM}px;
&:hover {
color: ${({ theme }) => theme.colorText};
}
`;
export const AIReasoningContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit}px;
padding-left: ${({ theme }) => theme.sizeUnit * 4}px;
`;
export const AIReasoningItem = styled.div`
font-size: ${({ theme }) => theme.fontSizeSM}px;
color: ${({ theme }) => theme.colorTextSecondary};
`;
export const ColumnSelectRow = styled.div`
display: flex;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
align-items: flex-start;
`;
export const ColumnSelectWrapper = styled.div`
flex: 1;
min-width: 0;
`;
export const FilterButtonStyled = styled(Button)`
flex-shrink: 0;
padding: 0 ${({ theme }) => theme.sizeUnit * 2}px;
`;
export const FilterPopoverContent = styled.div`
.edit-popover-resize {
transform: scaleX(-1);
float: right;
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
margin-right: ${({ theme }) => theme.sizeUnit * -1}px;
color: ${({ theme }) => theme.colorIcon};
cursor: nwse-resize;
}
.filter-sql-editor {
border: ${({ theme }) => theme.colorBorder} solid thin;
}
`;
export const FiltersSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
export const FilterTagsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.sizeUnit}px;
`;
export const HeaderButtonsContainer = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit}px;
`;

View File

@@ -0,0 +1,114 @@
/**
* 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 shared types for internal use
import type {
WhatIfFilter,
WhatIfFilterOperator,
WhatIfModification,
} from 'src/dashboard/types';
// Re-export shared types from dashboard/types.ts
export type { WhatIfFilter, WhatIfFilterOperator, WhatIfModification };
// Types specific to chart comparison display
export interface ChartMetricComparison {
metricName: string;
originalValue: number;
modifiedValue: number;
percentageChange: number;
}
export interface ChartComparison {
chartId: number;
chartName: string;
chartType: string;
metrics: ChartMetricComparison[];
}
// Types for /interpret API endpoint
export interface WhatIfInterpretRequest {
modifications: Array<{
column: string;
multiplier: number;
filters?: WhatIfFilter[];
}>;
charts: ChartComparison[];
dashboardName?: string;
}
export type WhatIfInsightType =
| 'observation'
| 'implication'
| 'recommendation';
export interface WhatIfInsight {
title: string;
description: string;
type: WhatIfInsightType;
}
export interface WhatIfInterpretResponse {
summary: string;
insights: WhatIfInsight[];
rawResponse?: string;
}
export type GroupedWhatIfInsights = Record<WhatIfInsightType, WhatIfInsight[]>;
export type WhatIfAIStatus = 'idle' | 'loading' | 'success' | 'error';
// Types for /suggest_related API endpoint
export interface AvailableColumn {
columnName: string;
description?: string | null;
verboseName?: string | null;
datasourceId: number;
}
export interface SuggestedModification {
column: string;
multiplier: number;
reasoning: string;
confidence: 'high' | 'medium' | 'low';
}
export interface WhatIfSuggestRelatedRequest {
selectedColumn: string;
userMultiplier: number;
availableColumns: AvailableColumn[];
dashboardName?: string;
}
export interface WhatIfSuggestRelatedResponse {
suggestedModifications: SuggestedModification[];
explanation?: string;
}
// Extended modification type that tracks whether it came from AI
export interface ExtendedWhatIfModification {
column: string;
multiplier: number;
filters?: WhatIfFilter[];
isAISuggested?: boolean;
reasoning?: string;
confidence?: 'high' | 'medium' | 'low';
/** Human-readable column label for display */
verboseName?: string | null;
}

View File

@@ -0,0 +1,334 @@
/**
* 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 } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { QueryData } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import {
ActiveTabs,
DashboardLayout,
RootState,
Slice,
} from 'src/dashboard/types';
import { ChartComparison, ChartMetricComparison } from './types';
import { CHART_TYPE, TAB_TYPE } from 'src/dashboard/util/componentTypes';
interface ChartStateWithOriginal {
chartStatus?: string;
queriesResponse?: QueryData[] | null;
originalQueriesResponse?: QueryData[] | null;
}
interface QueryResponse {
data?: Array<Record<string, unknown>>;
colnames?: string[];
coltypes?: GenericDataType[];
}
function extractMetricValue(
data: Array<Record<string, unknown>> | undefined,
metricName: string,
): number | null {
if (!data || data.length === 0) return null;
// Sum all values for the metric across rows
let total = 0;
let found = false;
for (const row of data) {
if (metricName in row) {
const value = row[metricName];
if (typeof value === 'number' && !Number.isNaN(value)) {
total += value;
found = true;
}
}
}
return found ? total : null;
}
function isNumericColumn(
data: Array<Record<string, unknown>> | undefined,
colName: string,
): boolean {
if (!data || data.length === 0) return false;
for (const row of data) {
if (colName in row) {
const value = row[colName];
return typeof value === 'number';
}
}
return false;
}
/**
* Hook to get a function that checks if a chart is in an active tab.
* A chart is considered visible if:
* 1. It has no tab parents (not inside any tab)
* 2. All of its tab parents are in the active tabs list
*/
export function useIsChartInActiveTab() {
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout?.present,
);
const activeTabs = useSelector<RootState, ActiveTabs>(
state => state.dashboardState?.activeTabs,
);
const layoutChartItems = useMemo(
() =>
Object.values(dashboardLayout || {}).filter(
item => item.type === CHART_TYPE,
),
[dashboardLayout],
);
return useCallback(
(chartId: number): boolean => {
const chartLayoutItem = layoutChartItems.find(
layoutItem => layoutItem.meta?.chartId === chartId,
);
const tabParents = chartLayoutItem?.parents?.filter(
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
);
// Chart is visible if it has no tab parents or all tab parents are active
return (
!tabParents ||
tabParents.length === 0 ||
tabParents.every(tab => activeTabs?.includes(tab))
);
},
[dashboardLayout, layoutChartItems, activeTabs],
);
}
/**
* Filter chart IDs to only include those in active tabs.
*/
export function useChartsInActiveTabs(chartIds: number[]): number[] {
const isChartInActiveTab = useIsChartInActiveTab();
return useMemo(
() => chartIds.filter(isChartInActiveTab),
[chartIds, isChartInActiveTab],
);
}
interface ChartComparisonData {
chartStatus?: string;
originalData?: Array<Record<string, unknown>>;
modifiedData?: Array<Record<string, unknown>>;
colnames?: string[];
coltypes?: GenericDataType[];
}
/**
* Selector that extracts only the comparison-relevant data for specific chart IDs.
* This avoids re-renders when unrelated chart data changes.
*/
function useChartComparisonData(
chartIds: number[],
): Record<number, ChartComparisonData> {
return useSelector((state: RootState) => {
const result: Record<number, ChartComparisonData> = {};
for (const chartId of chartIds) {
const chartState = state.charts[chartId] as
| ChartStateWithOriginal
| undefined;
if (chartState) {
const originalResponse = chartState.originalQueriesResponse?.[0] as
| QueryResponse
| undefined;
const modifiedResponse = chartState.queriesResponse?.[0] as
| QueryResponse
| undefined;
result[chartId] = {
chartStatus: chartState.chartStatus,
originalData: originalResponse?.data,
modifiedData: modifiedResponse?.data,
colnames: modifiedResponse?.colnames,
coltypes: modifiedResponse?.coltypes,
};
}
}
return result;
}, shallowEqual);
}
/**
* Selector that extracts chart display names and viz types for specific chart IDs.
* Uses sliceNameOverride from dashboard layout if available, otherwise falls back to slice_name.
*/
function useChartDisplayData(
chartIds: number[],
): Record<number, { displayName: string; viz_type: string }> {
return useSelector((state: RootState) => {
const slices = state.sliceEntities.slices as { [id: number]: Slice };
const dashboardLayout = state.dashboardLayout?.present;
const result: Record<number, { displayName: string; viz_type: string }> =
{};
// Build a map of chartId -> sliceNameOverride from dashboard layout
const nameOverrides: Record<number, string | undefined> = {};
if (dashboardLayout) {
for (const item of Object.values(dashboardLayout)) {
if (item.type === CHART_TYPE && item.meta?.chartId) {
nameOverrides[item.meta.chartId] = item.meta.sliceNameOverride;
}
}
}
for (const chartId of chartIds) {
const slice = slices[chartId];
if (slice) {
result[chartId] = {
displayName: nameOverrides[chartId] || slice.slice_name,
viz_type: slice.viz_type,
};
}
}
return result;
}, shallowEqual);
}
export function useChartComparison(
affectedChartIds: number[],
): ChartComparison[] {
const visibleChartIds = useChartsInActiveTabs(affectedChartIds);
const chartData = useChartComparisonData(visibleChartIds);
const chartDisplayData = useChartDisplayData(visibleChartIds);
return useMemo(() => {
const comparisons: ChartComparison[] = [];
for (const chartId of visibleChartIds) {
const chartState = chartData[chartId];
const displayData = chartDisplayData[chartId];
if (!chartState || !displayData) continue;
const { originalData, modifiedData } = chartState;
if (!originalData || !modifiedData) continue;
// Skip if original and modified data are the same reference
// This indicates the what-if query hasn't completed yet (race condition guard)
if (originalData === modifiedData) {
continue;
}
// Get column names and types from the response
const colnames = chartState.colnames || [];
const coltypes = chartState.coltypes || [];
const metrics: ChartMetricComparison[] = [];
for (let i = 0; i < colnames.length; i += 1) {
const metricName = colnames[i];
const coltype = coltypes[i];
// Only include numeric columns (not temporal/date, string, or boolean)
// This filters out x-axis date columns and dimension columns
// If coltypes is available, use it; otherwise fall back to runtime check
if (coltype !== undefined && coltype !== GenericDataType.Numeric) {
continue;
}
// Runtime check: verify the column actually contains numeric values
// This also catches cases where coltypes is not available
if (!isNumericColumn(modifiedData, metricName)) continue;
const originalValue = extractMetricValue(originalData, metricName);
const modifiedValue = extractMetricValue(modifiedData, metricName);
if (
originalValue !== null &&
modifiedValue !== null &&
originalValue !== 0
) {
const percentageChange =
((modifiedValue - originalValue) / Math.abs(originalValue)) * 100;
metrics.push({
metricName,
originalValue,
modifiedValue,
percentageChange,
});
}
}
if (metrics.length > 0) {
comparisons.push({
chartId,
chartName: displayData.displayName,
chartType: displayData.viz_type,
metrics,
});
}
}
return comparisons;
}, [chartData, chartDisplayData, visibleChartIds]);
}
/**
* Selector that extracts only loading statuses for specific chart IDs.
*/
function useChartLoadingStatuses(
chartIds: number[],
): Record<number, string | undefined> {
return useSelector((state: RootState) => {
const result: Record<number, string | undefined> = {};
for (const chartId of chartIds) {
const chartState = state.charts[chartId] as
| ChartStateWithOriginal
| undefined;
result[chartId] = chartState?.chartStatus;
}
return result;
}, shallowEqual);
}
/**
* Check if all affected charts (in active tabs) have finished loading.
* Returns true only if ALL visible charts are in a definitive complete state
* ('success' or 'rendered'). This prevents race conditions where charts
* might briefly be in an intermediate state.
*/
export function useAllChartsLoaded(chartIds: number[]): boolean {
const visibleChartIds = useChartsInActiveTabs(chartIds);
const chartStatuses = useChartLoadingStatuses(visibleChartIds);
return useMemo(() => {
// Require explicit completion status, not just "not loading"
// This prevents race conditions during state transitions
// Include 'failed' to avoid waiting indefinitely for charts that errored
const completeStatuses = ['success', 'rendered', 'failed'];
return (
visibleChartIds.length > 0 &&
visibleChartIds.every(id =>
completeStatuses.includes(chartStatuses[id] ?? ''),
)
);
}, [chartStatuses, visibleChartIds]);
}

View File

@@ -0,0 +1,334 @@
/**
* 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, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { logging } from '@superset-ui/core';
import { setWhatIfModifications } from 'src/dashboard/actions/dashboardState';
import {
triggerQuery,
saveOriginalChartData,
} from 'src/components/Chart/chartAction';
import { RootState, WhatIfFilter } from 'src/dashboard/types';
import { useNumericColumns } from 'src/dashboard/util/useNumericColumns';
import { fetchRelatedColumnSuggestions } from './whatIfApi';
import { ExtendedWhatIfModification, WhatIfModification } from './types';
export interface UseWhatIfApplyParams {
selectedColumn: string | undefined;
sliderValue: number;
filters: WhatIfFilter[];
enableCascadingEffects: boolean;
}
export interface UseWhatIfApplyReturn {
appliedModifications: ExtendedWhatIfModification[];
affectedChartIds: number[];
isLoadingSuggestions: boolean;
applyCounter: number;
handleApply: () => Promise<void>;
handleDismissLoader: () => void;
aiInsightsModifications: WhatIfModification[];
loadModificationsDirectly: (
modifications: ExtendedWhatIfModification[],
) => void;
clearModifications: () => void;
/** Ref to register an abort function from WhatIfAIInsights */
interpretAbortRef: React.MutableRefObject<(() => void) | null>;
}
/**
* Custom hook for managing what-if apply logic and modifications state.
* Handles:
* - Applied modifications tracking
* - AI suggestions fetching with cascading effects
* - Redux dispatching for what-if state
* - Chart query triggering
*/
export function useWhatIfApply({
selectedColumn,
sliderValue,
filters,
enableCascadingEffects,
}: UseWhatIfApplyParams): UseWhatIfApplyReturn {
const dispatch = useDispatch();
const [appliedModifications, setAppliedModifications] = useState<
ExtendedWhatIfModification[]
>([]);
const [affectedChartIds, setAffectedChartIds] = useState<number[]>([]);
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
// Counter that increments each time Apply is clicked, used as key to reset AI insights
const [applyCounter, setApplyCounter] = useState(0);
// AbortController for cancelling in-flight /suggest_related requests
const suggestionsAbortControllerRef = useRef<AbortController | null>(null);
// Ref to hold the abort function from WhatIfAIInsights for /interpret requests
const interpretAbortRef = useRef<(() => void) | null>(null);
const { numericColumns, columnToChartIds } = useNumericColumns();
const dashboardInfo = useSelector((state: RootState) => state.dashboardInfo);
// Cleanup: cancel any pending requests on unmount
useEffect(
() => () => {
suggestionsAbortControllerRef.current?.abort();
},
[],
);
const handleApply = useCallback(async () => {
if (!selectedColumn) return;
// Cancel any in-flight requests
suggestionsAbortControllerRef.current?.abort();
interpretAbortRef.current?.();
// Immediately clear previous results and increment counter to reset AI insights component
setAppliedModifications([]);
setAffectedChartIds([]);
setApplyCounter(c => c + 1);
const multiplier = 1 + sliderValue / 100;
// Find verbose name for the selected column
const selectedColumnInfo = numericColumns.find(
col => col.columnName === selectedColumn,
);
// Base user modification with filters
const userModification: ExtendedWhatIfModification = {
column: selectedColumn,
multiplier,
isAISuggested: false,
filters: filters.length > 0 ? filters : undefined,
verboseName: selectedColumnInfo?.verboseName,
};
let allModifications: ExtendedWhatIfModification[] = [userModification];
// If cascading effects enabled, fetch AI suggestions
if (enableCascadingEffects) {
// Create a new AbortController for this request
const abortController = new AbortController();
suggestionsAbortControllerRef.current = abortController;
setIsLoadingSuggestions(true);
try {
const suggestions = await fetchRelatedColumnSuggestions(
{
selectedColumn,
userMultiplier: multiplier,
availableColumns: numericColumns.map(col => ({
columnName: col.columnName,
description: col.description,
verboseName: col.verboseName,
datasourceId: col.datasourceId,
})),
dashboardName: dashboardInfo?.dash_edit_perm
? dashboardInfo?.dashboard_title
: undefined,
},
abortController.signal,
);
// Add AI suggestions to modifications (with same filters as user modification)
const aiModifications: ExtendedWhatIfModification[] =
suggestions.suggestedModifications.map(mod => {
const colInfo = numericColumns.find(
col => col.columnName === mod.column,
);
return {
column: mod.column,
multiplier: mod.multiplier,
isAISuggested: true,
reasoning: mod.reasoning,
confidence: mod.confidence,
filters: filters.length > 0 ? filters : undefined,
verboseName: colInfo?.verboseName,
};
});
allModifications = [...allModifications, ...aiModifications];
} catch (error) {
// Don't log or update state if the request was aborted
if (error instanceof Error && error.name === 'AbortError') {
return;
}
logging.error('Failed to get AI suggestions:', error);
// Continue with just user modification
}
setIsLoadingSuggestions(false);
}
setAppliedModifications(allModifications);
// Collect all affected chart IDs from all modifications
const allAffectedChartIds = new Set<number>();
allModifications.forEach(mod => {
const chartIds = columnToChartIds.get(mod.column) || [];
chartIds.forEach(id => allAffectedChartIds.add(id));
});
const chartIdsArray = Array.from(allAffectedChartIds);
// Save original chart data before applying what-if modifications
chartIdsArray.forEach(chartId => {
dispatch(saveOriginalChartData(chartId));
});
// Set the what-if modifications in Redux state (all modifications)
dispatch(
setWhatIfModifications(
allModifications.map(mod => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters,
})),
),
);
// Trigger queries for all affected charts
// This sets chart status to 'loading', which is important for AI insights timing
chartIdsArray.forEach(chartId => {
dispatch(triggerQuery(true, chartId));
});
// Set affected chart IDs AFTER Redux updates and query triggers
// This ensures WhatIfAIInsights mounts when charts are already loading,
// preventing it from immediately fetching with stale data
setAffectedChartIds(chartIdsArray);
}, [
dispatch,
selectedColumn,
sliderValue,
columnToChartIds,
enableCascadingEffects,
numericColumns,
dashboardInfo,
filters,
]);
const handleDismissLoader = useCallback(() => {
suggestionsAbortControllerRef.current?.abort();
setIsLoadingSuggestions(false);
}, []);
/**
* Load modifications directly without fetching AI suggestions.
* Used when loading a saved simulation.
*/
const loadModificationsDirectly = useCallback(
(modifications: ExtendedWhatIfModification[]) => {
// Cancel any in-flight requests
suggestionsAbortControllerRef.current?.abort();
interpretAbortRef.current?.();
setIsLoadingSuggestions(false);
// Increment counter to reset AI insights component
setApplyCounter(c => c + 1);
// Populate verbose names for loaded modifications
const modificationsWithVerboseNames = modifications.map(mod => {
const colInfo = numericColumns.find(
col => col.columnName === mod.column,
);
return {
...mod,
verboseName: mod.verboseName || colInfo?.verboseName,
};
});
setAppliedModifications(modificationsWithVerboseNames);
// Collect all affected chart IDs from all modifications
const allAffectedChartIds = new Set<number>();
modifications.forEach(mod => {
const chartIds = columnToChartIds.get(mod.column) || [];
chartIds.forEach(id => allAffectedChartIds.add(id));
});
const chartIdsArray = Array.from(allAffectedChartIds);
// Save original chart data before applying what-if modifications
chartIdsArray.forEach(chartId => {
dispatch(saveOriginalChartData(chartId));
});
// Set the what-if modifications in Redux state
dispatch(
setWhatIfModifications(
modificationsWithVerboseNames.map(mod => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters,
})),
),
);
// Trigger queries for all affected charts
chartIdsArray.forEach(chartId => {
dispatch(triggerQuery(true, chartId));
});
// Set affected chart IDs to enable AI insights
setAffectedChartIds(chartIdsArray);
},
[dispatch, columnToChartIds, numericColumns],
);
/**
* Clear all modifications and reset state.
*/
const clearModifications = useCallback(() => {
// Cancel any in-flight requests
suggestionsAbortControllerRef.current?.abort();
interpretAbortRef.current?.();
setIsLoadingSuggestions(false);
setAppliedModifications([]);
setAffectedChartIds([]);
dispatch(setWhatIfModifications([]));
}, [dispatch]);
// Memoize modifications array for WhatIfAIInsights to prevent unnecessary re-renders
const aiInsightsModifications = useMemo(
() =>
appliedModifications.map(mod => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters,
})),
[appliedModifications],
);
return {
appliedModifications,
affectedChartIds,
isLoadingSuggestions,
applyCounter,
handleApply,
handleDismissLoader,
aiInsightsModifications,
loadModificationsDirectly,
clearModifications,
interpretAbortRef,
};
}
export default useWhatIfApply;

View File

@@ -0,0 +1,229 @@
/**
* 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, useState } from 'react';
import { formatTimeRangeLabel } from '@superset-ui/core';
import { WhatIfFilter } from 'src/dashboard/types';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { Clauses } from 'src/explore/components/controls/FilterControl/types';
import { OPERATOR_ENUM_TO_OPERATOR_TYPE } from 'src/explore/constants';
export interface UseWhatIfFiltersReturn {
filters: WhatIfFilter[];
filterPopoverVisible: boolean;
editingFilterIndex: number | null;
currentAdhocFilter: AdhocFilter | null;
setFilterPopoverVisible: (visible: boolean) => void;
setFilters: (filters: WhatIfFilter[]) => void;
handleOpenFilterPopover: () => void;
handleEditFilter: (index: number) => void;
handleFilterChange: (adhocFilter: AdhocFilter) => void;
handleRemoveFilter: (e: React.MouseEvent, index: number) => void;
handleFilterPopoverClose: () => void;
handleFilterPopoverResize: () => void;
clearFilters: () => void;
formatFilterLabel: (filter: WhatIfFilter) => string;
}
/**
* Custom hook for managing what-if filter state and operations.
* Encapsulates all filter-related logic including:
* - Filter CRUD operations
* - AdhocFilter <-> WhatIfFilter conversions
* - Popover state management
* - Filter label formatting
*/
export function useWhatIfFilters(): UseWhatIfFiltersReturn {
const [filters, setFilters] = useState<WhatIfFilter[]>([]);
const [filterPopoverVisible, setFilterPopoverVisible] = useState(false);
const [editingFilterIndex, setEditingFilterIndex] = useState<number | null>(
null,
);
const [currentAdhocFilter, setCurrentAdhocFilter] =
useState<AdhocFilter | null>(null);
// Convert AdhocFilter to WhatIfFilter
const adhocFilterToWhatIfFilter = useCallback(
(adhocFilter: AdhocFilter): WhatIfFilter | null => {
if (!adhocFilter.isValid()) return null;
const { subject, operator, comparator } = adhocFilter;
if (!subject || !operator) return null;
// Map operator to WhatIfFilterOperator
let op = operator as WhatIfFilter['op'];
// Handle operator mapping
if (operator === 'TEMPORAL_RANGE') {
op = 'TEMPORAL_RANGE';
} else if (operator === 'IN' || operator === 'in') {
op = 'IN';
} else if (operator === 'NOT IN' || operator === 'not in') {
op = 'NOT IN';
}
return {
col: subject,
op,
val: comparator,
};
},
[],
);
// Convert WhatIfFilter to AdhocFilter for editing
const whatIfFilterToAdhocFilter = useCallback(
(filter: WhatIfFilter): AdhocFilter => {
// Find the operatorId from the operator
let operatorId: string | undefined;
for (const [key, value] of Object.entries(
OPERATOR_ENUM_TO_OPERATOR_TYPE,
)) {
if (value.operation === filter.op) {
operatorId = key;
break;
}
}
return new AdhocFilter({
expressionType: 'SIMPLE',
subject: filter.col,
operator: filter.op,
operatorId,
comparator: filter.val,
clause: Clauses.Where,
});
},
[],
);
const handleOpenFilterPopover = useCallback(() => {
// Create a new empty AdhocFilter
const newFilter = new AdhocFilter({
expressionType: 'SIMPLE',
clause: Clauses.Where,
subject: null,
operator: null,
comparator: null,
isNew: true,
});
setCurrentAdhocFilter(newFilter);
setEditingFilterIndex(null);
setFilterPopoverVisible(true);
}, []);
const handleEditFilter = useCallback(
(index: number) => {
const filter = filters[index];
const adhocFilter = whatIfFilterToAdhocFilter(filter);
setCurrentAdhocFilter(adhocFilter);
setEditingFilterIndex(index);
setFilterPopoverVisible(true);
},
[filters, whatIfFilterToAdhocFilter],
);
const handleFilterChange = useCallback(
(adhocFilter: AdhocFilter) => {
const whatIfFilter = adhocFilterToWhatIfFilter(adhocFilter);
if (!whatIfFilter) return;
setFilters(prevFilters => {
if (editingFilterIndex !== null) {
// Update existing filter
const newFilters = [...prevFilters];
newFilters[editingFilterIndex] = whatIfFilter;
return newFilters;
}
// Add new filter
return [...prevFilters, whatIfFilter];
});
setFilterPopoverVisible(false);
setCurrentAdhocFilter(null);
setEditingFilterIndex(null);
},
[adhocFilterToWhatIfFilter, editingFilterIndex],
);
const handleRemoveFilter = useCallback(
(e: React.MouseEvent, index: number) => {
e.preventDefault();
e.stopPropagation();
setFilters(prevFilters => prevFilters.filter((_, i) => i !== index));
},
[],
);
const handleFilterPopoverClose = useCallback(() => {
setFilterPopoverVisible(false);
setCurrentAdhocFilter(null);
setEditingFilterIndex(null);
}, []);
// Intentionally empty: AdhocFilterEditPopover requires an onResize callback,
// but we don't need dynamic resizing in this fixed-width panel context.
const handleFilterPopoverResize = useCallback(() => {}, []);
const clearFilters = useCallback(() => {
setFilters([]);
}, []);
// Helper to format filter for display (matching Explore filter label format)
const formatFilterLabel = useCallback((filter: WhatIfFilter): string => {
const { col, op, val } = filter;
// Special handling for TEMPORAL_RANGE to match Explore format
if (op === 'TEMPORAL_RANGE' && typeof val === 'string') {
return formatTimeRangeLabel(val, col);
}
let valStr: string;
if (Array.isArray(val)) {
valStr = val.join(', ');
} else if (typeof val === 'boolean') {
valStr = val ? 'true' : 'false';
} else {
valStr = String(val);
}
// Truncate long values
if (valStr.length > 20) {
valStr = `${valStr.substring(0, 17)}...`;
}
return `${col} ${op} ${valStr}`;
}, []);
return {
filters,
filterPopoverVisible,
editingFilterIndex,
currentAdhocFilter,
setFilterPopoverVisible,
setFilters,
handleOpenFilterPopover,
handleEditFilter,
handleFilterChange,
handleRemoveFilter,
handleFilterPopoverClose,
handleFilterPopoverResize,
clearFilters,
formatFilterLabel,
};
}
export default useWhatIfFilters;

View File

@@ -0,0 +1,328 @@
/**
* 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 { SupersetClient, Signal } from '@superset-ui/core';
import {
WhatIfInterpretRequest,
WhatIfInterpretResponse,
ChartComparison,
WhatIfFilter,
WhatIfModification,
WhatIfSuggestRelatedRequest,
WhatIfSuggestRelatedResponse,
SuggestedModification,
} from './types';
// =============================================================================
// Simulation CRUD Types
// =============================================================================
export interface WhatIfSimulation {
id: number;
uuid: string;
name: string;
description?: string | null;
dashboardId: number;
modifications: WhatIfModification[];
cascadingEffectsEnabled: boolean;
createdOn?: string | null;
changedOn?: string | null;
}
export interface CreateSimulationRequest {
name: string;
description?: string;
dashboardId: number;
modifications: WhatIfModification[];
cascadingEffectsEnabled: boolean;
}
export interface UpdateSimulationRequest {
name?: string;
description?: string;
modifications?: WhatIfModification[];
cascadingEffectsEnabled?: boolean;
}
interface ApiResponse {
result: {
summary: string;
insights: Array<{
title: string;
description: string;
type: string;
}>;
raw_response?: string;
};
}
export async function fetchWhatIfInterpretation(
request: WhatIfInterpretRequest,
signal?: Signal,
): Promise<WhatIfInterpretResponse> {
const response = await SupersetClient.post({
endpoint: '/api/v1/what_if/interpret',
signal,
jsonPayload: {
modifications: request.modifications.map(mod => ({
column: mod.column,
multiplier: mod.multiplier,
...(mod.filters && mod.filters.length > 0
? {
filters: mod.filters.map((f: WhatIfFilter) => ({
col: f.col,
op: f.op,
val: f.val,
})),
}
: {}),
})),
charts: request.charts.map((chart: ChartComparison) => ({
chart_id: chart.chartId,
chart_name: chart.chartName,
chart_type: chart.chartType,
metrics: chart.metrics.map(m => ({
metric_name: m.metricName,
original_value: m.originalValue,
modified_value: m.modifiedValue,
percentage_change: m.percentageChange,
})),
})),
dashboard_name: request.dashboardName,
},
});
const data = response.json as ApiResponse;
const { result } = data;
return {
summary: result.summary,
insights: result.insights.map(insight => ({
title: insight.title,
description: insight.description,
type: insight.type as 'observation' | 'implication' | 'recommendation',
})),
rawResponse: result.raw_response,
};
}
interface ApiSuggestRelatedResponse {
result: {
suggested_modifications: Array<{
column: string;
multiplier: number;
reasoning: string;
confidence: string;
}>;
explanation?: string;
};
}
export async function fetchRelatedColumnSuggestions(
request: WhatIfSuggestRelatedRequest,
signal?: Signal,
): Promise<WhatIfSuggestRelatedResponse> {
const response = await SupersetClient.post({
endpoint: '/api/v1/what_if/suggest_related',
signal,
jsonPayload: {
selected_column: request.selectedColumn,
user_multiplier: request.userMultiplier,
available_columns: request.availableColumns.map(col => ({
column_name: col.columnName,
description: col.description,
verbose_name: col.verboseName,
datasource_id: col.datasourceId,
})),
dashboard_name: request.dashboardName,
},
});
const data = response.json as ApiSuggestRelatedResponse;
const { result } = data;
return {
suggestedModifications: result.suggested_modifications.map(
(mod): SuggestedModification => ({
column: mod.column,
multiplier: mod.multiplier,
reasoning: mod.reasoning,
confidence: mod.confidence as 'high' | 'medium' | 'low',
}),
),
explanation: result.explanation,
};
}
// =============================================================================
// Simulation CRUD API Functions
// =============================================================================
interface SimulationListResponse {
result: Array<{
id: number;
uuid: string;
name: string;
description?: string | null;
dashboard_id?: number;
modifications: Array<{
column: string;
multiplier: number;
filters?: Array<{
col: string;
op: string;
val: string | number | boolean | Array<string | number>;
}>;
}>;
cascading_effects_enabled: boolean;
created_on?: string | null;
changed_on?: string | null;
}>;
}
interface SimulationCreateResponse {
id: number;
uuid: string;
}
export async function fetchAllSimulations(): Promise<WhatIfSimulation[]> {
const response = await SupersetClient.get({
endpoint: '/api/v1/what_if/simulations',
});
const data = response.json as SimulationListResponse;
return data.result.map(sim => ({
id: sim.id,
uuid: sim.uuid,
name: sim.name,
description: sim.description,
dashboardId: sim.dashboard_id ?? 0,
modifications: sim.modifications.map(mod => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters?.map(f => ({
col: f.col,
op: f.op as WhatIfFilter['op'],
val: f.val,
})),
})),
cascadingEffectsEnabled: sim.cascading_effects_enabled,
createdOn: sim.created_on,
changedOn: sim.changed_on,
}));
}
export async function fetchSimulations(
dashboardId: number,
): Promise<WhatIfSimulation[]> {
const response = await SupersetClient.get({
endpoint: `/api/v1/what_if/simulations/dashboard/${dashboardId}`,
});
const data = response.json as SimulationListResponse;
return data.result.map(sim => ({
id: sim.id,
uuid: sim.uuid,
name: sim.name,
description: sim.description,
dashboardId,
modifications: sim.modifications.map(mod => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters?.map(f => ({
col: f.col,
op: f.op as WhatIfFilter['op'],
val: f.val,
})),
})),
cascadingEffectsEnabled: sim.cascading_effects_enabled,
createdOn: sim.created_on,
changedOn: sim.changed_on,
}));
}
export async function createSimulation(
request: CreateSimulationRequest,
): Promise<WhatIfSimulation> {
const response = await SupersetClient.post({
endpoint: '/api/v1/what_if/simulations',
jsonPayload: {
name: request.name,
description: request.description,
dashboard_id: request.dashboardId,
modifications: request.modifications.map(mod => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters?.map(f => ({
col: f.col,
op: f.op,
val: f.val,
})),
})),
cascading_effects_enabled: request.cascadingEffectsEnabled,
},
});
const data = response.json as SimulationCreateResponse;
return {
id: data.id,
uuid: data.uuid,
name: request.name,
description: request.description,
dashboardId: request.dashboardId,
modifications: request.modifications,
cascadingEffectsEnabled: request.cascadingEffectsEnabled,
};
}
export async function updateSimulation(
simulationId: number,
request: UpdateSimulationRequest,
): Promise<void> {
const payload: Record<string, unknown> = {};
if (request.name !== undefined) payload.name = request.name;
if (request.description !== undefined)
payload.description = request.description;
if (request.cascadingEffectsEnabled !== undefined) {
payload.cascading_effects_enabled = request.cascadingEffectsEnabled;
}
if (request.modifications !== undefined) {
payload.modifications = request.modifications.map(mod => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters?.map(f => ({
col: f.col,
op: f.op,
val: f.val,
})),
}));
}
await SupersetClient.put({
endpoint: `/api/v1/what_if/simulations/${simulationId}`,
jsonPayload: payload,
});
}
export async function deleteSimulation(simulationId: number): Promise<void> {
await SupersetClient.delete({
endpoint: `/api/v1/what_if/simulations/${simulationId}`,
});
}

View File

@@ -98,13 +98,6 @@ const ChartWrapper = styled.div`
}
`;
const ChartOverlay = styled.div`
position: absolute;
top: 0;
left: 0;
z-index: 5;
`;
const SliceContainer = styled.div`
display: flex;
flex-direction: column;
@@ -367,6 +360,9 @@ const Chart = props => {
state.dashboardInfo?.metadata?.shared_label_colors,
),
);
const whatIfModifications = useSelector(
state => state.dashboardState.whatIfModifications || EMPTY_ARRAY,
);
const formData = useMemo(
() =>
@@ -386,6 +382,7 @@ const Chart = props => {
labelsColorMap,
sharedLabelsColors,
ownColorScheme,
whatIfModifications,
}),
[
chart.id,
@@ -403,6 +400,7 @@ const Chart = props => {
labelsColorMap,
sharedLabelsColors,
ownColorScheme,
whatIfModifications,
],
);
@@ -410,9 +408,13 @@ const Chart = props => {
const ownState = useMemo(() => {
const baseOwnState = dataMask[props.id]?.ownState || EMPTY_OBJECT;
// Create a chartState-like object that includes state from chartState or formData fallbacks
const chartStateForConversion = {
state: getChartStateWithFallback(chartState, formData, slice.viz_type),
};
return createOwnStateWithChartState(
baseOwnState,
chartState,
chartStateForConversion,
slice.viz_type,
);
}, [
@@ -420,6 +422,7 @@ const Chart = props => {
props.id,
slice.viz_type,
chartState?.state,
formData,
]);
const onExploreChart = useCallback(
@@ -596,7 +599,6 @@ const Chart = props => {
return <MissingChart height={getChartHeight()} />;
}
const isLoading = chartStatus === 'loading';
const cachedDttm =
// eslint-disable-next-line camelcase
queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
@@ -668,15 +670,6 @@ const Chart = props => {
className={cx('dashboard-chart')}
aria-label={slice.description}
>
{isLoading && (
<ChartOverlay
style={{
width,
height: getChartHeight(),
}}
/>
)}
<ChartContainer
width={width}
height={getChartHeight()}
@@ -693,17 +686,7 @@ const Chart = props => {
formData={formData}
labelsColor={labelsColor}
labelsColorMap={labelsColorMap}
ownState={createOwnStateWithChartState(
dataMask[props.id]?.ownState || EMPTY_OBJECT,
{
state: getChartStateWithFallback(
chartState,
formData,
slice.viz_type,
),
},
slice.viz_type,
)}
ownState={ownState}
filterState={dataMask[props.id]?.filterState}
queriesResponse={chart.queriesResponse}
timeout={timeout}

View File

@@ -31,6 +31,7 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath';
import useFilterFocusHighlightStyles from 'src/dashboard/util/useFilterFocusHighlightStyles';
import useWhatIfHighlightStyles from 'src/dashboard/util/useWhatIfHighlightStyles';
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
import {
GRID_BASE_UNIT,
@@ -107,6 +108,7 @@ const ChartHolder = ({
const isFullSize = fullSizeChartId === chartId;
const focusHighlightStyles = useFilterFocusHighlightStyles(chartId);
const whatIfHighlightStyles = useWhatIfHighlightStyles(chartId);
const directPathToChild = useSelector(
(state: RootState) => state.dashboardState.directPathToChild,
);
@@ -260,7 +262,7 @@ const ChartHolder = ({
ref={dragSourceRef}
data-test="dashboard-component-chart-holder"
style={focusHighlightStyles}
css={isFullSize ? fullSizeStyle : undefined}
css={[isFullSize && fullSizeStyle, whatIfHighlightStyles]}
className={cx(
'dashboard-component',
'dashboard-component-chart-holder',
@@ -325,6 +327,7 @@ const ChartHolder = ({
onResizeStop,
editMode,
focusHighlightStyles,
whatIfHighlightStyles,
isFullSize,
fullSizeStyle,
chartId,

View File

@@ -54,6 +54,9 @@ import {
REMOVE_CHART_STATE,
RESTORE_CHART_STATES,
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';
@@ -333,6 +336,24 @@ export default function dashboardStateReducer(state = {}, action) {
chartStates: {},
};
},
[SET_WHAT_IF_MODIFICATIONS]() {
return {
...state,
whatIfModifications: action.modifications,
};
},
[CLEAR_WHAT_IF_MODIFICATIONS]() {
return {
...state,
whatIfModifications: [],
};
},
[TOGGLE_WHAT_IF_PANEL]() {
return {
...state,
whatIfPanelOpen: action.isOpen,
};
},
};
if (action.type in actionHandlers) {

View File

@@ -40,6 +40,7 @@ import {
} from './components/nativeFilters/ChartCustomization/types';
import { GroupByCustomizationsState } from './reducers/groupByCustomizations';
import { ChartState } from '../explore/types';
import { SliceEntitiesState } from './actions/sliceEntities';
export type { Dashboard } from 'src/types/Dashboard';
@@ -129,6 +130,8 @@ export type DashboardState = {
data: JsonObject;
};
chartStates?: Record<string, any>;
whatIfModifications: WhatIfModification[];
whatIfPanelOpen?: boolean;
};
export type DashboardInfo = {
id: number;
@@ -184,7 +187,7 @@ export type DatasourcesState = {
/** Root state of redux */
export type RootState = {
datasources: DatasourcesState;
sliceEntities: JsonObject;
sliceEntities: SliceEntitiesState;
charts: ChartsState;
dashboardLayout: DashboardLayoutState;
dashboardFilters: {};
@@ -280,6 +283,40 @@ export type Slice = {
created_by: { id: number };
};
/**
* What-If Analysis types
*/
export type WhatIfFilterOperator =
| '=='
| '!='
| '>'
| '<'
| '>='
| '<='
| 'IN'
| 'NOT IN'
| 'TEMPORAL_RANGE';
export interface WhatIfFilter {
col: string;
op: WhatIfFilterOperator;
val: string | number | boolean | Array<string | number>;
}
export interface WhatIfModification {
column: string;
multiplier: number;
filters?: WhatIfFilter[];
}
export interface WhatIfColumn {
columnName: string;
datasourceId: number;
usedByChartIds: number[];
description?: string | null;
verboseName?: string | null;
}
export enum MenuKeys {
DownloadAsImage = 'download_as_image',
ExploreChart = 'explore_chart',

View File

@@ -30,6 +30,8 @@ import {
ChartConfiguration,
ChartQueryPayload,
ActiveFilters,
WhatIfModification,
Slice,
} from 'src/dashboard/types';
import { ChartCustomizationItem } from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
@@ -42,6 +44,10 @@ import {
} from './chartTypeLimitations';
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
import { getAllActiveFilters } from '../activeAllDashboardFilters';
import {
collectSqlExpressionsFromSlice,
findColumnsInSqlExpressions,
} from '../whatIf';
interface CachedFormData {
extra_form_data?: JsonObject;
@@ -76,6 +82,7 @@ const cachedFormdataByChart: Record<
CachedFormData & {
dataMask: DataMask;
extraControls: Record<string, string | boolean | null>;
whatIfModifications?: WhatIfModification[];
}
> = {};
@@ -97,6 +104,7 @@ export interface GetFormDataWithExtraFiltersArguments {
allSliceIds: number[];
chartCustomization?: JsonObject;
activeFilters?: ActiveFilters;
whatIfModifications?: WhatIfModification[];
}
const createFilterDataMapping = (
@@ -129,19 +137,10 @@ function extractColumnNames(columns: unknown[]): string[] {
return columnNames;
}
function buildExistingColumnsSet(chart: ChartQueryPayload): Set<string> {
const existingColumns = new Set<string>();
const chartType = chart.form_data?.viz_type;
const existingGroupBy = ensureIsArray(chart.form_data?.groupby);
existingGroupBy.forEach((col: string) => existingColumns.add(col));
const xAxisColumn = chart.form_data?.x_axis;
if (xAxisColumn && chartType !== 'heatmap' && chartType !== 'heatmap_v2') {
existingColumns.add(xAxisColumn);
}
const metrics = chart.form_data?.metrics || [];
function extractColumnsFromMetrics(
metrics: any[],
existingColumns: Set<string>,
): void {
metrics.forEach((metric: any) => {
if (typeof metric === 'string') {
existingColumns.add(metric);
@@ -158,6 +157,48 @@ function buildExistingColumnsSet(chart: ChartQueryPayload): Set<string> {
}
}
});
}
function buildExistingColumnsSet(chart: ChartQueryPayload): Set<string> {
const existingColumns = new Set<string>();
const chartType = chart.form_data?.viz_type;
const existingGroupBy = ensureIsArray(chart.form_data?.groupby);
existingGroupBy.forEach((col: string) => existingColumns.add(col));
// Handle groupby_b for multi-query charts (e.g., Mixed Timeseries)
const existingGroupByB = ensureIsArray(chart.form_data?.groupby_b);
existingGroupByB.forEach((col: string) => existingColumns.add(col));
const xAxisColumn = chart.form_data?.x_axis;
if (xAxisColumn && chartType !== 'heatmap' && chartType !== 'heatmap_v2') {
existingColumns.add(xAxisColumn);
}
const metrics = chart.form_data?.metrics || [];
extractColumnsFromMetrics(metrics, existingColumns);
// Handle metrics_b for multi-query charts (e.g., Mixed Timeseries)
const metricsB = chart.form_data?.metrics_b || [];
extractColumnsFromMetrics(metricsB, existingColumns);
// 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);
@@ -450,6 +491,7 @@ export default function getFormDataWithExtraFilters({
allSliceIds,
chartCustomization,
activeFilters: passedActiveFilters,
whatIfModifications,
}: GetFormDataWithExtraFiltersArguments) {
const cachedFormData = cachedFormdataByChart[sliceId];
const dataMaskEqual = areObjectsEqual(cachedFormData?.dataMask, dataMask, {
@@ -476,7 +518,8 @@ export default function getFormDataWithExtraFilters({
}) &&
areObjectsEqual(cachedFormData?.chart_customization, chartCustomization, {
ignoreUndefined: true,
})
}) &&
isEqual(cachedFormData?.whatIfModifications, whatIfModifications)
) {
return cachedFormData;
}
@@ -518,6 +561,35 @@ export default function getFormDataWithExtraFilters({
}
}
// Apply what-if modifications to charts that use the modified columns
// Note: what_if goes in 'extras', not 'extra_form_data', because the backend
// reads it from extras in get_sqla_query (superset/models/helpers.py)
let whatIfExtras: { what_if?: { modifications: WhatIfModification[] } } = {};
if (whatIfModifications && whatIfModifications.length > 0) {
const chartColumns = buildExistingColumnsSet(chart as ChartQueryPayload);
// Also check if modified columns appear in SQL expressions
// (e.g., custom metrics like AVG(orders / customers))
const modifiedColumnNames = whatIfModifications.map(mod => mod.column);
const sliceForSqlCheck = { form_data: chart.form_data } as Slice;
const sqlExpressions = collectSqlExpressionsFromSlice(sliceForSqlCheck);
const sqlReferencedColumns = findColumnsInSqlExpressions(
sqlExpressions,
modifiedColumnNames,
);
const applicableModifications = whatIfModifications.filter(
mod =>
chartColumns.has(mod.column) || sqlReferencedColumns.has(mod.column),
);
if (applicableModifications.length > 0) {
whatIfExtras = {
what_if: { modifications: applicableModifications },
};
}
}
let layerFilterScope: { [filterId: string]: number[] } | undefined;
const isDeckMultiChart = chart.form_data?.viz_type === 'deck_multi';
@@ -571,6 +643,13 @@ export default function getFormDataWithExtraFilters({
...groupByFormData,
...(chartCustomization && { chart_customization: chartCustomization }),
...(layerFilterScope && { layer_filter_scope: layerFilterScope }),
// Merge what-if into extras (backend reads from extras, not extra_form_data)
...(whatIfExtras.what_if && {
extras: {
...chart.form_data?.extras,
...whatIfExtras,
},
}),
};
cachedFiltersByChart[sliceId] = filters;
@@ -579,6 +658,7 @@ export default function getFormDataWithExtraFilters({
dataMask,
extraControls,
...(chartCustomization && { chart_customization: chartCustomization }),
whatIfModifications,
};
return formData;

View File

@@ -24,6 +24,24 @@ export const arrayDiff = (a: string[], b: string[]) => [
...b.filter(x => !a.includes(x)),
];
// Fields in ownState that don't require re-querying the chart when changed
// clientView is used by TableChart to store filtered rows for export - it's a
// derived/cached value that doesn't affect the query
const IGNORED_OWN_STATE_FIELDS = ['clientView'];
const getComparableOwnState = (
ownState: JsonObject | undefined,
): JsonObject => {
if (!ownState) return {};
const result: JsonObject = {};
Object.keys(ownState).forEach(key => {
if (!IGNORED_OWN_STATE_FIELDS.includes(key)) {
result[key] = ownState[key];
}
});
return result;
};
export const getAffectedOwnDataCharts = (
ownDataCharts: JsonObject,
appliedOwnDataCharts: JsonObject,
@@ -35,9 +53,10 @@ export const getAffectedOwnDataCharts = (
);
const checkForUpdateIds = new Set<string>([...chartIds, ...appliedChartIds]);
checkForUpdateIds.forEach(chartId => {
if (
!areObjectsEqual(ownDataCharts[chartId], appliedOwnDataCharts[chartId])
) {
// Compare ownState excluding fields that don't require re-querying
const currentState = getComparableOwnState(ownDataCharts[chartId]);
const appliedState = getComparableOwnState(appliedOwnDataCharts[chartId]);
if (!areObjectsEqual(currentState, appliedState)) {
affectedIds.push(chartId);
}
});

View File

@@ -0,0 +1,59 @@
/**
* 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 { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { RootState, Slice, WhatIfColumn } from 'src/dashboard/types';
import { getNumericColumnsForDashboard } from './whatIf';
/**
* Hook to get numeric columns available for what-if analysis on the dashboard.
* This hook memoizes the computation and provides a stable reference to avoid
* unnecessary re-renders in consuming components.
*
* Returns:
* - numericColumns: Array of WhatIfColumn objects with column metadata
* - columnToChartIds: Map from column name to array of chart IDs that use it
*/
export function useNumericColumns(): {
numericColumns: WhatIfColumn[];
columnToChartIds: Map<string, number[]>;
} {
const slices = useSelector(
(state: RootState) => state.sliceEntities.slices as { [id: number]: Slice },
);
const datasources = useSelector((state: RootState) => state.datasources);
const numericColumns = useMemo(
() => getNumericColumnsForDashboard(slices, datasources),
[slices, datasources],
);
const columnToChartIds = useMemo(() => {
const map = new Map<string, number[]>();
numericColumns.forEach(col => {
map.set(col.columnName, col.usedByChartIds);
});
return map;
}, [numericColumns]);
return { numericColumns, columnToChartIds };
}
export default useNumericColumns;

View File

@@ -0,0 +1,160 @@
/**
* 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 { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { css, keyframes } from '@emotion/react';
import { RootState, WhatIfModification } from 'src/dashboard/types';
import {
extractColumnsFromSlice,
collectSqlExpressionsFromSlice,
findColumnsInSqlExpressions,
} from './whatIf';
const EMPTY_STYLES = undefined;
/* eslint-disable theme-colors/no-literal-colors */
const rainbowSlide = keyframes`
0% {
background-position: 0% 50%;
}
100% {
background-position: 300% 50%;
}
`;
const pulse = keyframes`
0%, 100% {
opacity: 0.6;
filter: blur(2px);
}
50% {
opacity: 0.9;
filter: blur(6px);
}
`;
export const whatIfHighlightStyles = css`
position: relative;
&::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 10px;
padding: 3px;
background: linear-gradient(
90deg,
#ff8a8a,
#ffbe8a,
#ffff8a,
#beff8a,
#8aff8a,
#8affbe,
#8affff,
#8abeff,
#8a8aff,
#be8aff,
#ff8aff,
#ff8abe,
#ff8a8a,
#ffbe8a,
#ffff8a,
#beff8a,
#8aff8a,
#8affbe,
#8affff,
#8abeff,
#8a8aff,
#be8aff,
#ff8aff,
#ff8abe,
#ff8a8a
);
background-size: 300% 100%;
animation:
${rainbowSlide} 20s linear infinite,
${pulse} 3s ease-in-out infinite;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
`;
/* eslint-enable theme-colors/no-literal-colors */
/**
* Hook that returns animated rainbow border styles for charts
* that are affected by what-if transformations.
*/
const useWhatIfHighlightStyles = (chartId: number) => {
const whatIfModifications = useSelector(
(state: RootState) => state.dashboardState.whatIfModifications,
);
const slice = useSelector(
(state: RootState) => state.sliceEntities?.slices?.[chartId],
);
const isAffected = useMemo(() => {
if (!whatIfModifications || whatIfModifications.length === 0) {
return false;
}
if (!slice) {
return false;
}
const chartColumns = extractColumnsFromSlice(slice);
const modifiedColumnNames = whatIfModifications.map(
(mod: WhatIfModification) => mod.column,
);
const modifiedColumns = new Set(modifiedColumnNames);
// Check if any of the chart's explicitly referenced columns are being modified
for (const column of chartColumns) {
if (modifiedColumns.has(column)) {
return true;
}
}
// Also check if modified columns appear in SQL expressions
const sqlExpressions = collectSqlExpressionsFromSlice(slice);
if (sqlExpressions.length > 0) {
const sqlReferencedColumns = findColumnsInSqlExpressions(
sqlExpressions,
modifiedColumnNames,
);
if (sqlReferencedColumns.size > 0) {
return true;
}
}
return false;
}, [whatIfModifications, slice]);
if (!isAffected) {
return EMPTY_STYLES;
}
return whatIfHighlightStyles;
};
export default useWhatIfHighlightStyles;

View File

@@ -0,0 +1,211 @@
/**
* 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 {
collectSqlExpressionsFromSlice,
findColumnsInSqlExpressions,
sliceUsesColumn,
} from './whatIf';
import { Slice } from '../types';
const createMockSlice = (formData: Record<string, unknown>): Slice =>
({
slice_id: 1,
slice_name: 'Test Slice',
form_data: formData,
}) as Slice;
test('collectSqlExpressionsFromSlice extracts SQL from metrics', () => {
const slice = createMockSlice({
metrics: [
{
expressionType: 'SQL',
sqlExpression: 'AVG(orders / customers)',
label: 'Avg Orders',
},
],
});
const expressions = collectSqlExpressionsFromSlice(slice);
expect(expressions).toEqual(['AVG(orders / customers)']);
});
test('collectSqlExpressionsFromSlice extracts SQL from filters', () => {
const slice = createMockSlice({
adhoc_filters: [
{
expressionType: 'SQL',
sqlExpression: 'revenue > 1000',
clause: 'WHERE',
},
],
});
const expressions = collectSqlExpressionsFromSlice(slice);
expect(expressions).toEqual(['revenue > 1000']);
});
test('collectSqlExpressionsFromSlice extracts SQL from adhoc columns in groupby', () => {
const slice = createMockSlice({
groupby: [
{
sqlExpression: "DATE_TRUNC('month', order_date)",
label: 'Month',
},
],
});
const expressions = collectSqlExpressionsFromSlice(slice);
expect(expressions).toEqual(["DATE_TRUNC('month', order_date)"]);
});
test('collectSqlExpressionsFromSlice extracts SQL from singular metric', () => {
const slice = createMockSlice({
metric: {
expressionType: 'SQL',
sqlExpression: 'SUM(amount)',
label: 'Total Amount',
},
});
const expressions = collectSqlExpressionsFromSlice(slice);
expect(expressions).toEqual(['SUM(amount)']);
});
test('collectSqlExpressionsFromSlice ignores SIMPLE expression types', () => {
const slice = createMockSlice({
metrics: [
{
expressionType: 'SIMPLE',
column: { column_name: 'revenue' },
aggregate: 'SUM',
},
],
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'status',
operator: '==',
comparator: 'active',
},
],
});
const expressions = collectSqlExpressionsFromSlice(slice);
expect(expressions).toEqual([]);
});
test('findColumnsInSqlExpressions finds exact column matches', () => {
const sqlExpressions = ['AVG(orders / customers)', 'SUM(revenue)'];
const columnNames = ['orders', 'customers', 'revenue', 'total'];
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
expect(found).toEqual(new Set(['orders', 'customers', 'revenue']));
});
test('findColumnsInSqlExpressions avoids false positives with similar names', () => {
const sqlExpressions = ['SUM(order_count)', 'AVG(reorder_rate)'];
const columnNames = ['order', 'orders', 'order_count', 'reorder_rate'];
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
// Should only match exact column names, not partial matches
expect(found).toEqual(new Set(['order_count', 'reorder_rate']));
expect(found.has('order')).toBe(false);
expect(found.has('orders')).toBe(false);
});
test('findColumnsInSqlExpressions handles columns at start and end of expression', () => {
const sqlExpressions = ['revenue + cost'];
const columnNames = ['revenue', 'cost'];
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
expect(found).toEqual(new Set(['revenue', 'cost']));
});
test('findColumnsInSqlExpressions handles columns in parentheses', () => {
const sqlExpressions = ['SUM(amount)', '(price * quantity)'];
const columnNames = ['amount', 'price', 'quantity'];
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
expect(found).toEqual(new Set(['amount', 'price', 'quantity']));
});
test('findColumnsInSqlExpressions handles special regex characters in column names', () => {
const sqlExpressions = ['SUM(col.name) + AVG(col$value)'];
const columnNames = ['col.name', 'col$value'];
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
expect(found).toEqual(new Set(['col.name', 'col$value']));
});
test('findColumnsInSqlExpressions returns empty set when no matches', () => {
const sqlExpressions = ['SUM(total)'];
const columnNames = ['revenue', 'cost'];
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
expect(found.size).toBe(0);
});
test('findColumnsInSqlExpressions returns empty set with empty inputs', () => {
expect(findColumnsInSqlExpressions([], ['col1']).size).toBe(0);
expect(findColumnsInSqlExpressions(['SUM(col)'], []).size).toBe(0);
expect(findColumnsInSqlExpressions([], []).size).toBe(0);
});
test('sliceUsesColumn detects columns in SQL expressions', () => {
const slice = createMockSlice({
metrics: [
{
expressionType: 'SQL',
sqlExpression: 'AVG(orders / customers)',
label: 'Avg',
},
],
});
expect(sliceUsesColumn(slice, 'orders')).toBe(true);
expect(sliceUsesColumn(slice, 'customers')).toBe(true);
expect(sliceUsesColumn(slice, 'revenue')).toBe(false);
});
test('sliceUsesColumn detects explicitly referenced columns', () => {
const slice = createMockSlice({
groupby: ['category', 'region'],
});
expect(sliceUsesColumn(slice, 'category')).toBe(true);
expect(sliceUsesColumn(slice, 'region')).toBe(true);
expect(sliceUsesColumn(slice, 'country')).toBe(false);
});
test('sliceUsesColumn detects columns in both explicit and SQL references', () => {
const slice = createMockSlice({
groupby: ['category'],
metrics: [
{
expressionType: 'SQL',
sqlExpression: 'SUM(revenue)',
label: 'Total',
},
],
});
expect(sliceUsesColumn(slice, 'category')).toBe(true);
expect(sliceUsesColumn(slice, 'revenue')).toBe(true);
});

View File

@@ -0,0 +1,422 @@
/**
* 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 {
ensureIsArray,
getColumnLabel,
isQueryFormColumn,
JsonValue,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
import { DatasourcesState, Slice, WhatIfColumn } from '../types';
/**
* Type definitions for form_data structures used in what-if analysis.
* These are local types for the subset of form_data we need to inspect.
*/
/** Metric definition in form_data */
interface FormDataMetric {
expressionType?: 'SIMPLE' | 'SQL';
column?: string | { column_name: string };
aggregate?: string;
sqlExpression?: string;
label?: string;
}
/** Filter definition in form_data */
interface FormDataFilter {
expressionType?: 'SIMPLE' | 'SQL';
subject?: string;
operator?: string;
comparator?: JsonValue;
sqlExpression?: string;
clause?: string;
}
/**
* Check if a column is numeric based on its type_generic field
*/
export function isNumericColumn(column: ColumnMeta): boolean {
return column.type_generic === GenericDataType.Numeric;
}
/**
* Collect all SQL expressions from a slice's form_data.
* This includes:
* - Metrics with expressionType: 'SQL' (sqlExpression)
* - Filters with expressionType: 'SQL' (sqlExpression)
* - Adhoc columns in groupby, x_axis, series, etc. (sqlExpression)
*/
export function collectSqlExpressionsFromSlice(slice: Slice): string[] {
const expressions: string[] = [];
const formData = slice.form_data;
if (!formData) return expressions;
// Helper to extract sqlExpression from adhoc columns
const addAdhocColumnExpression = (col: unknown) => {
if (
col &&
typeof col === 'object' &&
'sqlExpression' in col &&
typeof (col as { sqlExpression: unknown }).sqlExpression === 'string'
) {
expressions.push((col as { sqlExpression: string }).sqlExpression);
}
};
// Helper to extract SQL expressions from metrics array
const addMetricSqlExpressions = (metrics: unknown[]) => {
metrics.forEach((metric: unknown) => {
if (
metric &&
typeof metric === 'object' &&
'expressionType' in metric &&
(metric as { expressionType: unknown }).expressionType === 'SQL' &&
'sqlExpression' in metric &&
typeof (metric as { sqlExpression: unknown }).sqlExpression === 'string'
) {
expressions.push((metric as { sqlExpression: string }).sqlExpression);
}
});
};
// Extract SQL expressions from metrics
addMetricSqlExpressions(ensureIsArray(formData.metrics));
// Handle metrics_b for multi-query charts (e.g., Mixed Timeseries)
addMetricSqlExpressions(ensureIsArray(formData.metrics_b));
// Extract SQL expression from singular metric
if (
formData.metric &&
typeof formData.metric === 'object' &&
'expressionType' in formData.metric &&
(formData.metric as { expressionType: unknown }).expressionType === 'SQL' &&
'sqlExpression' in formData.metric &&
typeof (formData.metric as { sqlExpression: unknown }).sqlExpression ===
'string'
) {
expressions.push(
(formData.metric as { sqlExpression: string }).sqlExpression,
);
}
// Helper to extract SQL expressions from filters array
const addFilterSqlExpressions = (filters: unknown[]) => {
filters.forEach((filter: unknown) => {
if (
filter &&
typeof filter === 'object' &&
'expressionType' in filter &&
(filter as { expressionType: unknown }).expressionType === 'SQL' &&
'sqlExpression' in filter &&
typeof (filter as { sqlExpression: unknown }).sqlExpression === 'string'
) {
expressions.push((filter as { sqlExpression: string }).sqlExpression);
}
});
};
// Extract SQL expressions from filters
addFilterSqlExpressions(ensureIsArray(formData.adhoc_filters));
// Handle adhoc_filters_b for multi-query charts (e.g., Mixed Timeseries)
addFilterSqlExpressions(ensureIsArray(formData.adhoc_filters_b));
// Extract SQL expressions from adhoc columns in groupby, x_axis, series, columns, entity
ensureIsArray(formData.groupby).forEach(addAdhocColumnExpression);
ensureIsArray(formData.columns).forEach(addAdhocColumnExpression);
// Handle groupby_b for multi-query charts (e.g., Mixed Timeseries)
ensureIsArray(formData.groupby_b).forEach(addAdhocColumnExpression);
if (formData.x_axis) {
addAdhocColumnExpression(formData.x_axis);
}
if (formData.series) {
addAdhocColumnExpression(formData.series);
}
if (formData.entity) {
addAdhocColumnExpression(formData.entity);
}
return expressions;
}
/**
* Find column names that appear in SQL expressions.
* Uses word boundary matching to avoid false positives
* (e.g., "order" shouldn't match "order_id" or "reorder").
*/
export function findColumnsInSqlExpressions(
sqlExpressions: string[],
columnNames: string[],
): Set<string> {
const foundColumns = new Set<string>();
if (sqlExpressions.length === 0 || columnNames.length === 0) {
return foundColumns;
}
// Combine all SQL expressions into one string for efficient searching
const combinedSql = sqlExpressions.join(' ');
columnNames.forEach(columnName => {
// Use word boundary regex to match exact column names
// Escape special regex characters in column name
const escapedName = columnName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Match column name surrounded by word boundaries or common SQL delimiters
const regex = new RegExp(
`(^|[^a-zA-Z0-9_])${escapedName}([^a-zA-Z0-9_]|$)`,
);
if (regex.test(combinedSql)) {
foundColumns.add(columnName);
}
});
return foundColumns;
}
/**
* Extract column names from a slice's form_data
* This includes columns from groupby, metrics, x_axis, series, filters, etc.
*/
export function extractColumnsFromSlice(slice: Slice): Set<string> {
const columns = new Set<string>();
const formData = slice.form_data;
if (!formData) return columns;
// Helper to add column - handles both physical columns (strings) and adhoc columns
const addColumn = (col: unknown) => {
if (isQueryFormColumn(col)) {
const label = getColumnLabel(col);
if (label) columns.add(label);
}
};
// Helper to extract columns from metrics array
const addMetricColumns = (metrics: unknown[]) => {
metrics.forEach((metric: unknown) => {
if (typeof metric === 'string') {
// Saved metric name - we can't extract columns from it
return;
}
if (metric && typeof metric === 'object' && 'column' in metric) {
const metricColumn = (metric as FormDataMetric).column;
if (typeof metricColumn === 'string') {
columns.add(metricColumn);
} else if (
metricColumn &&
typeof metricColumn === 'object' &&
'column_name' in metricColumn
) {
columns.add(metricColumn.column_name);
}
}
});
};
// Helper to extract columns from filters array
const addFilterColumns = (filters: unknown[]) => {
filters.forEach((filter: unknown) => {
const f = filter as FormDataFilter;
if (f?.subject && typeof f.subject === 'string') {
columns.add(f.subject);
}
});
};
// Extract groupby columns (can be physical or adhoc)
ensureIsArray(formData.groupby).forEach(addColumn);
// Handle groupby_b for multi-query charts (e.g., Mixed Timeseries)
ensureIsArray(formData.groupby_b).forEach(addColumn);
// Extract x_axis column (can be physical or adhoc)
if (formData.x_axis) {
addColumn(formData.x_axis);
}
// Extract metrics - get column names from metric definitions
addMetricColumns(ensureIsArray(formData.metrics));
// Handle metrics_b for multi-query charts (e.g., Mixed Timeseries)
addMetricColumns(ensureIsArray(formData.metrics_b));
// Extract metric (singular) - used by pie charts and other single-metric charts
if (formData.metric && typeof formData.metric === 'object') {
const metric = formData.metric as FormDataMetric;
if ('column' in metric && metric.column) {
const metricColumn = metric.column;
if (typeof metricColumn === 'string') {
columns.add(metricColumn);
} else if (typeof metricColumn === 'object' && 'column_name' in metricColumn) {
columns.add(metricColumn.column_name);
}
}
}
// Extract series column (can be physical or adhoc)
if (formData.series) {
addColumn(formData.series);
}
// Extract entity column
if (formData.entity) {
addColumn(formData.entity);
}
// Extract columns from filters
addFilterColumns(ensureIsArray(formData.adhoc_filters));
// Handle adhoc_filters_b for multi-query charts (e.g., Mixed Timeseries)
addFilterColumns(ensureIsArray(formData.adhoc_filters_b));
// Extract columns array (used by some chart types like box_plot)
ensureIsArray(formData.columns).forEach(addColumn);
return columns;
}
/**
* Get the datasource key from a slice's form_data
* Format: "datasourceId__datasourceType" e.g., "2__table"
*/
export function getDatasourceKey(slice: Slice): string | null {
const datasource = slice.form_data?.datasource;
if (!datasource || typeof datasource !== 'string') return null;
return datasource;
}
/**
* Get numeric columns used by slices on a dashboard
* Returns columns grouped by their usage across slices
*
* Uses sliceEntities.slices instead of charts state because it changes less
* frequently (only on slice metadata updates, not on every query result change)
*/
export function getNumericColumnsForDashboard(
slices: { [id: number]: Slice },
datasources: DatasourcesState,
): WhatIfColumn[] {
const columnMap = new Map<string, WhatIfColumn>();
Object.values(slices).forEach(slice => {
const chartId = slice.slice_id;
const datasourceKey = getDatasourceKey(slice);
if (!datasourceKey) return;
const datasource = datasources[datasourceKey];
if (!datasource?.columns) return;
// Extract columns explicitly referenced by this slice
const referencedColumns = extractColumnsFromSlice(slice);
// Also check SQL expressions for column references
const sqlExpressions = collectSqlExpressionsFromSlice(slice);
if (sqlExpressions.length > 0) {
// Get all numeric column names from this datasource
const numericColumnNames = datasource.columns
.filter((c: ColumnMeta) => isNumericColumn(c))
.map((c: ColumnMeta) => c.column_name);
// Find which numeric columns are referenced in SQL expressions
const sqlReferencedColumns = findColumnsInSqlExpressions(
sqlExpressions,
numericColumnNames,
);
// Add SQL-referenced columns to the set
sqlReferencedColumns.forEach(colName => referencedColumns.add(colName));
}
// For each referenced column, check if it's numeric
referencedColumns.forEach(colName => {
const colMetadata = datasource.columns.find(
(c: ColumnMeta) => c.column_name === colName,
);
if (colMetadata && isNumericColumn(colMetadata)) {
// Create a unique key for this column (datasource + column name)
const key = `${datasource.id}:${colName}`;
if (!columnMap.has(key)) {
columnMap.set(key, {
columnName: colName,
datasourceId: datasource.id,
usedByChartIds: [chartId],
description: colMetadata.description,
verboseName: colMetadata.verbose_name,
});
} else {
const existing = columnMap.get(key)!;
if (!existing.usedByChartIds.includes(chartId)) {
existing.usedByChartIds.push(chartId);
}
}
}
});
});
return Array.from(columnMap.values());
}
/**
* Check if a slice uses a specific column.
* Checks both explicitly referenced columns and columns in SQL expressions.
*/
export function sliceUsesColumn(slice: Slice, columnName: string): boolean {
const columns = extractColumnsFromSlice(slice);
if (columns.has(columnName)) {
return true;
}
// Also check SQL expressions
const sqlExpressions = collectSqlExpressionsFromSlice(slice);
if (sqlExpressions.length > 0) {
const sqlReferencedColumns = findColumnsInSqlExpressions(sqlExpressions, [
columnName,
]);
return sqlReferencedColumns.has(columnName);
}
return false;
}
/**
* Format a multiplier value as a percentage change string.
* Example: 1.15 -> "+15%", 0.85 -> "-15%"
*
* @param multiplier - The multiplier value (1 = no change)
* @param decimals - Number of decimal places (default: 0)
* @returns Formatted percentage string with sign
*/
export function formatPercentageChange(
multiplier: number,
decimals: number = 0,
): string {
const percentChange = (multiplier - 1) * 100;
const sign = percentChange >= 0 ? '+' : '';
const formatted =
decimals > 0
? percentChange.toFixed(decimals)
: Math.round(percentChange).toString();
return `${sign}${formatted}%`;
}

View File

@@ -0,0 +1,390 @@
/**
* 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 { useState, useCallback, useMemo } from 'react';
import { t } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/ui';
import { Modal, Tag, Button, Input } from '@superset-ui/core/components';
import Slider from '@superset-ui/core/components/Slider';
import { Icons } from '@superset-ui/core/components/Icons';
import { WhatIfModification, WhatIfFilter } from 'src/dashboard/types';
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
import {
WhatIfSimulation,
updateSimulation,
} from 'src/dashboard/components/WhatIfDrawer/whatIfApi';
import {
SLIDER_MIN,
SLIDER_MAX,
SLIDER_MARKS,
SLIDER_TOOLTIP_CONFIG,
} from 'src/dashboard/components/WhatIfDrawer/constants';
/** Maps column name to verbose name for display */
type ColumnVerboseNames = Record<string, string>;
interface EditSimulationModalProps {
simulation: WhatIfSimulation | null;
onHide: () => void;
onSaved: () => void;
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
columnVerboseNames: ColumnVerboseNames;
}
const ModificationRow = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
padding: ${({ theme }) => theme.sizeUnit * 3}px;
background: ${({ theme }) => theme.colorBgLayout};
border-radius: ${({ theme }) => theme.borderRadius}px;
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const ModificationHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const ColumnName = styled.span`
font-weight: ${({ theme }) => theme.fontWeightStrong};
font-size: ${({ theme }) => theme.fontSizeLG}px;
`;
const SliderContainer = styled.div`
padding: 0 ${({ theme }) => theme.sizeUnit}px;
& .ant-slider-mark {
font-size: ${({ theme }) => theme.fontSizeSM}px;
}
`;
const FiltersContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.sizeUnit}px;
`;
const FilterLabel = styled.div`
font-size: ${({ theme }) => theme.fontSizeSM}px;
color: ${({ theme }) => theme.colorTextSecondary};
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
`;
const EmptyState = styled.div`
text-align: center;
padding: ${({ theme }) => theme.sizeUnit * 6}px;
color: ${({ theme }) => theme.colorTextSecondary};
`;
const AddModificationButton = styled(Button)`
margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const NewModificationForm = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
padding: ${({ theme }) => theme.sizeUnit * 3}px;
background: ${({ theme }) => theme.colorBgLayout};
border-radius: ${({ theme }) => theme.borderRadius}px;
border: 1px dashed ${({ theme }) => theme.colorBorder};
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
`;
/**
* Format a WhatIfFilter for display
*/
function formatFilterLabel(filter: WhatIfFilter): string {
const { col, op, val } = filter;
let valStr: string;
if (Array.isArray(val)) {
valStr = val.join(', ');
} else if (typeof val === 'boolean') {
valStr = val ? 'true' : 'false';
} else {
valStr = String(val);
}
// Truncate long values
if (valStr.length > 20) {
valStr = `${valStr.substring(0, 17)}...`;
}
return `${col} ${op} ${valStr}`;
}
interface EditableModification extends WhatIfModification {
sliderValue: number; // (multiplier - 1) * 100
}
function EditSimulationModal({
simulation,
onHide,
onSaved,
addSuccessToast,
addDangerToast,
columnVerboseNames,
}: EditSimulationModalProps) {
const theme = useTheme();
// Convert modifications to editable format with slider values
const initialModifications = useMemo(
() =>
simulation?.modifications.map(mod => ({
...mod,
sliderValue: (mod.multiplier - 1) * 100,
})) ?? [],
[simulation],
);
const [modifications, setModifications] =
useState<EditableModification[]>(initialModifications);
const [saving, setSaving] = useState(false);
const [showNewModification, setShowNewModification] = useState(false);
const [newColumnName, setNewColumnName] = useState('');
const [newSliderValue, setNewSliderValue] = useState(0);
// Reset state when simulation changes
useMemo(() => {
setModifications(initialModifications);
setShowNewModification(false);
setNewColumnName('');
setNewSliderValue(0);
}, [initialModifications]);
const handleSliderChange = useCallback((index: number, value: number) => {
setModifications(prev =>
prev.map((mod, i) =>
i === index
? { ...mod, sliderValue: value, multiplier: 1 + value / 100 }
: mod,
),
);
}, []);
const handleRemoveModification = useCallback((index: number) => {
setModifications(prev => prev.filter((_, i) => i !== index));
}, []);
const handleAddModification = useCallback(() => {
if (!newColumnName.trim()) return;
const newMod: EditableModification = {
column: newColumnName.trim(),
multiplier: 1 + newSliderValue / 100,
sliderValue: newSliderValue,
};
setModifications(prev => [...prev, newMod]);
setShowNewModification(false);
setNewColumnName('');
setNewSliderValue(0);
}, [newColumnName, newSliderValue]);
const handleSave = useCallback(async () => {
if (!simulation) return;
setSaving(true);
try {
const updatedModifications: WhatIfModification[] = modifications.map(
mod => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters,
}),
);
await updateSimulation(simulation.id, {
modifications: updatedModifications,
});
addSuccessToast(t('Simulation updated successfully'));
onSaved();
onHide();
} catch (error) {
addDangerToast(t('Failed to update simulation'));
} finally {
setSaving(false);
}
}, [
simulation,
modifications,
onSaved,
onHide,
addSuccessToast,
addDangerToast,
]);
const hasChanges = useMemo(() => {
if (!simulation) return false;
if (modifications.length !== simulation.modifications.length) return true;
return modifications.some((mod, i) => {
const original = simulation.modifications[i];
return mod.multiplier !== original.multiplier;
});
}, [simulation, modifications]);
if (!simulation) return null;
return (
<Modal
show
onHide={onHide}
title={t('Edit simulation: %s', simulation.name)}
primaryButtonName={t('Save')}
onHandledPrimaryAction={handleSave}
primaryButtonLoading={saving}
disablePrimaryButton={!hasChanges || saving}
centered
>
{modifications.length === 0 && !showNewModification ? (
<EmptyState>
<Icons.WarningOutlined
iconSize="xl"
css={css`
color: ${theme.colorWarning};
margin-bottom: ${theme.sizeUnit * 2}px;
`}
/>
<div>{t('No modifications in this simulation')}</div>
</EmptyState>
) : (
modifications.map((mod, index) => (
<ModificationRow key={`${mod.column}-${index}`}>
<ModificationHeader>
<ColumnName>
{columnVerboseNames[mod.column] || mod.column}
</ColumnName>
<div
css={css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
`}
>
<span
css={css`
font-weight: ${theme.fontWeightStrong};
font-size: ${theme.fontSizeLG}px;
color: ${mod.multiplier >= 1
? theme.colorSuccess
: theme.colorError};
`}
>
{formatPercentageChange(mod.multiplier, 0)}
</span>
<Button
buttonSize="small"
onClick={() => handleRemoveModification(index)}
buttonStyle="tertiary"
aria-label={t('Remove modification')}
>
<Icons.DeleteOutlined iconSize="s" />
</Button>
</div>
</ModificationHeader>
<SliderContainer>
<Slider
min={SLIDER_MIN}
max={SLIDER_MAX}
value={mod.sliderValue}
onChange={value => handleSliderChange(index, value)}
marks={SLIDER_MARKS}
tooltip={SLIDER_TOOLTIP_CONFIG}
/>
</SliderContainer>
{mod.filters && mod.filters.length > 0 && (
<div>
<FilterLabel>{t('Filters')}</FilterLabel>
<FiltersContainer>
{mod.filters.map((filter, filterIndex) => (
<Tag key={`${filter.col}-${filterIndex}`}>
{formatFilterLabel(filter)}
</Tag>
))}
</FiltersContainer>
</div>
)}
</ModificationRow>
))
)}
{showNewModification && (
<NewModificationForm>
<Input
placeholder={t('Column name')}
value={newColumnName}
onChange={e => setNewColumnName(e.target.value)}
/>
<SliderContainer>
<Slider
min={SLIDER_MIN}
max={SLIDER_MAX}
value={newSliderValue}
onChange={setNewSliderValue}
marks={SLIDER_MARKS}
tooltip={SLIDER_TOOLTIP_CONFIG}
/>
</SliderContainer>
<div
css={css`
display: flex;
gap: ${theme.sizeUnit}px;
`}
>
<Button
buttonStyle="primary"
buttonSize="small"
onClick={handleAddModification}
disabled={!newColumnName.trim() || newSliderValue === 0}
>
{t('Add')}
</Button>
<Button
buttonSize="small"
onClick={() => {
setShowNewModification(false);
setNewColumnName('');
setNewSliderValue(0);
}}
>
{t('Cancel')}
</Button>
</div>
</NewModificationForm>
)}
{!showNewModification && (
<AddModificationButton
buttonStyle="tertiary"
onClick={() => setShowNewModification(true)}
>
<Icons.PlusOutlined iconSize="s" />
{t('Add modification')}
</AddModificationButton>
)}
</Modal>
);
}
export default EditSimulationModal;

View File

@@ -0,0 +1,549 @@
/**
* 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 { useMemo, useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { t, SupersetClient } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/ui';
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
import {
ConfirmStatusChange,
DeleteModal,
Empty,
Skeleton,
Tag,
Tooltip,
} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import {
ListView,
ListViewActionsBar,
type ListViewProps,
type ListViewActionProps,
} from 'src/components';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import withToasts from 'src/components/MessageToasts/withToasts';
import { WhatIfFilter, WhatIfModification } from 'src/dashboard/types';
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
import {
fetchAllSimulations,
deleteSimulation,
WhatIfSimulation,
} from 'src/dashboard/components/WhatIfDrawer/whatIfApi';
import EditSimulationModal from './EditSimulationModal';
const PAGE_SIZE = 25;
interface WhatIfSimulationListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
interface DashboardInfo {
id: number;
dashboard_title: string;
slug: string | null;
}
/** Maps column name to verbose name for display */
type ColumnVerboseNames = Record<string, string>;
const PageContainer = styled.div`
padding: ${({ theme }) => theme.sizeUnit * 4}px;
`;
const EmptyContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: ${({ theme }) => theme.sizeUnit * 16}px;
`;
const ModificationsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit}px;
`;
const ModificationTagsRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.sizeUnit}px;
`;
const FilterBadge = styled.span`
font-size: 10px;
color: ${({ theme }) => theme.colorTextSecondary};
margin-left: ${({ theme }) => theme.sizeUnit}px;
`;
/**
* Format a WhatIfFilter for display
*/
function formatFilterLabel(filter: WhatIfFilter): string {
const { col, op, val } = filter;
let valStr: string;
if (Array.isArray(val)) {
valStr = val.join(', ');
} else if (typeof val === 'boolean') {
valStr = val ? 'true' : 'false';
} else {
valStr = String(val);
}
// Truncate long values
if (valStr.length > 15) {
valStr = `${valStr.substring(0, 12)}...`;
}
return `${col} ${op} ${valStr}`;
}
/**
* Component to render a single modification with its filters
*/
function ModificationTag({
modification,
columnVerboseNames,
}: {
modification: WhatIfModification;
columnVerboseNames: ColumnVerboseNames;
}) {
const theme = useTheme();
const hasFilters = modification.filters && modification.filters.length > 0;
const displayName =
columnVerboseNames[modification.column] || modification.column;
const tagContent = (
<Tag
css={css`
display: inline-flex;
align-items: center;
gap: ${theme.sizeUnit}px;
margin: 0;
`}
>
<span>{displayName}</span>
<span
css={css`
font-weight: ${theme.fontWeightStrong};
color: ${modification.multiplier >= 1
? theme.colorSuccess
: theme.colorError};
`}
>
{formatPercentageChange(modification.multiplier, 0)}
</span>
{hasFilters && (
<FilterBadge>
<Icons.FilterOutlined iconSize="xs" />
</FilterBadge>
)}
</Tag>
);
if (hasFilters) {
const filterTooltip = modification
.filters!.map(f => formatFilterLabel(f))
.join(', ');
return <Tooltip title={filterTooltip}>{tagContent}</Tooltip>;
}
return tagContent;
}
function WhatIfSimulationList({
addDangerToast,
addSuccessToast,
}: WhatIfSimulationListProps) {
const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]);
const [dashboards, setDashboards] = useState<Record<number, DashboardInfo>>(
{},
);
const [columnVerboseNames, setColumnVerboseNames] =
useState<ColumnVerboseNames>({});
const [loading, setLoading] = useState(true);
const [simulationCurrentlyDeleting, setSimulationCurrentlyDeleting] =
useState<WhatIfSimulation | null>(null);
const [simulationCurrentlyEditing, setSimulationCurrentlyEditing] =
useState<WhatIfSimulation | null>(null);
const loadSimulations = useCallback(async () => {
setLoading(true);
try {
const result = await fetchAllSimulations();
setSimulations(result);
// Fetch dashboard info for all unique dashboard IDs
const dashboardIds = [...new Set(result.map(sim => sim.dashboardId))];
if (dashboardIds.length > 0) {
const dashboardInfos: Record<number, DashboardInfo> = {};
const verboseNames: ColumnVerboseNames = {};
await Promise.all(
dashboardIds.map(async id => {
try {
// Fetch dashboard info and datasets in parallel
const [dashboardResponse, datasetsResponse] = await Promise.all([
SupersetClient.get({
endpoint: `/api/v1/dashboard/${id}`,
}),
SupersetClient.get({
endpoint: `/api/v1/dashboard/${id}/datasets`,
}),
]);
dashboardInfos[id] = {
id,
dashboard_title: dashboardResponse.json.result.dashboard_title,
slug: dashboardResponse.json.result.slug,
};
// Extract column verbose names from all datasets
const datasets = datasetsResponse.json.result || [];
datasets.forEach(
(dataset: {
columns?: { column_name: string; verbose_name?: string }[];
}) => {
(dataset.columns || []).forEach(col => {
if (col.verbose_name) {
verboseNames[col.column_name] = col.verbose_name;
}
});
},
);
} catch {
dashboardInfos[id] = {
id,
dashboard_title: `Dashboard ${id}`,
slug: null,
};
}
}),
);
setDashboards(dashboardInfos);
setColumnVerboseNames(verboseNames);
}
} catch (error) {
addDangerToast(t('Failed to load simulations'));
} finally {
setLoading(false);
}
}, [addDangerToast]);
useEffect(() => {
loadSimulations();
}, [loadSimulations]);
const handleDelete = useCallback(
async (simulation: WhatIfSimulation) => {
try {
await deleteSimulation(simulation.id);
setSimulationCurrentlyDeleting(null);
addSuccessToast(t('Deleted: %s', simulation.name));
loadSimulations();
} catch (error) {
addDangerToast(t('Failed to delete simulation'));
}
},
[addSuccessToast, addDangerToast, loadSimulations],
);
const handleBulkDelete = useCallback(
async (simulationsToDelete: WhatIfSimulation[]) => {
try {
await Promise.all(
simulationsToDelete.map(sim => deleteSimulation(sim.id)),
);
addSuccessToast(
t('Deleted %s simulation(s)', simulationsToDelete.length),
);
loadSimulations();
} catch (error) {
addDangerToast(t('Failed to delete simulations'));
}
},
[addSuccessToast, addDangerToast, loadSimulations],
);
const menuData: SubMenuProps = {
name: t('What-if simulations'),
};
const initialSort = [{ id: 'changedOn', desc: true }];
const columns = useMemo(
() => [
{
accessor: 'name',
Header: t('Name'),
size: 'lg',
id: 'name',
Cell: ({
row: {
original: { id, name, dashboardId },
},
}: {
row: { original: WhatIfSimulation };
}) => {
const dashboard = dashboards[dashboardId];
const dashboardUrl = dashboard?.slug
? `/superset/dashboard/${dashboard.slug}/`
: `/superset/dashboard/${dashboardId}/`;
const url = `${dashboardUrl}?simulation=${id}`;
return <Link to={url}>{name}</Link>;
},
},
{
accessor: 'description',
Header: t('Description'),
size: 'xl',
id: 'description',
Cell: ({
row: {
original: { description },
},
}: {
row: { original: WhatIfSimulation };
}) => description || '-',
},
{
accessor: 'dashboardId',
Header: t('Dashboard'),
size: 'md',
id: 'dashboardId',
Cell: ({
row: {
original: { dashboardId },
},
}: {
row: { original: WhatIfSimulation };
}) => {
const dashboard = dashboards[dashboardId];
if (!dashboard) return `Dashboard ${dashboardId}`;
const url = dashboard.slug
? `/superset/dashboard/${dashboard.slug}/`
: `/superset/dashboard/${dashboardId}/`;
return <Link to={url}>{dashboard.dashboard_title}</Link>;
},
},
{
accessor: 'modifications',
Header: t('Modifications'),
size: 'xxl',
id: 'modifications',
disableSortBy: true,
Cell: ({
row: {
original: { modifications },
},
}: {
row: { original: WhatIfSimulation };
}) => {
if (modifications.length === 0) {
return <span>-</span>;
}
return (
<ModificationsContainer>
<ModificationTagsRow>
{modifications.map((mod, idx) => (
<ModificationTag
key={`${mod.column}-${idx}`}
modification={mod}
columnVerboseNames={columnVerboseNames}
/>
))}
</ModificationTagsRow>
</ModificationsContainer>
);
},
},
{
accessor: 'changedOn',
Header: t('Last modified'),
size: 'md',
id: 'changedOn',
Cell: ({
row: {
original: { changedOn },
},
}: {
row: { original: WhatIfSimulation };
}) => (changedOn ? dayjs(changedOn).format('ll') : '-'),
},
{
Cell: ({
row: { original },
}: {
row: { original: WhatIfSimulation };
}) => {
const handleEdit = () => setSimulationCurrentlyEditing(original);
const handleDelete = () => setSimulationCurrentlyDeleting(original);
const actions = [
{
label: 'edit-action',
tooltip: t('Edit modifications'),
placement: 'bottom',
icon: 'EditOutlined',
onClick: handleEdit,
},
{
label: 'delete-action',
tooltip: t('Delete simulation'),
placement: 'bottom',
icon: 'DeleteOutlined',
onClick: handleDelete,
},
];
return (
<ListViewActionsBar actions={actions as ListViewActionProps[]} />
);
},
Header: t('Actions'),
id: 'actions',
size: 'sm',
disableSortBy: true,
},
],
[dashboards, columnVerboseNames],
);
const emptyState = {
title: t('No simulations yet'),
image: 'filter-results.svg',
description: t(
'Create your first What-If simulation from the What-If panel in a dashboard.',
),
};
if (loading) {
return (
<>
<SubMenu {...menuData} />
<PageContainer>
<Skeleton active />
</PageContainer>
</>
);
}
if (simulations.length === 0) {
return (
<>
<SubMenu {...menuData} />
<EmptyContainer>
<Empty
image="simple"
description={
<>
<div
css={css`
font-weight: 600;
margin-bottom: 8px;
`}
>
{t('No simulations yet')}
</div>
<div>
{t(
'Create your first What-If simulation from the What-If panel in a dashboard.',
)}
</div>
</>
}
/>
</EmptyContainer>
</>
);
}
return (
<>
<SubMenu {...menuData} />
{simulationCurrentlyDeleting && (
<DeleteModal
description={t(
'Are you sure you want to delete %s?',
simulationCurrentlyDeleting.name,
)}
onConfirm={() => {
if (simulationCurrentlyDeleting) {
handleDelete(simulationCurrentlyDeleting);
}
}}
onHide={() => setSimulationCurrentlyDeleting(null)}
open
title={t('Delete Simulation?')}
/>
)}
{simulationCurrentlyEditing && (
<EditSimulationModal
simulation={simulationCurrentlyEditing}
onHide={() => setSimulationCurrentlyEditing(null)}
onSaved={loadSimulations}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
columnVerboseNames={columnVerboseNames}
/>
)}
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
'Are you sure you want to delete the selected simulations?',
)}
onConfirm={handleBulkDelete}
>
{confirmDelete => {
const bulkActions: ListViewProps['bulkActions'] = [
{
key: 'delete',
name: t('Delete'),
onSelect: confirmDelete,
type: 'danger',
},
];
return (
<ListView<WhatIfSimulation>
bulkActions={bulkActions}
bulkSelectEnabled={false}
columns={columns}
count={simulations.length}
data={simulations}
emptyState={emptyState}
fetchData={() => {}}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
refreshData={loadSimulations}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
/>
);
}}
</ConfirmStatusChange>
</>
);
}
export default withToasts(WhatIfSimulationList);

View File

@@ -138,6 +138,13 @@ const RowLevelSecurityList = lazy(
),
);
const WhatIfSimulationList = lazy(
() =>
import(
/* webpackChunkName: "WhatIfSimulationList" */ 'src/pages/WhatIfSimulationList'
),
);
const RolesList = lazy(
() => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'),
);
@@ -289,6 +296,10 @@ export const routes: Routes = [
path: '/rowlevelsecurity/list',
Component: RowLevelSecurityList,
},
{
path: '/whatif/simulations/',
Component: WhatIfSimulationList,
},
{
path: '/sqllab/',
Component: SqlLab,

View File

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

View File

@@ -1145,6 +1145,18 @@ QUERY_LOGGER = None
# Set this API key to enable Mapbox visualizations
MAPBOX_API_KEY = os.environ.get("MAPBOX_API_KEY", "")
# ---------------------------------------------------
# What-If AI Interpretation Configuration
# ---------------------------------------------------
# API key for OpenRouter (required for AI interpretation of what-if analysis)
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
# Model to use for interpretation (default: x-ai/grok-4.1-fast)
OPENROUTER_MODEL = "x-ai/grok-4.1-fast"
# API base URL for OpenRouter
OPENROUTER_API_BASE = "https://openrouter.ai/api/v1"
# Request timeout in seconds
OPENROUTER_TIMEOUT = 30
# Maximum number of rows returned for any analytical database query
SQL_MAX_ROW = 100000

View File

@@ -1516,11 +1516,172 @@ class SqlaTable(
def get_from_clause(
self,
template_processor: BaseTemplateProcessor | None = None,
what_if: dict[str, Any] | None = None,
) -> tuple[TableClause | Alias, str | None]:
if not self.is_virtual:
return self.get_sqla_table(), None
tbl = self.get_sqla_table()
if what_if:
tbl = self._apply_what_if_transform(tbl, what_if)
return tbl, None
return super().get_from_clause(template_processor)
from_clause, cte = super().get_from_clause(template_processor, what_if=None)
if what_if:
from_clause = self._apply_what_if_transform(from_clause, what_if)
return from_clause, cte
def _build_what_if_filter_condition(
self,
filters: list[dict[str, Any]],
) -> ColumnElement | None:
"""
Build a SQLAlchemy condition from a list of what-if filters.
Supports operators: ==, !=, >, <, >=, <=, IN, NOT IN, TEMPORAL_RANGE
:param filters: List of filter dicts with 'col', 'op', and 'val' keys
:returns: Combined SQLAlchemy condition (ANDed together), or None if no valid filters
"""
from superset.common.utils.time_range_utils import (
get_since_until_from_time_range,
)
from superset.utils.core import FilterOperator
conditions: list[ColumnElement] = []
# Build a map of column name -> column object for quick lookup
columns_by_name = {col.column_name: col for col in self.columns}
for flt in filters:
col_name = flt.get("col")
op = flt.get("op")
val = flt.get("val")
# Skip if column doesn't exist in datasource
if col_name not in columns_by_name:
continue
sqla_col = sa.column(col_name)
if op == FilterOperator.EQUALS:
conditions.append(sqla_col == val)
elif op == FilterOperator.NOT_EQUALS:
conditions.append(sqla_col != val)
elif op == FilterOperator.GREATER_THAN:
conditions.append(sqla_col > val)
elif op == FilterOperator.LESS_THAN:
conditions.append(sqla_col < val)
elif op == FilterOperator.GREATER_THAN_OR_EQUALS:
conditions.append(sqla_col >= val)
elif op == FilterOperator.LESS_THAN_OR_EQUALS:
conditions.append(sqla_col <= val)
elif op == FilterOperator.IN:
if isinstance(val, list):
conditions.append(sqla_col.in_(val))
elif op == FilterOperator.NOT_IN:
if isinstance(val, list):
conditions.append(~sqla_col.in_(val))
elif op == FilterOperator.TEMPORAL_RANGE:
# Parse time range string like "2024-01-01 : 2024-03-31" or "Last week"
if isinstance(val, str):
since, until = get_since_until_from_time_range(time_range=val)
time_conditions = []
col_obj = columns_by_name[col_name]
if since:
# Convert datetime to database-specific SQL literal
since_sql = self.dttm_sql_literal(since, col_obj)
time_conditions.append(sqla_col >= sa.literal_column(since_sql))
if until:
until_sql = self.dttm_sql_literal(until, col_obj)
time_conditions.append(sqla_col < sa.literal_column(until_sql))
if time_conditions:
conditions.append(and_(*time_conditions))
if not conditions:
return None
return and_(*conditions)
def _apply_what_if_transform(
self,
source: TableClause | Alias,
what_if: dict[str, Any],
) -> Alias:
"""
Wrap the source table/subquery with a subquery that applies
column transformations for what-if analysis.
:param source: Original table or subquery to transform
:param what_if: Dict containing 'modifications' list with column/multiplier
pairs and 'needed_columns' set with columns required by the query
:returns: Aliased subquery with transformations applied
"""
modifications = what_if.get("modifications", [])
if not modifications:
return source # type: ignore
# Build a dict of column -> modification config (including filters)
mod_map = {m["column"]: m for m in modifications}
# Get columns needed by the query + modified columns
# None means we need all columns (e.g., for complex SQL metrics)
needed_columns: set[str] | None = what_if.get("needed_columns")
modified_column_names = set(mod_map.keys())
# Collect columns used in filters
filter_columns: set[str] = set()
for mod in modifications:
for flt in mod.get("filters", []):
if col_name := flt.get("col"):
filter_columns.add(col_name)
# Determine which columns to select
available_columns = {col.column_name for col in self.columns}
if needed_columns is None:
# Use all available columns
columns_to_select = available_columns
else:
# Use only needed columns + modified columns + filter columns
columns_to_select = needed_columns | modified_column_names | filter_columns
# Build select list with only needed columns
select_columns = []
for col_name in columns_to_select:
# Skip columns that don't exist in the datasource
if col_name not in available_columns:
continue
if col_name in mod_map:
mod = mod_map[col_name]
multiplier = mod["multiplier"]
filters = mod.get("filters", [])
col_ref = sa.column(col_name)
if filters:
# Build conditional transformation with CASE WHEN
condition = self._build_what_if_filter_condition(filters)
if condition is not None:
transformed = sa.case(
(condition, col_ref * sa.literal(multiplier)),
else_=col_ref,
).label(col_name)
else:
# No valid filter conditions, apply unconditionally
transformed = (col_ref * sa.literal(multiplier)).label(col_name)
else:
# No filters, apply transformation to all rows
transformed = (col_ref * sa.literal(multiplier)).label(col_name)
select_columns.append(transformed)
else:
select_columns.append(sa.column(col_name))
if not select_columns:
# Fallback: if no columns to select, return source unchanged
return source # type: ignore
# Create subquery with transformations
subq = sa.select(*select_columns).select_from(source)
return subq.alias("__what_if")
def adhoc_metric_to_sqla(
self,

View File

@@ -0,0 +1,106 @@
# 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.
"""DAO for What-If Simulation persistence."""
from __future__ import annotations
import logging
from typing import Optional
from superset.daos.base import BaseDAO
from superset.extensions import db
from superset.utils.core import get_user_id
from superset.what_if.models import WhatIfSimulation
logger = logging.getLogger(__name__)
class WhatIfSimulationDAO(BaseDAO[WhatIfSimulation]):
"""Data access object for What-If Simulations."""
@classmethod
def find_by_dashboard_and_user(
cls,
dashboard_id: int,
user_id: Optional[int] = None,
) -> list[WhatIfSimulation]:
"""
Find all simulations for a dashboard owned by a specific user.
:param dashboard_id: The dashboard ID
:param user_id: The user ID (defaults to current user)
:returns: List of simulations
"""
if user_id is None:
user_id = get_user_id()
return (
db.session.query(WhatIfSimulation)
.filter(
WhatIfSimulation.dashboard_id == dashboard_id,
WhatIfSimulation.user_id == user_id,
)
.order_by(WhatIfSimulation.changed_on.desc())
.all()
)
@classmethod
def find_all_for_user(
cls,
user_id: Optional[int] = None,
) -> list[WhatIfSimulation]:
"""
Find all simulations owned by a user across all dashboards.
:param user_id: The user ID (defaults to current user)
:returns: List of simulations
"""
if user_id is None:
user_id = get_user_id()
return (
db.session.query(WhatIfSimulation)
.filter(WhatIfSimulation.user_id == user_id)
.order_by(WhatIfSimulation.changed_on.desc())
.all()
)
@classmethod
def validate_name_uniqueness(
cls,
name: str,
dashboard_id: int,
user_id: int,
simulation_id: Optional[int] = None,
) -> bool:
"""
Validate if simulation name is unique for this dashboard/user combo.
:param name: The simulation name
:param dashboard_id: The dashboard ID
:param user_id: The user ID
:param simulation_id: Optional simulation ID (for updates)
:returns: True if unique, False otherwise
"""
query = db.session.query(WhatIfSimulation).filter(
WhatIfSimulation.name == name,
WhatIfSimulation.dashboard_id == dashboard_id,
WhatIfSimulation.user_id == user_id,
)
if simulation_id:
query = query.filter(WhatIfSimulation.id != simulation_id)
return not db.session.query(query.exists()).scalar()

View File

@@ -223,6 +223,8 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.views.user_registrations import UserRegistrationsView
from superset.views.users.api import CurrentUserRestApi, UserRestApi
from superset.views.users_list import UsersListView
from superset.views.what_if import WhatIfSimulationView
from superset.what_if.api import WhatIfRestApi
set_app_error_handlers(self.superset_app)
self.register_request_handlers()
@@ -266,6 +268,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(RLSRestApi)
appbuilder.add_api(SavedQueryRestApi)
appbuilder.add_api(TagRestApi)
appbuilder.add_api(WhatIfRestApi)
appbuilder.add_api(SqlLabRestApi)
appbuilder.add_api(SqlLabPermalinkRestApi)
appbuilder.add_api(LogRestApi)
@@ -430,6 +433,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_view_no_menu(RoleRestAPI)
appbuilder.add_view_no_menu(UserInfoView)
appbuilder.add_view(
WhatIfSimulationView,
"What-If Simulations",
label=_("What-if simulations"),
href="/whatif/simulations/",
icon="fa-flask",
category="Manage",
category_label=_("Manage"),
)
#
# Add links
#

View File

@@ -0,0 +1,116 @@
# 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.
"""add_what_if_simulations
Revision ID: b8f3a2c9d1e5
Revises: a9c01ec10479
Create Date: 2025-12-19 10:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
from sqlalchemy_utils import UUIDType
from superset.migrations.shared.utils import (
create_fks_for_table,
create_table,
drop_table,
)
# revision identifiers, used by Alembic.
revision = "b8f3a2c9d1e5"
down_revision = "a9c01ec10479"
def upgrade():
create_table(
"what_if_simulations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", UUIDType(binary=True), nullable=False),
sa.Column("name", sa.String(length=256), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("dashboard_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"modifications_json",
sa.Text().with_variant(mysql.MEDIUMTEXT(), "mysql"),
nullable=False,
),
sa.Column(
"cascading_effects_enabled",
sa.Boolean(),
server_default=sa.false(),
nullable=False,
),
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("uuid"),
)
# Create index for fast lookup by dashboard_id + user_id
op.create_index(
"ix_what_if_simulations_dashboard_user",
"what_if_simulations",
["dashboard_id", "user_id"],
)
# Create foreign key constraints
create_fks_for_table(
"fk_what_if_simulations_dashboard_id_dashboards",
"what_if_simulations",
"dashboards",
["dashboard_id"],
["id"],
ondelete="CASCADE",
)
create_fks_for_table(
"fk_what_if_simulations_user_id_ab_user",
"what_if_simulations",
"ab_user",
["user_id"],
["id"],
)
create_fks_for_table(
"fk_what_if_simulations_created_by_fk_ab_user",
"what_if_simulations",
"ab_user",
["created_by_fk"],
["id"],
)
create_fks_for_table(
"fk_what_if_simulations_changed_by_fk_ab_user",
"what_if_simulations",
"ab_user",
["changed_by_fk"],
["id"],
)
def downgrade():
op.drop_index(
"ix_what_if_simulations_dashboard_user",
table_name="what_if_simulations",
)
drop_table("what_if_simulations")

View File

@@ -63,6 +63,9 @@ from sqlalchemy.sql.expression import Label, Select, TextAsFrom
from sqlalchemy.sql.selectable import Alias, TableClause
from sqlalchemy_utils import UUIDType
import sqlglot
from sqlglot import exp
from superset import db, is_feature_enabled
from superset.advanced_data_type.types import AdvancedDataTypeResponse
from superset.common.db_query_status import QueryStatus
@@ -2016,7 +2019,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
return self.db_engine_spec.get_text_clause(clause)
def get_from_clause(
self, template_processor: Optional[BaseTemplateProcessor] = None
self,
template_processor: Optional[BaseTemplateProcessor] = None,
what_if: Optional[dict[str, Any]] = None,
) -> tuple[Union[TableClause, Alias], Optional[str]]:
"""
Return where to select the columns and metrics from. Either a physical table
@@ -2060,6 +2065,161 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
return from_clause, cte
def _extract_columns_from_sql(
self, sql_expression: str, available_columns: set[str]
) -> set[str]:
"""
Extract column references from a SQL expression using sqlglot.
:param sql_expression: The SQL expression to parse
:param available_columns: Set of known column names in the dataset
:returns: Set of column names found in the expression that exist in available_columns
"""
try:
# Parse the expression as a SELECT statement to handle it properly
parsed = sqlglot.parse_one(f"SELECT {sql_expression}")
found_columns: set[str] = set()
for column in parsed.find_all(exp.Column):
col_name = column.name
if col_name in available_columns:
found_columns.add(col_name)
return found_columns
except Exception: # noqa: BLE001
# If parsing fails, return all available columns as fallback
return available_columns
def _collect_needed_columns( # noqa: C901
self,
columns: Optional[list[Column]] = None,
groupby: Optional[list[Column]] = None,
metrics: Optional[list[Metric]] = None,
filter: Optional[list[utils.QueryObjectFilterClause]] = None,
orderby: Optional[list[OrderBy]] = None,
granularity: Optional[str] = None,
extras: Optional[dict[str, Any]] = None,
) -> Optional[set[str]]:
"""
Collect all column names needed by the query for what-if transformation.
This allows us to only select necessary columns instead of SELECT *.
:returns: Set of column names that are referenced by the query,
or None if all columns should be included (e.g., for complex metrics)
"""
needed: set[str] = set()
available_columns = {col.column_name for col in self.columns}
# Add granularity column (time column)
if granularity:
needed.add(granularity)
# Add columns from dimensions/columns list
for col in columns or []:
if isinstance(col, str):
needed.add(col)
elif isinstance(col, dict):
if sql_expr := col.get("sqlExpression"):
# Check if it's just a simple column reference
if col.get("isColumnReference"):
needed.add(sql_expr)
else:
# Parse SQL expression to extract column references
needed.update(
self._extract_columns_from_sql(sql_expr, available_columns)
)
elif col.get("column_name"):
needed.add(col["column_name"])
# Add columns from groupby
for col in groupby or []:
if isinstance(col, str):
needed.add(col)
elif isinstance(col, dict):
if sql_expr := col.get("sqlExpression"):
# Check if it's just a simple column reference
if col.get("isColumnReference"):
needed.add(sql_expr)
else:
# Parse SQL expression to extract column references
needed.update(
self._extract_columns_from_sql(sql_expr, available_columns)
)
elif col.get("column_name"):
needed.add(col["column_name"])
# Add columns from metrics
for metric in metrics or []:
if isinstance(metric, str):
# Saved metric - we need to look it up to find the expression
# For now, fallback to selecting all columns
return None
elif isinstance(metric, dict):
expression_type = metric.get("expressionType")
if expression_type == "SQL":
# Parse SQL expression to extract column references
if sql_expr := metric.get("sqlExpression"):
needed.update(
self._extract_columns_from_sql(sql_expr, available_columns)
)
else:
# SIMPLE adhoc metric - check for column reference
metric_column = metric.get("column")
if isinstance(metric_column, dict):
col_name = metric_column.get("column_name")
if isinstance(col_name, str):
needed.add(col_name)
# Add columns from filters
for flt in filter or []:
col = flt.get("col")
if isinstance(col, str):
needed.add(col)
elif isinstance(col, dict):
if sql_expr := col.get("sqlExpression"):
# Check if it's just a simple column reference
if col.get("isColumnReference"):
needed.add(sql_expr)
else:
# Parse SQL expression to extract column references
needed.update(
self._extract_columns_from_sql(sql_expr, available_columns)
)
elif col.get("column_name"):
needed.add(col["column_name"])
# Add columns from orderby
for order_item in orderby or []:
if isinstance(order_item, (list, tuple)) and len(order_item) >= 1:
col = order_item[0]
if isinstance(col, str):
needed.add(col)
elif isinstance(col, dict):
if sql_expr := col.get("sqlExpression"):
# Check if it's just a simple column reference
if col.get("isColumnReference"):
needed.add(sql_expr)
else:
# Parse SQL expression to extract column references
needed.update(
self._extract_columns_from_sql(
sql_expr, available_columns
)
)
elif col.get("column_name"):
needed.add(col["column_name"])
# Add columns from extras.where and extras.having (raw SQL clauses)
if extras:
if where := extras.get("where"):
needed.update(
self._extract_columns_from_sql(where, available_columns)
)
if having := extras.get("having"):
needed.update(
self._extract_columns_from_sql(having, available_columns)
)
return needed
def adhoc_metric_to_sqla(
self,
metric: AdhocMetric,
@@ -2879,7 +3039,20 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
# Process FROM clause early to populate removed_filters from virtual dataset
# templates before we decide whether to add time filters
tbl, cte = self.get_from_clause(template_processor)
what_if = extras.get("what_if") if extras else None
if what_if:
# Collect columns needed by the query for efficient what-if transformation
what_if = dict(what_if) # Copy to avoid mutating original
what_if["needed_columns"] = self._collect_needed_columns(
columns=columns,
groupby=groupby,
metrics=metrics,
filter=filter,
orderby=orderby,
granularity=granularity,
extras=extras,
)
tbl, cte = self.get_from_clause(template_processor, what_if=what_if)
if granularity:
if granularity not in columns_by_name or not dttm_col:

39
superset/views/what_if.py Normal file
View File

@@ -0,0 +1,39 @@
# 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.
"""View for What-If simulations list page."""
from flask_appbuilder import permission_name
from flask_appbuilder.api import expose
from flask_appbuilder.security.decorators import has_access
from superset.superset_typing import FlaskResponse
from .base import BaseSupersetView
class WhatIfSimulationView(BaseSupersetView):
"""View for the What-If simulations list page."""
route_base = "/whatif"
class_permission_name = "Dashboard"
@expose("/simulations/")
@has_access
@permission_name("read")
def list(self) -> FlaskResponse:
"""Render the What-If simulations list page."""
return super().render_app_template()

View File

@@ -0,0 +1,17 @@
# 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.
"""What-If Analysis module for AI-powered interpretation of scenario analysis."""

473
superset/what_if/api.py Normal file
View File

@@ -0,0 +1,473 @@
# 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.
"""What-If Analysis REST API."""
from __future__ import annotations
import logging
from flask import request, Response
from flask_appbuilder.api import expose, protect, safe
from marshmallow import ValidationError
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from superset.daos.what_if_simulation import WhatIfSimulationDAO
from superset.extensions import event_logger
from superset.utils import json
from superset.views.base_api import BaseSupersetApi, statsd_metrics
from superset.what_if.commands.interpret import WhatIfInterpretCommand
from superset.what_if.commands.simulation_create import CreateWhatIfSimulationCommand
from superset.what_if.commands.simulation_delete import DeleteWhatIfSimulationCommand
from superset.what_if.commands.simulation_update import UpdateWhatIfSimulationCommand
from superset.what_if.commands.suggest_related import WhatIfSuggestRelatedCommand
from superset.what_if.exceptions import (
OpenRouterAPIError,
OpenRouterConfigError,
WhatIfSimulationCreateFailedError,
WhatIfSimulationDeleteFailedError,
WhatIfSimulationForbiddenError,
WhatIfSimulationInvalidError,
WhatIfSimulationNotFoundError,
WhatIfSimulationUpdateFailedError,
)
from superset.what_if.schemas import (
WhatIfInterpretRequestSchema,
WhatIfInterpretResponseSchema,
WhatIfSimulationPostSchema,
WhatIfSimulationPutSchema,
WhatIfSuggestRelatedRequestSchema,
WhatIfSuggestRelatedResponseSchema,
)
logger = logging.getLogger(__name__)
class WhatIfRestApi(BaseSupersetApi):
"""REST API for What-If Analysis features."""
resource_name = "what_if"
allow_browser_login = True
openapi_spec_tag = "What-If Analysis"
# Use Dashboard permissions since what-if is a dashboard feature
class_permission_name = "Dashboard"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@expose("/interpret", methods=("POST",))
@protect()
@safe
@event_logger.log_this
@statsd_metrics
def interpret(self) -> Response:
"""Generate AI interpretation of what-if analysis results.
---
post:
summary: Generate AI interpretation of what-if changes
description: >-
Sends what-if modification data to an LLM for business interpretation.
Returns a summary and actionable insights.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WhatIfInterpretRequestSchema'
responses:
200:
description: AI interpretation generated successfully
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/WhatIfInterpretResponseSchema'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
502:
description: Error communicating with AI service
content:
application/json:
schema:
type: object
properties:
message:
type: string
security:
- jwt: []
"""
try:
request_data = WhatIfInterpretRequestSchema().load(request.json)
except ValidationError as ex:
logger.warning("Invalid request data: %s", ex.messages)
return self.response_400(message=str(ex.messages))
try:
command = WhatIfInterpretCommand(request_data)
result = command.run()
return self.response(
200, result=WhatIfInterpretResponseSchema().dump(result)
)
except OpenRouterConfigError as ex:
logger.error("OpenRouter configuration error: %s", ex)
return self.response(500, message="AI interpretation is not configured")
except OpenRouterAPIError as ex:
logger.error("OpenRouter API error: %s", ex)
return self.response(502, message=str(ex))
except ValueError as ex:
logger.warning("Invalid request: %s", ex)
return self.response_400(message=str(ex))
@expose("/suggest_related", methods=("POST",))
@protect()
@safe
@event_logger.log_this
@statsd_metrics
def suggest_related(self) -> Response:
"""Get AI suggestions for related column modifications.
---
post:
summary: Get AI-suggested cascading column modifications
description: >-
Analyzes column relationships and suggests related columns
that should be modified when a user modifies a specific column.
Uses AI to infer causal, mathematical, and domain-specific relationships.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WhatIfSuggestRelatedRequestSchema'
responses:
200:
description: Related column suggestions generated successfully
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/WhatIfSuggestRelatedResponseSchema'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
502:
description: Error communicating with AI service
content:
application/json:
schema:
type: object
properties:
message:
type: string
security:
- jwt: []
"""
try:
request_data = WhatIfSuggestRelatedRequestSchema().load(request.json)
except ValidationError as ex:
logger.warning("Invalid request data: %s", ex.messages)
return self.response_400(message=str(ex.messages))
try:
command = WhatIfSuggestRelatedCommand(request_data)
result = command.run()
return self.response(
200, result=WhatIfSuggestRelatedResponseSchema().dump(result)
)
except OpenRouterConfigError as ex:
logger.error("OpenRouter configuration error: %s", ex)
return self.response(500, message="AI suggestions are not configured")
except OpenRouterAPIError as ex:
logger.error("OpenRouter API error: %s", ex)
return self.response(502, message=str(ex))
except ValueError as ex:
logger.warning("Invalid request: %s", ex)
return self.response_400(message=str(ex))
# =========================================================================
# Simulation CRUD Endpoints
# =========================================================================
@expose("/simulations", methods=("GET",))
@protect()
@safe
@event_logger.log_this
@statsd_metrics
def list_all_simulations(self) -> Response:
"""List all saved simulations for the current user across all dashboards.
---
get:
summary: List all What-If simulations for current user
responses:
200:
description: List of simulations
content:
application/json:
schema:
type: object
properties:
result:
type: array
401:
$ref: '#/components/responses/401'
security:
- jwt: []
"""
simulations = WhatIfSimulationDAO.find_all_for_user()
result = [
{
"id": sim.id,
"uuid": str(sim.uuid),
"name": sim.name,
"description": sim.description,
"dashboard_id": sim.dashboard_id,
"modifications": json.loads(sim.modifications_json),
"cascading_effects_enabled": sim.cascading_effects_enabled,
"created_on": sim.created_on.isoformat() if sim.created_on else None,
"changed_on": sim.changed_on.isoformat() if sim.changed_on else None,
}
for sim in simulations
]
return self.response(200, result=result)
@expose("/simulations/dashboard/<int:dashboard_id>", methods=("GET",))
@protect()
@safe
@event_logger.log_this
@statsd_metrics
def list_simulations(self, dashboard_id: int) -> Response:
"""List all saved simulations for a dashboard (current user only).
---
get:
summary: List What-If simulations for a dashboard
parameters:
- in: path
name: dashboard_id
schema:
type: integer
required: true
responses:
200:
description: List of simulations
content:
application/json:
schema:
type: object
properties:
result:
type: array
401:
$ref: '#/components/responses/401'
security:
- jwt: []
"""
simulations = WhatIfSimulationDAO.find_by_dashboard_and_user(dashboard_id)
result = [
{
"id": sim.id,
"uuid": str(sim.uuid),
"name": sim.name,
"description": sim.description,
"modifications": json.loads(sim.modifications_json),
"cascading_effects_enabled": sim.cascading_effects_enabled,
"created_on": sim.created_on.isoformat() if sim.created_on else None,
"changed_on": sim.changed_on.isoformat() if sim.changed_on else None,
}
for sim in simulations
]
return self.response(200, result=result)
@expose("/simulations", methods=("POST",))
@protect()
@safe
@event_logger.log_this
@statsd_metrics
def create_simulation(self) -> Response:
"""Create a new What-If simulation.
---
post:
summary: Save a new What-If simulation
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WhatIfSimulationPostSchema'
responses:
201:
description: Simulation created
content:
application/json:
schema:
type: object
properties:
id:
type: integer
uuid:
type: string
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
security:
- jwt: []
"""
try:
data = WhatIfSimulationPostSchema().load(request.json)
except ValidationError as ex:
logger.warning("Invalid request data: %s", ex.messages)
return self.response_400(message=str(ex.messages))
# Serialize modifications to JSON
data["modifications_json"] = json.dumps(data.pop("modifications"))
try:
simulation = CreateWhatIfSimulationCommand(data).run()
return self.response(
201,
id=simulation.id,
uuid=str(simulation.uuid),
)
except WhatIfSimulationInvalidError as ex:
return self.response_422(message=ex.normalized_messages())
except WhatIfSimulationCreateFailedError as ex:
logger.error("Error creating simulation: %s", ex)
return self.response_422(message=str(ex))
@expose("/simulations/<int:pk>", methods=("PUT",))
@protect()
@safe
@event_logger.log_this
@statsd_metrics
def update_simulation(self, pk: int) -> Response:
"""Update a What-If simulation.
---
put:
summary: Update a What-If simulation
parameters:
- in: path
name: pk
schema:
type: integer
required: true
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WhatIfSimulationPutSchema'
responses:
200:
description: Simulation updated
content:
application/json:
schema:
type: object
properties:
id:
type: integer
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
security:
- jwt: []
"""
try:
data = WhatIfSimulationPutSchema().load(request.json)
except ValidationError as ex:
logger.warning("Invalid request data: %s", ex.messages)
return self.response_400(message=str(ex.messages))
# Serialize modifications to JSON if present
if "modifications" in data:
data["modifications_json"] = json.dumps(data.pop("modifications"))
try:
simulation = UpdateWhatIfSimulationCommand(pk, data).run()
return self.response(200, id=simulation.id)
except WhatIfSimulationNotFoundError:
return self.response_404()
except WhatIfSimulationForbiddenError:
return self.response_403()
except WhatIfSimulationInvalidError as ex:
return self.response_422(message=ex.normalized_messages())
except WhatIfSimulationUpdateFailedError as ex:
logger.error("Error updating simulation: %s", ex)
return self.response_422(message=str(ex))
@expose("/simulations/<int:pk>", methods=("DELETE",))
@protect()
@safe
@event_logger.log_this
@statsd_metrics
def delete_simulation(self, pk: int) -> Response:
"""Delete a What-If simulation.
---
delete:
summary: Delete a What-If simulation
parameters:
- in: path
name: pk
schema:
type: integer
required: true
responses:
200:
description: Simulation deleted
content:
application/json:
schema:
type: object
properties:
message:
type: string
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
security:
- jwt: []
"""
try:
DeleteWhatIfSimulationCommand([pk]).run()
return self.response(200, message="OK")
except WhatIfSimulationNotFoundError:
return self.response_404()
except WhatIfSimulationForbiddenError:
return self.response_403()
except WhatIfSimulationDeleteFailedError as ex:
logger.error("Error deleting simulation: %s", ex)
return self.response_422(message=str(ex))

View File

@@ -0,0 +1,17 @@
# 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.
"""What-If Analysis commands."""

View File

@@ -0,0 +1,212 @@
# 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.
"""What-If Analysis interpretation command using OpenRouter."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from flask import current_app
from superset.commands.base import BaseCommand
from superset.utils import json
from superset.what_if.exceptions import OpenRouterAPIError, OpenRouterConfigError
logger = logging.getLogger(__name__)
class WhatIfInterpretCommand(BaseCommand):
"""Command to get AI interpretation of what-if analysis results."""
def __init__(self, data: dict[str, Any]) -> None:
self._data = data
def run(self) -> dict[str, Any]:
self.validate()
return self._get_ai_interpretation()
def validate(self) -> None:
api_key = current_app.config.get("OPENROUTER_API_KEY")
if not api_key:
raise OpenRouterConfigError("OPENROUTER_API_KEY not configured")
if not self._data.get("modifications"):
raise ValueError("At least one modification is required")
if not self._data.get("charts"):
raise ValueError("At least one chart comparison is required")
def _format_filter(self, flt: dict[str, Any]) -> str:
"""Format a single filter for display in the prompt."""
col = flt.get("col", "")
op = flt.get("op", "")
val = flt.get("val", "")
# Format the value based on type
if isinstance(val, list):
val_str = ", ".join(str(v) for v in val)
return f"{col} {op} [{val_str}]"
if isinstance(val, str) and op == "TEMPORAL_RANGE":
return f"{col} in time range '{val}'"
return f"{col} {op} {val}"
def _build_prompt(self) -> str:
modifications = self._data["modifications"]
charts = self._data["charts"]
dashboard_name = self._data.get("dashboard_name") or "Dashboard"
# Build modification description
mod_descriptions = []
for mod in modifications:
pct_change = (mod["multiplier"] - 1) * 100
sign = "+" if pct_change >= 0 else ""
base_desc = f"- {mod['column']}: {sign}{pct_change:.1f}%"
# Add filter conditions if present
filters = mod.get("filters") or []
if filters:
filter_strs = [self._format_filter(f) for f in filters]
filter_desc = " AND ".join(filter_strs)
base_desc += f" (only where {filter_desc})"
mod_descriptions.append(base_desc)
modifications_text = "\n".join(mod_descriptions)
# Build chart impact summary
chart_summaries = []
for chart in charts:
metrics_text = []
for metric in chart["metrics"]:
sign = "+" if metric["percentage_change"] >= 0 else ""
metrics_text.append(
f" - {metric['metric_name']}: "
f"{metric['original_value']:,.2f} -> {metric['modified_value']:,.2f} "
f"({sign}{metric['percentage_change']:.1f}%)"
)
chart_summaries.append(
f"**{chart['chart_name']}** ({chart['chart_type']}):\n"
+ "\n".join(metrics_text)
)
charts_text = "\n\n".join(chart_summaries)
return f"""You are a business intelligence analyst. A user is performing a what-if analysis on their "{dashboard_name}" dashboard.
## Scenario
The user modified the following column(s):
{modifications_text}
## Impact on Charts
{charts_text}
## Your Task
Analyze this what-if scenario and provide:
1. **Summary**: A 1-2 sentence executive summary of the overall impact.
2. **Key Observations**: 2-3 specific observations about how the changes affected different metrics.
3. **Business Implications**: What does this mean for the business? Consider:
- Revenue/cost implications
- Operational efficiency
- Risk factors
4. **Recommendations**: 1-2 actionable recommendations based on this analysis.
Please be concise, specific, and focus on business value. Use the actual numbers from the data.
Respond in JSON format:
{{
"summary": "...",
"insights": [
{{"title": "...", "description": "...", "type": "observation"}},
{{"title": "...", "description": "...", "type": "implication"}},
{{"title": "...", "description": "...", "type": "recommendation"}}
]
}}"""
def _get_ai_interpretation(self) -> dict[str, Any]:
api_key = current_app.config.get("OPENROUTER_API_KEY")
model = current_app.config.get("OPENROUTER_MODEL", "x-ai/grok-4.1-fast")
api_base = current_app.config.get(
"OPENROUTER_API_BASE", "https://openrouter.ai/api/v1"
)
timeout = current_app.config.get("OPENROUTER_TIMEOUT", 30)
prompt = self._build_prompt()
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": current_app.config.get("WEBDRIVER_BASEURL", ""),
"X-Title": "Apache Superset What-If Analysis",
}
payload = {
"model": model,
"messages": [
{
"role": "system",
"content": (
"You are a business intelligence analyst. "
"Respond only with valid JSON."
),
},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
"max_tokens": 1000,
"response_format": {"type": "json_object"},
}
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(
f"{api_base}/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
# Parse the JSON response
parsed = json.loads(content)
return {
"summary": parsed.get("summary", ""),
"insights": parsed.get("insights", []),
"raw_response": content if current_app.debug else None,
}
except httpx.HTTPStatusError as ex:
logger.error("OpenRouter API error: %s", ex.response.status_code)
raise OpenRouterAPIError(
f"OpenRouter API error: {ex.response.status_code}"
) from ex
except json.JSONDecodeError as ex:
logger.error("Failed to parse AI response: %s", ex)
raise OpenRouterAPIError("Failed to parse AI response") from ex
except httpx.TimeoutException as ex:
logger.error("OpenRouter API timeout")
raise OpenRouterAPIError("AI service timed out") from ex
except Exception as ex:
logger.exception("Unexpected error calling OpenRouter")
raise OpenRouterAPIError(f"Unexpected error: {ex!s}") from ex

View File

@@ -0,0 +1,68 @@
# 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.
"""Create What-If Simulation command."""
from __future__ import annotations
import logging
from functools import partial
from typing import Any
from flask_appbuilder.models.sqla import Model
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.daos.what_if_simulation import WhatIfSimulationDAO
from superset.utils.core import get_user_id
from superset.utils.decorators import on_error, transaction
from superset.what_if.exceptions import (
WhatIfSimulationCreateFailedError,
WhatIfSimulationInvalidError,
WhatIfSimulationNameUniquenessError,
)
logger = logging.getLogger(__name__)
class CreateWhatIfSimulationCommand(BaseCommand):
"""Command to create a new What-If simulation."""
def __init__(self, data: dict[str, Any]):
self._properties = data.copy()
@transaction(on_error=partial(on_error, reraise=WhatIfSimulationCreateFailedError))
def run(self) -> Model:
self.validate()
user_id = get_user_id()
self._properties["user_id"] = user_id
return WhatIfSimulationDAO.create(attributes=self._properties)
def validate(self) -> None:
exceptions: list[ValidationError] = []
name = self._properties.get("name", "")
dashboard_id = self._properties.get("dashboard_id")
user_id = get_user_id()
# Validate name uniqueness for this dashboard/user
if not WhatIfSimulationDAO.validate_name_uniqueness(
name, dashboard_id, user_id
):
exceptions.append(WhatIfSimulationNameUniquenessError())
if exceptions:
raise WhatIfSimulationInvalidError(exceptions=exceptions)

View File

@@ -0,0 +1,60 @@
# 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.
"""Delete What-If Simulation command."""
from __future__ import annotations
import logging
from functools import partial
from superset.commands.base import BaseCommand
from superset.daos.what_if_simulation import WhatIfSimulationDAO
from superset.utils.core import get_user_id
from superset.utils.decorators import on_error, transaction
from superset.what_if.exceptions import (
WhatIfSimulationDeleteFailedError,
WhatIfSimulationForbiddenError,
WhatIfSimulationNotFoundError,
)
from superset.what_if.models import WhatIfSimulation
logger = logging.getLogger(__name__)
class DeleteWhatIfSimulationCommand(BaseCommand):
"""Command to delete What-If simulation(s)."""
def __init__(self, simulation_ids: list[int]):
self._simulation_ids = simulation_ids
self._models: list[WhatIfSimulation] = []
@transaction(on_error=partial(on_error, reraise=WhatIfSimulationDeleteFailedError))
def run(self) -> None:
self.validate()
WhatIfSimulationDAO.delete(self._models)
def validate(self) -> None:
user_id = get_user_id()
self._models = WhatIfSimulationDAO.find_by_ids(self._simulation_ids)
if len(self._models) != len(self._simulation_ids):
raise WhatIfSimulationNotFoundError()
# Check ownership of all simulations
for model in self._models:
if model.user_id != user_id:
raise WhatIfSimulationForbiddenError()

View File

@@ -0,0 +1,82 @@
# 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.
"""Update What-If Simulation command."""
from __future__ import annotations
import logging
from functools import partial
from typing import Any, Optional
from flask_appbuilder.models.sqla import Model
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.daos.what_if_simulation import WhatIfSimulationDAO
from superset.utils.core import get_user_id
from superset.utils.decorators import on_error, transaction
from superset.what_if.exceptions import (
WhatIfSimulationForbiddenError,
WhatIfSimulationInvalidError,
WhatIfSimulationNameUniquenessError,
WhatIfSimulationNotFoundError,
WhatIfSimulationUpdateFailedError,
)
from superset.what_if.models import WhatIfSimulation
logger = logging.getLogger(__name__)
class UpdateWhatIfSimulationCommand(BaseCommand):
"""Command to update a What-If simulation."""
def __init__(self, simulation_id: int, data: dict[str, Any]):
self._simulation_id = simulation_id
self._properties = data.copy()
self._model: Optional[WhatIfSimulation] = None
@transaction(on_error=partial(on_error, reraise=WhatIfSimulationUpdateFailedError))
def run(self) -> Model:
self.validate()
return WhatIfSimulationDAO.update(self._model, attributes=self._properties)
def validate(self) -> None:
exceptions: list[ValidationError] = []
# Fetch model
self._model = WhatIfSimulationDAO.find_by_id(self._simulation_id)
if not self._model:
raise WhatIfSimulationNotFoundError()
# Check ownership
user_id = get_user_id()
if self._model.user_id != user_id:
raise WhatIfSimulationForbiddenError()
# Validate name uniqueness if name is being updated
name = self._properties.get("name")
if name and name != self._model.name:
if not WhatIfSimulationDAO.validate_name_uniqueness(
name,
self._model.dashboard_id,
user_id,
self._simulation_id,
):
exceptions.append(WhatIfSimulationNameUniquenessError())
if exceptions:
raise WhatIfSimulationInvalidError(exceptions=exceptions)

View File

@@ -0,0 +1,220 @@
# 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.
"""What-If Analysis suggest related columns command using OpenRouter."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from flask import current_app
from superset.commands.base import BaseCommand
from superset.utils import json
from superset.what_if.exceptions import OpenRouterAPIError, OpenRouterConfigError
logger = logging.getLogger(__name__)
class WhatIfSuggestRelatedCommand(BaseCommand):
"""Command to get AI suggestions for related column modifications."""
def __init__(self, data: dict[str, Any]) -> None:
self._data = data
def run(self) -> dict[str, Any]:
self.validate()
return self._get_ai_suggestions()
def validate(self) -> None:
api_key = current_app.config.get("OPENROUTER_API_KEY")
if not api_key:
raise OpenRouterConfigError("OPENROUTER_API_KEY not configured")
if not self._data.get("selected_column"):
raise ValueError("selected_column is required")
if not self._data.get("available_columns"):
raise ValueError("available_columns list is required")
if self._data.get("user_multiplier") is None:
raise ValueError("user_multiplier is required")
def _build_prompt(self) -> str:
selected_column = self._data["selected_column"]
user_multiplier = self._data["user_multiplier"]
available_columns = self._data["available_columns"]
dashboard_name = self._data.get("dashboard_name") or "Dashboard"
pct_change = (user_multiplier - 1) * 100
sign = "+" if pct_change >= 0 else ""
# Build column list with descriptions
columns_text = []
for col in available_columns:
# Skip the selected column - we don't want to suggest modifying it again
if col["column_name"] == selected_column:
continue
col_desc = f"- **{col['column_name']}**"
if col.get("verbose_name"):
col_desc += f" ({col['verbose_name']})"
if col.get("description"):
col_desc += f": {col['description']}"
columns_text.append(col_desc)
if not columns_text:
columns_text = ["No other columns available"]
return f"""You are a business intelligence analyst helping with what-if scenario analysis.
## Context
A user is working on a "{dashboard_name}" dashboard and wants to simulate the cascading effects of changing a metric.
## User's Modification
The user is modifying **{selected_column}** by {sign}{pct_change:.1f}%
## Other Available Columns
These are the other numeric columns available in the dashboard:
{chr(10).join(columns_text)}
## Your Task
Analyze the relationships between these columns and suggest which OTHER columns should also be modified as a cascading effect of the user's change to {selected_column}.
Consider:
1. **Causal relationships**: If column A affects column B in real business scenarios
2. **Mathematical relationships**: Derived metrics, ratios, calculated fields
3. **Domain knowledge**: Industry-standard relationships (e.g., increasing customers often increases orders and revenue)
For each suggested column, provide:
- The appropriate multiplier (proportional, dampened, amplified, or inverse based on the relationship)
- A brief reasoning explaining the relationship (1 sentence)
- Your confidence level (high/medium/low)
Guidelines:
- Only suggest columns that have a clear logical relationship to {selected_column}
- Be conservative - don't suggest modifications without good reasoning
- The multiplier should be realistic (e.g., if {selected_column} increases 10%, a related column might increase 5-15%, not 100%)
- If no clear relationships exist, return an empty suggestions array
Respond in JSON format:
{{
"suggested_modifications": [
{{
"column": "column_name",
"multiplier": 1.08,
"reasoning": "Brief explanation of the relationship",
"confidence": "high"
}}
],
"explanation": "Overall summary of the analysis (1-2 sentences)"
}}"""
def _get_ai_suggestions(self) -> dict[str, Any]:
api_key = current_app.config.get("OPENROUTER_API_KEY")
model = current_app.config.get("OPENROUTER_MODEL", "x-ai/grok-4.1-fast")
api_base = current_app.config.get(
"OPENROUTER_API_BASE", "https://openrouter.ai/api/v1"
)
timeout = current_app.config.get("OPENROUTER_TIMEOUT", 30)
prompt = self._build_prompt()
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": current_app.config.get("WEBDRIVER_BASEURL", ""),
"X-Title": "Apache Superset What-If Analysis",
}
payload = {
"model": model,
"messages": [
{
"role": "system",
"content": (
"You are a business intelligence analyst specializing in "
"data relationships and cascading effects analysis. "
"Respond only with valid JSON."
),
},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
"max_tokens": 1000,
"response_format": {"type": "json_object"},
}
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(
f"{api_base}/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
# Parse the JSON response
parsed = json.loads(content)
# Validate and normalize the response
suggestions = parsed.get("suggested_modifications", [])
validated_suggestions = []
for suggestion in suggestions:
# Ensure required fields exist
if all(
k in suggestion
for k in ["column", "multiplier", "reasoning", "confidence"]
):
# Normalize confidence to lowercase
confidence = suggestion["confidence"].lower()
if confidence not in ("high", "medium", "low"):
confidence = "medium"
validated_suggestions.append(
{
"column": suggestion["column"],
"multiplier": float(suggestion["multiplier"]),
"reasoning": suggestion["reasoning"],
"confidence": confidence,
}
)
return {
"suggested_modifications": validated_suggestions,
"explanation": parsed.get("explanation"),
}
except httpx.HTTPStatusError as ex:
logger.error("OpenRouter API error: %s", ex.response.status_code)
raise OpenRouterAPIError(
f"OpenRouter API error: {ex.response.status_code}"
) from ex
except json.JSONDecodeError as ex:
logger.error("Failed to parse AI response: %s", ex)
raise OpenRouterAPIError("Failed to parse AI response") from ex
except httpx.TimeoutException as ex:
logger.error("OpenRouter API timeout")
raise OpenRouterAPIError("AI service timed out") from ex
except Exception as ex:
logger.exception("Unexpected error calling OpenRouter")
raise OpenRouterAPIError(f"Unexpected error: {ex!s}") from ex

View File

@@ -0,0 +1,99 @@
# 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.
"""What-If Analysis exceptions."""
from flask_babel import lazy_gettext as _
from marshmallow import ValidationError
from superset.commands.exceptions import (
CommandException,
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
ForbiddenError,
)
from superset.exceptions import SupersetException
class WhatIfException(SupersetException):
"""Base exception for What-If Analysis errors."""
class OpenRouterConfigError(WhatIfException):
"""Raised when OpenRouter API is not configured."""
status = 500
message = "OpenRouter API is not configured"
class OpenRouterAPIError(WhatIfException):
"""Raised when there is an error communicating with OpenRouter API."""
status = 502
message = "Error communicating with OpenRouter API"
# =============================================================================
# Simulation persistence exceptions
# =============================================================================
class WhatIfSimulationNameUniquenessError(ValidationError):
"""Validation error for simulation name already exists."""
def __init__(self) -> None:
super().__init__(
[_("Name must be unique for this dashboard")],
field_name="name",
)
class WhatIfSimulationNotFoundError(CommandException):
"""Raised when a simulation is not found."""
status = 404
message = _("What-If simulation not found.")
class WhatIfSimulationInvalidError(CommandInvalidError):
"""Raised when simulation parameters are invalid."""
message = _("What-If simulation parameters are invalid.")
class WhatIfSimulationCreateFailedError(CreateFailedError):
"""Raised when simulation creation fails."""
message = _("What-If simulation could not be created.")
class WhatIfSimulationUpdateFailedError(CreateFailedError):
"""Raised when simulation update fails."""
message = _("What-If simulation could not be updated.")
class WhatIfSimulationDeleteFailedError(DeleteFailedError):
"""Raised when simulation deletion fails."""
message = _("What-If simulation could not be deleted.")
class WhatIfSimulationForbiddenError(ForbiddenError):
"""Raised when user doesn't have permission to access a simulation."""
message = _("You do not have permission to access this simulation.")

View File

@@ -0,0 +1,85 @@
# 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.
"""What-If Simulation persistence models."""
from __future__ import annotations
import json
import uuid
from typing import Any
from flask_appbuilder import Model
from sqlalchemy import Boolean, Column, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
from superset import security_manager
from superset.models.helpers import AuditMixinNullable
class WhatIfSimulation(Model, AuditMixinNullable):
"""Saved What-If simulation configuration."""
__tablename__ = "what_if_simulations"
id = Column(Integer, primary_key=True)
uuid = Column(
UUIDType(binary=True), default=uuid.uuid4, unique=True, nullable=False
)
name = Column(String(256), nullable=False)
description = Column(Text, nullable=True)
dashboard_id = Column(
Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False
)
user_id = Column(Integer, ForeignKey("ab_user.id"), nullable=False)
# JSON column storing modifications array
# Structure: [{"column": "...", "multiplier": 1.1, "filters": [...]}]
modifications_json = Column(Text, nullable=False)
# Whether cascading effects were enabled when saved
cascading_effects_enabled = Column(Boolean, default=False, nullable=False)
# Relationships
dashboard = relationship(
"Dashboard",
foreign_keys=[dashboard_id],
backref="what_if_simulations",
)
user = relationship(
security_manager.user_model,
foreign_keys=[user_id],
)
__table_args__ = (
Index("ix_what_if_simulations_dashboard_user", dashboard_id, user_id),
)
@property
def modifications(self) -> list[dict[str, Any]]:
"""Parse and return modifications from JSON."""
if self.modifications_json:
return json.loads(self.modifications_json)
return []
@modifications.setter
def modifications(self, value: list[dict[str, Any]]) -> None:
"""Serialize modifications to JSON."""
self.modifications_json = json.dumps(value)
def __repr__(self) -> str:
return f"WhatIfSimulation<{self.name}>"

329
superset/what_if/schemas.py Normal file
View File

@@ -0,0 +1,329 @@
# 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.
"""What-If Analysis schemas for request/response validation."""
from marshmallow import fields, Schema
class ChartMetricComparisonSchema(Schema):
"""Schema for a single metric comparison within a chart."""
metric_name = fields.String(
required=True,
metadata={"description": "Name of the metric being compared"},
)
original_value = fields.Float(
required=True,
metadata={"description": "Original metric value before modification"},
)
modified_value = fields.Float(
required=True,
metadata={"description": "Modified metric value after what-if applied"},
)
percentage_change = fields.Float(
required=True,
metadata={"description": "Percentage change from original to modified"},
)
class ChartComparisonSchema(Schema):
"""Schema for chart-level comparison data."""
chart_id = fields.Integer(
required=True,
metadata={"description": "Unique identifier for the chart"},
)
chart_name = fields.String(
required=True,
metadata={"description": "Display name of the chart"},
)
chart_type = fields.String(
required=True,
metadata={"description": "Visualization type (e.g., bar, line, pie)"},
)
metrics = fields.List(
fields.Nested(ChartMetricComparisonSchema),
required=True,
metadata={"description": "List of metric comparisons for this chart"},
)
class WhatIfFilterSchema(Schema):
"""Schema for a what-if filter condition."""
col = fields.String(
required=True,
metadata={"description": "Column name to filter on"},
)
op = fields.String(
required=True,
metadata={
"description": "Filter operator: ==, !=, >, <, >=, <=, IN, NOT IN, TEMPORAL_RANGE"
},
)
val = fields.Raw(
required=True,
metadata={
"description": "Filter value (string, number, or array for IN/NOT IN operators)"
},
)
class ModificationSchema(Schema):
"""Schema for a single what-if modification."""
column = fields.String(
required=True,
metadata={"description": "Column name being modified"},
)
multiplier = fields.Float(
required=True,
metadata={
"description": "Multiplier applied to the column (e.g., 1.1 for +10%)"
},
)
filters = fields.List(
fields.Nested(WhatIfFilterSchema),
required=False,
load_default=None,
metadata={
"description": "Optional filters to apply modification conditionally"
},
)
class WhatIfInterpretRequestSchema(Schema):
"""Schema for what-if interpretation request."""
modifications = fields.List(
fields.Nested(ModificationSchema),
required=True,
metadata={"description": "List of column modifications applied"},
)
charts = fields.List(
fields.Nested(ChartComparisonSchema),
required=True,
metadata={"description": "List of charts with comparison data"},
)
dashboard_name = fields.String(
required=False,
load_default=None,
metadata={"description": "Name of the dashboard for context"},
)
class InsightSchema(Schema):
"""Schema for a single AI-generated insight."""
title = fields.String(
required=True,
metadata={"description": "Short title summarizing the insight"},
)
description = fields.String(
required=True,
metadata={"description": "Detailed description of the insight"},
)
type = fields.String(
required=True,
metadata={
"description": "Type of insight: observation, implication, or recommendation"
},
)
class WhatIfInterpretResponseSchema(Schema):
"""Schema for what-if interpretation response."""
summary = fields.String(
required=True,
metadata={"description": "Executive summary of the what-if analysis"},
)
insights = fields.List(
fields.Nested(InsightSchema),
required=True,
metadata={"description": "List of AI-generated insights"},
)
raw_response = fields.String(
required=False,
metadata={"description": "Raw AI response (only in debug mode)"},
)
# Schemas for suggest_related endpoint
class AvailableColumnSchema(Schema):
"""Schema for an available column with metadata."""
column_name = fields.String(
required=True,
metadata={"description": "Name of the column"},
)
description = fields.String(
required=False,
load_default=None,
metadata={"description": "Column description/documentation"},
)
verbose_name = fields.String(
required=False,
load_default=None,
metadata={"description": "Human-readable column name"},
)
datasource_id = fields.Integer(
required=True,
metadata={"description": "ID of the datasource containing this column"},
)
class WhatIfSuggestRelatedRequestSchema(Schema):
"""Schema for suggest_related request."""
selected_column = fields.String(
required=True,
metadata={"description": "The column the user selected to modify"},
)
user_multiplier = fields.Float(
required=True,
metadata={
"description": "The multiplier the user applied (e.g., 1.1 for +10%)"
},
)
available_columns = fields.List(
fields.Nested(AvailableColumnSchema),
required=True,
metadata={"description": "All numeric columns available in the dashboard"},
)
dashboard_name = fields.String(
required=False,
load_default=None,
metadata={"description": "Name of the dashboard for context"},
)
class SuggestedModificationSchema(Schema):
"""Schema for a single AI-suggested modification."""
column = fields.String(
required=True,
metadata={"description": "Column name to modify"},
)
multiplier = fields.Float(
required=True,
metadata={"description": "Suggested multiplier for this column"},
)
reasoning = fields.String(
required=True,
metadata={"description": "Brief explanation of why this column is related"},
)
confidence = fields.String(
required=True,
metadata={"description": "Confidence level: high, medium, or low"},
)
class WhatIfSuggestRelatedResponseSchema(Schema):
"""Schema for suggest_related response."""
suggested_modifications = fields.List(
fields.Nested(SuggestedModificationSchema),
required=True,
metadata={"description": "List of AI-suggested column modifications"},
)
explanation = fields.String(
required=False,
load_default=None,
metadata={"description": "Overall explanation of the relationship analysis"},
)
# =============================================================================
# Simulation CRUD Schemas
# =============================================================================
class WhatIfSimulationPostSchema(Schema):
"""Schema for creating a What-If simulation."""
name = fields.String(
required=True,
metadata={"description": "Name of the saved simulation"},
)
description = fields.String(
required=False,
allow_none=True,
load_default=None,
metadata={"description": "Optional description"},
)
dashboard_id = fields.Integer(
required=True,
metadata={"description": "ID of the dashboard this simulation belongs to"},
)
modifications = fields.List(
fields.Nested(ModificationSchema),
required=True,
metadata={"description": "List of column modifications"},
)
cascading_effects_enabled = fields.Boolean(
required=False,
load_default=False,
metadata={"description": "Whether AI cascading effects were enabled"},
)
class WhatIfSimulationPutSchema(Schema):
"""Schema for updating a What-If simulation."""
name = fields.String(
required=False,
metadata={"description": "Name of the saved simulation"},
)
description = fields.String(
required=False,
allow_none=True,
metadata={"description": "Optional description"},
)
modifications = fields.List(
fields.Nested(ModificationSchema),
required=False,
metadata={"description": "List of column modifications"},
)
cascading_effects_enabled = fields.Boolean(
required=False,
metadata={"description": "Whether AI cascading effects were enabled"},
)
class WhatIfSimulationResponseSchema(Schema):
"""Schema for simulation response."""
id = fields.Integer(metadata={"description": "Simulation ID"})
uuid = fields.String(metadata={"description": "Simulation UUID"})
name = fields.String(metadata={"description": "Simulation name"})
description = fields.String(
allow_none=True,
metadata={"description": "Simulation description"},
)
dashboard_id = fields.Integer(metadata={"description": "Dashboard ID"})
modifications = fields.List(
fields.Nested(ModificationSchema),
metadata={"description": "Saved modifications"},
)
cascading_effects_enabled = fields.Boolean(
metadata={"description": "Whether cascading effects were enabled"},
)
created_on = fields.DateTime(metadata={"description": "Creation timestamp"})
changed_on = fields.DateTime(metadata={"description": "Last modified timestamp"})

File diff suppressed because it is too large Load Diff