This is an automated email from the ASF dual-hosted git repository. kgabryje pushed a commit to branch what-if in repository https://gitbox.apache.org/repos/asf/superset.git
commit 54518daea0a23fab673769490470fe12745f1b83 Author: Kamil Gabryjelski <[email protected]> AuthorDate: Tue Dec 16 22:39:06 2025 +0100 banner --- .../src/components/Icons/AntdEnhanced.tsx | 2 + .../src/components/Chart/chartAction.js | 16 ++ .../src/components/Chart/chartReducer.ts | 27 ++++ .../DashboardBuilder/DashboardBuilder.tsx | 24 ++- .../dashboard/components/WhatIfBanner/index.tsx | 167 +++++++++++++++++++++ .../dashboard/components/WhatIfDrawer/index.tsx | 18 ++- 6 files changed, 244 insertions(+), 10 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index a079b54c16..8ff6302d3b 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -66,6 +66,7 @@ import { ExclamationCircleOutlined, ExclamationCircleFilled, ExpandOutlined, + ExperimentOutlined, EyeOutlined, EyeInvisibleOutlined, FallOutlined, @@ -204,6 +205,7 @@ const AntdIcons = { ExclamationCircleOutlined, ExclamationCircleFilled, ExpandOutlined, + ExperimentOutlined, EyeOutlined, EyeInvisibleOutlined, FacebookOutlined, diff --git a/superset-frontend/src/components/Chart/chartAction.js b/superset-frontend/src/components/Chart/chartAction.js index bd9bd94a56..92564b235c 100644 --- a/superset-frontend/src/components/Chart/chartAction.js +++ b/superset-frontend/src/components/Chart/chartAction.js @@ -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, diff --git a/superset-frontend/src/components/Chart/chartReducer.ts b/superset-frontend/src/components/Chart/chartReducer.ts index 081bd41547..b3bc0e7a54 100644 --- a/superset-frontend/src/components/Chart/chartReducer.ts +++ b/superset-frontend/src/components/Chart/chartReducer.ts @@ -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 */ diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 3961b1e63a..6a02ed3e57 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -57,6 +57,7 @@ import { } 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 { @@ -275,12 +276,15 @@ const StyledDashboardContent = styled.div<{ editMode: boolean; marginLeft: number; marginRight: number; + hasWhatIfPanel: boolean; }>` - ${({ theme, editMode, marginLeft, marginRight }) => 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; @@ -290,13 +294,13 @@ 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; margin-right: ${marginRight}px; + min-width: 0; /* Prevent grid blowout */ ${editMode && ` @@ -312,6 +316,8 @@ const StyledDashboardContent = styled.div<{ } .dashboard-builder-sidepane { + grid-column: 2; + grid-row: 1 / -1; /* Span all rows */ width: ${BUILDER_SIDEPANEL_WIDTH}px; z-index: 1; } @@ -688,7 +694,9 @@ const DashboardBuilder = () => { editMode={editMode} marginLeft={dashboardContentMarginLeft} marginRight={dashboardContentMarginRight} + hasWhatIfPanel={!editMode && whatIfPanelOpen} > + {!editMode && <WhatIfBanner topOffset={barTopOffset} />} {showDashboard ? ( missingInitialFilters.length > 0 ? ( <div @@ -698,6 +706,8 @@ const DashboardBuilder = () => { align-items: center; justify-content: center; flex: 1; + grid-column: 1; + grid-row: 2; & div { width: 500px; } diff --git a/superset-frontend/src/dashboard/components/WhatIfBanner/index.tsx b/superset-frontend/src/dashboard/components/WhatIfBanner/index.tsx new file mode 100644 index 0000000000..fa01d1f45a --- /dev/null +++ b/superset-frontend/src/dashboard/components/WhatIfBanner/index.tsx @@ -0,0 +1,167 @@ +/** + * 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 { 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 { getNumericColumnsForDashboard } from 'src/dashboard/util/whatIf'; +import { RootState, WhatIfModification } from 'src/dashboard/types'; + +/** + * 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}; + } + } +`; + +const formatPercentageChange = (multiplier: number): string => { + const percentChange = (multiplier - 1) * 100; + const sign = percentChange >= 0 ? '+' : ''; + return `${sign}${Math.round(percentChange)}%`; +}; + +interface WhatIfBannerProps { + topOffset: number; +} + +const WhatIfBanner = ({ topOffset }: WhatIfBannerProps) => { + const theme = useTheme(); + const dispatch = useDispatch(); + + const whatIfModifications = useSelector<RootState, WhatIfModification[]>( + state => state.dashboardState.whatIfModifications || [], + ); + + const charts = useSelector((state: RootState) => state.charts); + const datasources = useSelector((state: RootState) => state.datasources); + + const numericColumns = useMemo( + () => getNumericColumnsForDashboard(charts, datasources), + [charts, datasources], + ); + + const columnToChartIds = useMemo(() => { + const map = new Map<string, number[]>(); + numericColumns.forEach(col => { + map.set(col.columnName, col.usedByChartIds); + }); + return map; + }, [numericColumns]); + + 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); + + 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', + modification.column, + 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; diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx index d7eca32cc8..6caade5384 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx @@ -24,7 +24,10 @@ import { Button, Select } from '@superset-ui/core/components'; import Slider from '@superset-ui/core/components/Slider'; import { Icons } from '@superset-ui/core/components/Icons'; import { setWhatIfModifications } from 'src/dashboard/actions/dashboardState'; -import { triggerQuery } from 'src/components/Chart/chartAction'; +import { + triggerQuery, + saveOriginalChartData, +} from 'src/components/Chart/chartAction'; import { getNumericColumnsForDashboard } from 'src/dashboard/util/whatIf'; import { RootState, WhatIfColumn } from 'src/dashboard/types'; @@ -35,6 +38,8 @@ const SLIDER_MAX = 50; const SLIDER_DEFAULT = 0; const PanelContainer = styled.div<{ topOffset: number }>` + 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}; @@ -45,7 +50,7 @@ const PanelContainer = styled.div<{ topOffset: number }>` position: sticky; top: ${({ topOffset }) => topOffset}px; height: calc(100vh - ${({ topOffset }) => topOffset}px); - align-self: flex-start; + align-self: start; z-index: 10; `; @@ -162,6 +167,14 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { const multiplier = 1 + sliderValue / 100; + // Get affected chart IDs + const affectedChartIds = columnToChartIds.get(selectedColumn) || []; + + // Save original chart data before applying what-if modifications + affectedChartIds.forEach(chartId => { + dispatch(saveOriginalChartData(chartId)); + }); + // Set the what-if modifications in Redux state dispatch( setWhatIfModifications([ @@ -173,7 +186,6 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { ); // Trigger queries for all charts that use the selected column - const affectedChartIds = columnToChartIds.get(selectedColumn) || []; affectedChartIds.forEach(chartId => { dispatch(triggerQuery(true, chartId)); });
