You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by kg...@apache.org on 2025/12/18 23:55:35 UTC
(superset) 21/23: Code cleanup + HARRY POTTER
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 888e14eb0c159b6991a4b783a4d03b3d0590936c
Author: Kamil Gabryjelski <ka...@gmail.com>
AuthorDate: Thu Dec 18 23:29:22 2025 +0100
Code cleanup + HARRY POTTER
---
.../dashboard/components/WhatIfBanner/index.tsx | 31 +-
.../components/WhatIfDrawer/FilterSection.tsx | 153 ++++
.../WhatIfDrawer/HarryPotterWandLoader.tsx | 493 +++++++++++++
.../WhatIfDrawer/ModificationsDisplay.tsx | 117 +++
.../components/WhatIfDrawer/WhatIfAIInsights.tsx | 47 +-
.../dashboard/components/WhatIfDrawer/constants.ts | 38 +
.../dashboard/components/WhatIfDrawer/index.tsx | 799 +++------------------
.../dashboard/components/WhatIfDrawer/styles.ts | 207 ++++++
.../src/dashboard/components/WhatIfDrawer/types.ts | 32 +-
.../components/WhatIfDrawer/useChartComparison.ts | 25 +-
.../components/WhatIfDrawer/useWhatIfApply.ts | 233 ++++++
.../components/WhatIfDrawer/useWhatIfFilters.ts | 227 ++++++
superset-frontend/src/dashboard/types.ts | 1 +
.../src/dashboard/util/useNumericColumns.ts | 59 ++
superset-frontend/src/dashboard/util/whatIf.ts | 68 +-
15 files changed, 1729 insertions(+), 801 deletions(-)
diff --git a/superset-frontend/src/dashboard/components/WhatIfBanner/index.tsx b/superset-frontend/src/dashboard/components/WhatIfBanner/index.tsx
index 2eca2d7b90..4fd5a6a546 100644
--- a/superset-frontend/src/dashboard/components/WhatIfBanner/index.tsx
+++ b/superset-frontend/src/dashboard/components/WhatIfBanner/index.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useCallback, useMemo } from 'react';
+import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
@@ -24,8 +24,9 @@ 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, Slice, WhatIfModification } from 'src/dashboard/types';
+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[] = [];
@@ -83,12 +84,6 @@ const ExitButton = styled(Button)`
}
`;
-const formatPercentageChange = (multiplier: number): string => {
- const percentChange = (multiplier - 1) * 100;
- const sign = percentChange >= 0 ? '+' : '';
- return `${sign}${Math.round(percentChange)}%`;
-};
-
interface WhatIfBannerProps {
topOffset: number;
}
@@ -101,23 +96,7 @@ const WhatIfBanner = ({ topOffset }: WhatIfBannerProps) => {
state => state.dashboardState.whatIfModifications ?? EMPTY_MODIFICATIONS,
);
- 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]);
+ const { columnToChartIds } = useNumericColumns();
const handleExitWhatIf = useCallback(() => {
const affectedChartIds = new Set<number>();
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/FilterSection.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/FilterSection.tsx
new file mode 100644
index 0000000000..16ad32555f
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/FilterSection.tsx
@@ -0,0 +1,153 @@
+/**
+ * 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 { css, useTheme } from '@apache-superset/core/ui';
+import { Tooltip, Tag, Popover } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { WhatIfFilter, Datasource } from 'src/dashboard/types';
+import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
+import AdhocFilterEditPopover from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopover';
+import {
+ FilterButton,
+ FilterPopoverContent,
+ FiltersSection,
+ FilterTagsContainer,
+ Label,
+} from './styles';
+
+interface FilterSectionProps {
+ filters: WhatIfFilter[];
+ 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;
+ onEditFilter: (index: number) => void;
+ onRemoveFilter: (e: React.MouseEvent, index: number) => void;
+ formatFilterLabel: (filter: WhatIfFilter) => string;
+}
+
+/**
+ * Component for rendering the filter button and filter tags.
+ * Uses memo to prevent unnecessary re-renders when parent state changes
+ * that don't affect this component.
+ */
+const FilterSection = memo(function FilterSection({
+ filters,
+ filterPopoverVisible,
+ currentAdhocFilter,
+ selectedColumn,
+ selectedDatasource,
+ filterColumnOptions,
+ onOpenFilterPopover,
+ onFilterPopoverVisibleChange,
+ onFilterChange,
+ onFilterPopoverClose,
+ onFilterPopoverResize,
+ onEditFilter,
+ onRemoveFilter,
+ formatFilterLabel,
+}: FilterSectionProps) {
+ const theme = useTheme();
+
+ 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')
+ }
+ >
+ <FilterButton
+ onClick={onOpenFilterPopover}
+ disabled={!selectedColumn || !selectedDatasource}
+ aria-label={t('Add filter')}
+ buttonStyle="tertiary"
+ >
+ <Icons.FilterOutlined iconSize="m" />
+ </FilterButton>
+ </Tooltip>
+ </Popover>
+
+ {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 => onRemoveFilter(e, index)}
+ onClick={() => onEditFilter(index)}
+ css={css`
+ cursor: pointer;
+ margin: 0;
+ &:hover {
+ opacity: 0.8;
+ }
+ `}
+ >
+ {formatFilterLabel(filter)}
+ </Tag>
+ ))}
+ </FilterTagsContainer>
+ </FiltersSection>
+ )}
+ </>
+ );
+});
+
+export default FilterSection;
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/HarryPotterWandLoader.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/HarryPotterWandLoader.tsx
new file mode 100644
index 0000000000..f7dcbde50c
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/HarryPotterWandLoader.tsx
@@ -0,0 +1,493 @@
+/**
+ * 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(26, 26, 46, 0.25) 0%, rgba(13, 13, 26, 0.28) 50%, rgba(0, 0, 5, 0.3) 100%);
+ backdrop-filter: blur(3px);
+ 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: #87CEEB;
+ animation: ${textGlow} 2s ease-in-out infinite;
+ letter-spacing: 3px;
+`;
+
+const SubText = styled.div`
+ margin-top: 16px;
+ font-size: 14px;
+ color: #6495ED;
+ opacity: 0.8;
+ letter-spacing: 1px;
+`;
+
+const DismissHint = styled.div`
+ margin-top: 32px;
+ font-size: 12px;
+ color: #4a4a6a;
+ opacity: 0.6;
+`;
+/* 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;
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/ModificationsDisplay.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/ModificationsDisplay.tsx
new file mode 100644
index 0000000000..44f19241d5
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/ModificationsDisplay.tsx
@@ -0,0 +1,117 @@
+/**
+ * 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.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, 1)}
+ </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.column}:</strong> {mod.reasoning}
+ </AIReasoningItem>
+ ))}
+ </AIReasoningContent>
+ )}
+ </AIReasoningSection>
+ )}
+ </ModificationsSection>
+ );
+});
+
+export default ModificationsDisplay;
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfAIInsights.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfAIInsights.tsx
index 9f4e1616ee..dda919da61 100644
--- a/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfAIInsights.tsx
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfAIInsights.tsx
@@ -33,6 +33,10 @@ import {
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 };
+
/**
* Create a stable key from modifications for comparison.
* This allows us to detect when modifications have meaningfully changed.
@@ -150,49 +154,12 @@ const WhatIfAIInsights = ({
const modificationsKey = getModificationsKey(modifications);
const prevModificationsKeyRef = useRef<string>(modificationsKey);
- // Debug logging for race condition diagnosis
- const willTriggerFetch =
- modifications.length > 0 &&
- chartComparisons.length > 0 &&
- allChartsLoaded &&
- status === 'idle';
-
- console.log('[WhatIfAIInsights] State:', {
- affectedChartIds,
- allChartsLoaded,
- chartComparisonsLength: chartComparisons.length,
- modificationsLength: modifications.length,
- status,
- modificationsKey,
- willTriggerFetch,
- });
-
- // Log chart comparison details when about to fetch (helps diagnose race conditions)
- if (willTriggerFetch && chartComparisons.length > 0) {
- console.log(
- '[WhatIfAIInsights] Chart comparisons to send:',
- chartComparisons.map(c => ({
- chartId: c.chartId,
- chartName: c.chartName,
- metrics: c.metrics.map(m => ({
- name: m.metricName,
- original: m.originalValue,
- modified: m.modifiedValue,
- change: `${m.percentageChange.toFixed(2)}%`,
- })),
- })),
- );
- }
-
// Reset status when modifications change (user adjusts the slider)
useEffect(() => {
if (
modificationsKey !== prevModificationsKeyRef.current &&
modifications.length > 0
) {
- console.log(
- '[WhatIfAIInsights] Modifications changed, resetting status to idle',
- );
// 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
@@ -280,7 +247,9 @@ const WhatIfAIInsights = ({
{t('AI Insights')}
</InsightsHeader>
- {status === 'loading' && <Skeleton active paragraph={{ rows: 3 }} />}
+ {status === 'loading' && (
+ <Skeleton active paragraph={SKELETON_PARAGRAPH_3} />
+ )}
{status === 'error' && (
<Alert
@@ -305,7 +274,7 @@ const WhatIfAIInsights = ({
)}
{status === 'idle' && !allChartsLoaded && (
- <Skeleton active paragraph={{ rows: 2 }} />
+ <Skeleton active paragraph={SKELETON_PARAGRAPH_2} />
)}
</InsightsContainer>
);
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/constants.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/constants.ts
new file mode 100644
index 0000000000..02e6fa52c3
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/constants.ts
@@ -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 };
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx
index 9dee0ca6ae..7d00748bcf 100644
--- a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx
@@ -16,232 +16,51 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { t, logging, formatTimeRangeLabel } from '@superset-ui/core';
-import { css, styled, Alert, useTheme } from '@apache-superset/core/ui';
+
+import { useCallback, useMemo, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { t } from '@superset-ui/core';
+import { css, Alert, useTheme } from '@apache-superset/core/ui';
import {
- Button,
Select,
Checkbox,
Tooltip,
- Tag,
- Popover,
+ CheckboxChangeEvent,
} 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 { useNumericColumns } from 'src/dashboard/util/useNumericColumns';
+import { RootState, Datasource } from 'src/dashboard/types';
+import WhatIfAIInsights from './WhatIfAIInsights';
+import HarryPotterWandLoader from './HarryPotterWandLoader';
+import FilterSection from './FilterSection';
+import ModificationsDisplay from './ModificationsDisplay';
+import { useWhatIfFilters } from './useWhatIfFilters';
+import { useWhatIfApply } from './useWhatIfApply';
import {
- triggerQuery,
- saveOriginalChartData,
-} from 'src/components/Chart/chartAction';
-import { getNumericColumnsForDashboard } from 'src/dashboard/util/whatIf';
+ SLIDER_MIN,
+ SLIDER_MAX,
+ SLIDER_DEFAULT,
+ SLIDER_MARKS,
+ SLIDER_TOOLTIP_CONFIG,
+ WHAT_IF_PANEL_WIDTH,
+} from './constants';
import {
- RootState,
- Slice,
- WhatIfColumn,
- WhatIfFilter,
- Datasource,
-} from 'src/dashboard/types';
-import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
-import AdhocFilterEditPopover from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopover';
-import { Clauses } from 'src/explore/components/controls/FilterControl/types';
-import { OPERATOR_ENUM_TO_OPERATOR_TYPE } from 'src/explore/constants';
-import WhatIfAIInsights from './WhatIfAIInsights';
-import MagicWandLoader from './MagicWandLoader';
-import { fetchRelatedColumnSuggestions } from './whatIfApi';
-import { ExtendedWhatIfModification } from './types';
-
-export const WHAT_IF_PANEL_WIDTH = 340;
-
-const SLIDER_MIN = -50;
-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};
- border-left: 1px solid ${({ theme }) => theme.colorBorderSecondary};
- display: flex;
- flex-direction: column;
- overflow: hidden;
- position: sticky;
- top: ${({ topOffset }) => topOffset}px;
- height: calc(100vh - ${({ topOffset }) => topOffset}px);
- align-self: start;
- z-index: 10;
-`;
-
-const PanelHeader = styled.div`
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: ${({ theme }) => theme.sizeUnit * 3}px
- ${({ theme }) => theme.sizeUnit * 4}px;
- border-bottom: 1px solid ${({ theme }) => theme.colorBorderSecondary};
-`;
-
-const PanelTitle = styled.div`
- display: flex;
- align-items: center;
- gap: ${({ theme }) => theme.sizeUnit * 2}px;
- font-weight: ${({ theme }) => theme.fontWeightStrong};
- font-size: ${({ theme }) => theme.fontSizeLG}px;
-`;
-
-const CloseButton = styled.button`
- background: none;
- border: none;
- cursor: pointer;
- padding: ${({ theme }) => theme.sizeUnit}px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: ${({ theme }) => theme.colorTextSecondary};
- border-radius: ${({ theme }) => theme.borderRadius}px;
-
- &:hover {
- background-color: ${({ theme }) => theme.colorBgTextHover};
- color: ${({ theme }) => theme.colorText};
- }
-`;
-
-const PanelContent = styled.div`
- flex: 1;
- overflow-y: auto;
- padding: ${({ theme }) => theme.sizeUnit * 4}px;
- display: flex;
- flex-direction: column;
- gap: ${({ theme }) => theme.sizeUnit * 5}px;
-`;
-
-const FormSection = styled.div`
- display: flex;
- flex-direction: column;
- gap: ${({ theme }) => theme.sizeUnit * 2}px;
-`;
-
-const Label = styled.label`
- color: ${({ theme }) => theme.colorText};
-`;
-
-const SliderContainer = styled.div`
- padding: 0 ${({ theme }) => theme.sizeUnit}px;
- & .ant-slider-mark {
- font-size: ${({ theme }) => theme.fontSizeSM}px;
- }
-`;
-
-const ApplyButton = styled(Button)`
- width: 100%;
- min-height: 32px;
-`;
-
-const CheckboxContainer = styled.div`
- display: flex;
- align-items: center;
- gap: ${({ theme }) => theme.sizeUnit}px;
-`;
-
-const ModificationsSection = styled.div`
- display: flex;
- flex-direction: column;
- gap: ${({ theme }) => theme.sizeUnit * 5}px;
-`;
-
-const ModificationTagsContainer = styled.div`
- display: flex;
- flex-wrap: wrap;
- gap: ${({ theme }) => theme.sizeUnit * 2}px;
-`;
-
-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;
-`;
-
-const AIReasoningSection = styled.div`
- display: flex;
- flex-direction: column;
- gap: ${({ theme }) => theme.sizeUnit}px;
-`;
-
-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};
- }
-`;
-
-const AIReasoningContent = styled.div`
- display: flex;
- flex-direction: column;
- gap: ${({ theme }) => theme.sizeUnit}px;
- padding-left: ${({ theme }) => theme.sizeUnit * 4}px;
-`;
-
-const AIReasoningItem = styled.div`
- font-size: ${({ theme }) => theme.fontSizeSM}px;
- color: ${({ theme }) => theme.colorTextSecondary};
-`;
-
-const ColumnSelectRow = styled.div`
- display: flex;
- gap: ${({ theme }) => theme.sizeUnit * 2}px;
- align-items: flex-start;
-`;
-
-const ColumnSelectWrapper = styled.div`
- flex: 1;
- min-width: 0;
-`;
-
-const FilterButton = styled(Button)`
- flex-shrink: 0;
- padding: 0 ${({ theme }) => theme.sizeUnit * 2}px;
-`;
-
-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;
- }
-`;
-
-const FiltersSection = styled.div`
- display: flex;
- flex-direction: column;
- gap: ${({ theme }) => theme.sizeUnit * 2}px;
-`;
-
-const FilterTagsContainer = styled.div`
- display: flex;
- flex-wrap: wrap;
- gap: ${({ theme }) => theme.sizeUnit}px;
-`;
+ PanelContainer,
+ PanelHeader,
+ PanelTitle,
+ CloseButton,
+ PanelContent,
+ FormSection,
+ Label,
+ SliderContainer,
+ ApplyButton,
+ CheckboxContainer,
+ ColumnSelectRow,
+ ColumnSelectWrapper,
+} from './styles';
+
+export { WHAT_IF_PANEL_WIDTH };
interface WhatIfPanelProps {
onClose: () => void;
@@ -250,50 +69,51 @@ interface WhatIfPanelProps {
const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => {
const theme = useTheme();
- const dispatch = useDispatch();
- const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
+ // Local state for column selection and slider
+ const [selectedColumn, setSelectedColumn] = useState<string | undefined>(
+ undefined,
+ );
const [sliderValue, setSliderValue] = useState<number>(SLIDER_DEFAULT);
- const [affectedChartIds, setAffectedChartIds] = useState<number[]>([]);
const [enableCascadingEffects, setEnableCascadingEffects] = useState(false);
- const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
- const [appliedModifications, setAppliedModifications] = useState<
- ExtendedWhatIfModification[]
- >([]);
- // Counter that increments each time Apply is clicked, used as key to reset AI insights
- const [applyCounter, setApplyCounter] = useState(0);
- const [showAIReasoning, setShowAIReasoning] = useState(false);
-
- // Filter state
- const [filters, setFilters] = useState<WhatIfFilter[]>([]);
- const [filterPopoverVisible, setFilterPopoverVisible] = useState(false);
- const [editingFilterIndex, setEditingFilterIndex] = useState<number | null>(
- null,
- );
- const [currentAdhocFilter, setCurrentAdhocFilter] =
- useState<AdhocFilter | null>(null);
-
- // AbortController for cancelling in-flight /suggest_related requests
- const suggestionsAbortControllerRef = useRef<AbortController | null>(null);
- // Cleanup: cancel any pending requests on unmount
- useEffect(
- () => () => {
- suggestionsAbortControllerRef.current?.abort();
- },
- [],
- );
+ // Custom hook for filter management
+ const {
+ filters,
+ filterPopoverVisible,
+ currentAdhocFilter,
+ setFilterPopoverVisible,
+ handleOpenFilterPopover,
+ handleEditFilter,
+ handleFilterChange,
+ handleRemoveFilter,
+ handleFilterPopoverClose,
+ handleFilterPopoverResize,
+ clearFilters,
+ formatFilterLabel,
+ } = useWhatIfFilters();
+
+ // Custom hook for apply logic and modifications
+ const {
+ appliedModifications,
+ affectedChartIds,
+ isLoadingSuggestions,
+ applyCounter,
+ handleApply,
+ handleDismissLoader,
+ aiInsightsModifications,
+ } = useWhatIfApply({
+ selectedColumn,
+ sliderValue,
+ filters,
+ enableCascadingEffects,
+ });
- const slices = useSelector(
- (state: RootState) => state.sliceEntities.slices as { [id: number]: Slice },
- );
+ // Get numeric columns and datasources
+ const { numericColumns } = useNumericColumns();
const datasources = useSelector((state: RootState) => state.datasources);
- const numericColumns = useMemo(
- () => getNumericColumnsForDashboard(slices, datasources),
- [slices, datasources],
- );
-
+ // Column options for the select dropdown
const columnOptions = useMemo(
() =>
numericColumns.map(col => ({
@@ -303,21 +123,13 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => {
[numericColumns],
);
- // Create a map from column name to affected chart IDs
- const columnToChartIds = useMemo(() => {
- const map = new Map<string, number[]>();
- numericColumns.forEach((col: WhatIfColumn) => {
- map.set(col.columnName, col.usedByChartIds);
- });
- return map;
- }, [numericColumns]);
-
- // Find the datasource for the selected column
+ // 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"
@@ -334,298 +146,27 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => {
return selectedDatasource.columns;
}, [selectedDatasource]);
- // 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,
- });
+ // 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 handleColumnChange = useCallback((value: string | null) => {
- setSelectedColumn(value);
- // Clear filters when column changes since they're tied to the datasource
- setFilters([]);
- }, []);
-
const handleSliderChange = useCallback((value: number) => {
setSliderValue(value);
}, []);
- // Filter handlers
- 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 handleCascadingEffectsChange = useCallback((e: CheckboxChangeEvent) => {
+ setEnableCascadingEffects(e.target.checked);
}, []);
- 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);
- }, []);
-
- // No-op handler for popover resize
- const handleFilterPopoverResize = useCallback(() => {}, []);
-
- const dashboardInfo = useSelector((state: RootState) => state.dashboardInfo);
-
- const handleApply = useCallback(async () => {
- if (!selectedColumn) return;
-
- // Cancel any in-flight suggestions request
- suggestionsAbortControllerRef.current?.abort();
-
- // Immediately clear previous results and increment counter to reset AI insights component
- setAppliedModifications([]);
- setAffectedChartIds([]);
- setApplyCounter(c => c + 1);
-
- const multiplier = 1 + sliderValue / 100;
-
- // Base user modification with filters
- const userModification: ExtendedWhatIfModification = {
- column: selectedColumn,
- multiplier,
- isAISuggested: false,
- filters: filters.length > 0 ? filters : undefined,
- };
-
- 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 => ({
- column: mod.column,
- multiplier: mod.multiplier,
- isAISuggested: true,
- reasoning: mod.reasoning,
- confidence: mod.confidence,
- filters: filters.length > 0 ? filters : undefined,
- }));
-
- 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 isApplyDisabled =
!selectedColumn || sliderValue === SLIDER_DEFAULT || isLoadingSuggestions;
- // Helper to format percentage change
- const formatPercentage = (multiplier: number): string => {
- const pct = (multiplier - 1) * 100;
- const sign = pct >= 0 ? '+' : '';
- return `${sign}${pct.toFixed(1)}%`;
- };
-
- // Helper to format filter for display (matching Explore filter label format)
- const formatFilterLabel = (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}`;
- };
-
- const sliderMarks = {
- [SLIDER_MIN]: `${SLIDER_MIN}%`,
- 0: '0%',
- [SLIDER_MAX]: `+${SLIDER_MAX}%`,
- };
-
return (
<PanelContainer data-test="what-if-panel" topOffset={topOffset}>
<PanelHeader>
@@ -642,6 +183,7 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => {
<Icons.CloseOutlined iconSize="m" />
</CloseButton>
</PanelHeader>
+
<PanelContent>
<FormSection>
<Label>{t('Select column to adjust')}</Label>
@@ -657,77 +199,23 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => {
ariaLabel={t('Select column to adjust')}
/>
</ColumnSelectWrapper>
- <Popover
- open={filterPopoverVisible}
- onOpenChange={setFilterPopoverVisible}
- trigger="click"
- placement="left"
- destroyOnHidden
- content={
- currentAdhocFilter && selectedDatasource ? (
- <FilterPopoverContent>
- <AdhocFilterEditPopover
- adhocFilter={currentAdhocFilter}
- options={filterColumnOptions}
- datasource={selectedDatasource}
- onChange={handleFilterChange}
- onClose={handleFilterPopoverClose}
- onResize={handleFilterPopoverResize}
- requireSave
- />
- </FilterPopoverContent>
- ) : null
- }
- >
- <Tooltip
- title={
- selectedColumn
- ? t('Add filter to scope the modification')
- : t('Select a column first')
- }
- >
- <FilterButton
- onClick={handleOpenFilterPopover}
- disabled={!selectedColumn || !selectedDatasource}
- aria-label={t('Add filter')}
- buttonStyle="tertiary"
- >
- <Icons.FilterOutlined iconSize="m" />
- </FilterButton>
- </Tooltip>
- </Popover>
+ <FilterSection
+ filters={filters}
+ filterPopoverVisible={filterPopoverVisible}
+ currentAdhocFilter={currentAdhocFilter}
+ selectedColumn={selectedColumn}
+ selectedDatasource={selectedDatasource}
+ filterColumnOptions={filterColumnOptions}
+ onOpenFilterPopover={handleOpenFilterPopover}
+ onFilterPopoverVisibleChange={setFilterPopoverVisible}
+ onFilterChange={handleFilterChange}
+ onFilterPopoverClose={handleFilterPopoverClose}
+ onFilterPopoverResize={handleFilterPopoverResize}
+ onEditFilter={handleEditFilter}
+ onRemoveFilter={handleRemoveFilter}
+ formatFilterLabel={formatFilterLabel}
+ />
</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>
@@ -738,11 +226,8 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => {
max={SLIDER_MAX}
value={sliderValue}
onChange={handleSliderChange}
- marks={sliderMarks}
- tooltip={{
- formatter: (value?: number) =>
- value !== undefined ? `${value > 0 ? '+' : ''}${value}%` : '',
- }}
+ marks={SLIDER_MARKS}
+ tooltip={SLIDER_TOOLTIP_CONFIG}
/>
</SliderContainer>
</FormSection>
@@ -750,13 +235,13 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => {
<CheckboxContainer>
<Checkbox
checked={enableCascadingEffects}
- onChange={e => setEnableCascadingEffects(e.target.checked)}
+ onChange={handleCascadingEffectsChange}
>
- {t('AI-powered cascading effects')}
+ {t('Show the bigger picture with AI')}
</Checkbox>
<Tooltip
title={t(
- 'When enabled, AI will analyze column relationships and automatically suggest related columns that should also be modified.',
+ '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
@@ -791,81 +276,19 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => {
/>
)}
- {appliedModifications.length > 0 && (
- <ModificationsSection>
- <ModificationTagsContainer>
- {appliedModifications.map((mod, idx) => (
- <Tag
- key={idx}
- css={css`
- display: inline-flex;
- align-items: center;
- gap: ${theme.sizeUnit}px;
- margin: 0;
- `}
- >
- <span>{mod.column}</span>
- {mod.isAISuggested && <AIBadge>{t('AI')}</AIBadge>}
- <span
- css={css`
- font-weight: ${theme.fontWeightStrong};
- color: ${mod.multiplier >= 1
- ? theme.colorSuccess
- : theme.colorError};
- `}
- >
- {formatPercentage(mod.multiplier)}
- </span>
- </Tag>
- ))}
- </ModificationTagsContainer>
- {appliedModifications.some(mod => mod.reasoning) && (
- <AIReasoningSection>
- <AIReasoningToggle
- onClick={() => setShowAIReasoning(!showAIReasoning)}
- >
- {showAIReasoning ? (
- <Icons.DownOutlined iconSize="xs" />
- ) : (
- <Icons.RightOutlined iconSize="xs" />
- )}
- {t('How AI chose these')}
- </AIReasoningToggle>
- {showAIReasoning && (
- <AIReasoningContent>
- {appliedModifications
- .filter(mod => mod.reasoning)
- .map((mod, idx) => (
- <AIReasoningItem key={idx}>
- <strong>{mod.column}:</strong> {mod.reasoning}
- </AIReasoningItem>
- ))}
- </AIReasoningContent>
- )}
- </AIReasoningSection>
- )}
- </ModificationsSection>
- )}
+ <ModificationsDisplay modifications={appliedModifications} />
{affectedChartIds.length > 0 && (
<WhatIfAIInsights
key={applyCounter}
affectedChartIds={affectedChartIds}
- modifications={appliedModifications.map(mod => ({
- column: mod.column,
- multiplier: mod.multiplier,
- filters: mod.filters,
- }))}
+ modifications={aiInsightsModifications}
/>
)}
</PanelContent>
+
{isLoadingSuggestions && (
- <MagicWandLoader
- onDismiss={() => {
- suggestionsAbortControllerRef.current?.abort();
- setIsLoadingSuggestions(false);
- }}
- />
+ <HarryPotterWandLoader onDismiss={handleDismissLoader} />
)}
</PanelContainer>
);
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts
new file mode 100644
index 0000000000..34154b061d
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts
@@ -0,0 +1,207 @@
+/**
+ * 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 }>`
+ 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: flex;
+ 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 FilterButton = 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;
+`;
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/types.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/types.ts
index 2c1ea2a02d..838ad6770c 100644
--- a/superset-frontend/src/dashboard/components/WhatIfDrawer/types.ts
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/types.ts
@@ -17,6 +17,17 @@
* 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;
@@ -31,23 +42,7 @@ export interface ChartComparison {
metrics: ChartMetricComparison[];
}
-export type WhatIfFilterOperator =
- | '=='
- | '!='
- | '>'
- | '<'
- | '>='
- | '<='
- | 'IN'
- | 'NOT IN'
- | 'TEMPORAL_RANGE';
-
-export interface WhatIfFilter {
- col: string;
- op: WhatIfFilterOperator;
- val: string | number | boolean | Array<string | number>;
-}
-
+// Types for /interpret API endpoint
export interface WhatIfInterpretRequest {
modifications: Array<{
column: string;
@@ -72,8 +67,7 @@ export interface WhatIfInterpretResponse {
export type WhatIfAIStatus = 'idle' | 'loading' | 'success' | 'error';
-// Types for suggest_related endpoint
-
+// Types for /suggest_related API endpoint
export interface AvailableColumn {
columnName: string;
description?: string | null;
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/useChartComparison.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/useChartComparison.ts
index b5d75dd949..54aea40364 100644
--- a/superset-frontend/src/dashboard/components/WhatIfDrawer/useChartComparison.ts
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/useChartComparison.ts
@@ -128,11 +128,10 @@ export function useIsChartInActiveTab() {
export function useChartsInActiveTabs(chartIds: number[]): number[] {
const isChartInActiveTab = useIsChartInActiveTab();
- return useMemo(() => {
- const visibleCharts = chartIds.filter(isChartInActiveTab);
- console.log('[useChartsInActiveTabs] Visible charts:', visibleCharts);
- return visibleCharts;
- }, [chartIds, isChartInActiveTab]);
+ return useMemo(
+ () => chartIds.filter(isChartInActiveTab),
+ [chartIds, isChartInActiveTab],
+ );
}
interface ChartComparisonData {
@@ -222,11 +221,6 @@ export function useChartComparison(
return useMemo(() => {
const comparisons: ChartComparison[] = [];
- console.log(
- '[useChartComparison] Processing visible charts:',
- visibleChartIds,
- );
-
for (const chartId of visibleChartIds) {
const chartState = chartData[chartId];
const displayData = chartDisplayData[chartId];
@@ -240,9 +234,6 @@ export function useChartComparison(
// 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) {
- console.warn(
- `[useChartComparison] Chart ${chartId}: originalData === modifiedData (same reference), skipping`,
- );
continue;
}
@@ -251,7 +242,7 @@ export function useChartComparison(
const coltypes = chartState.coltypes || [];
const metrics: ChartMetricComparison[] = [];
- for (let i = 0; i < colnames.length; i++) {
+ for (let i = 0; i < colnames.length; i += 1) {
const metricName = colnames[i];
const coltype = coltypes[i];
@@ -329,12 +320,6 @@ export function useAllChartsLoaded(chartIds: number[]): boolean {
const chartStatuses = useChartLoadingStatuses(visibleChartIds);
return useMemo(() => {
- const statuses = visibleChartIds.map(id => ({
- id,
- status: chartStatuses[id],
- }));
- console.log('[useAllChartsLoaded] Chart statuses:', statuses);
-
// 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
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfApply.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfApply.ts
new file mode 100644
index 0000000000..7587b6dc14
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfApply.ts
@@ -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 { 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[];
+}
+
+/**
+ * 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);
+
+ 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 suggestions request
+ suggestionsAbortControllerRef.current?.abort();
+
+ // Immediately clear previous results and increment counter to reset AI insights component
+ setAppliedModifications([]);
+ setAffectedChartIds([]);
+ setApplyCounter(c => c + 1);
+
+ const multiplier = 1 + sliderValue / 100;
+
+ // Base user modification with filters
+ const userModification: ExtendedWhatIfModification = {
+ column: selectedColumn,
+ multiplier,
+ isAISuggested: false,
+ filters: filters.length > 0 ? filters : undefined,
+ };
+
+ 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 => ({
+ column: mod.column,
+ multiplier: mod.multiplier,
+ isAISuggested: true,
+ reasoning: mod.reasoning,
+ confidence: mod.confidence,
+ filters: filters.length > 0 ? filters : undefined,
+ }));
+
+ 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);
+ }, []);
+
+ // 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,
+ };
+}
+
+export default useWhatIfApply;
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfFilters.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfFilters.ts
new file mode 100644
index 0000000000..173f4c98ae
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfFilters.ts
@@ -0,0 +1,227 @@
+/**
+ * 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;
+ 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,
+ handleOpenFilterPopover,
+ handleEditFilter,
+ handleFilterChange,
+ handleRemoveFilter,
+ handleFilterPopoverClose,
+ handleFilterPopoverResize,
+ clearFilters,
+ formatFilterLabel,
+ };
+}
+
+export default useWhatIfFilters;
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 48cce50f45..73b6497dcb 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -131,6 +131,7 @@ export type DashboardState = {
};
chartStates?: Record<string, any>;
whatIfModifications: WhatIfModification[];
+ whatIfPanelOpen?: boolean;
};
export type DashboardInfo = {
id: number;
diff --git a/superset-frontend/src/dashboard/util/useNumericColumns.ts b/superset-frontend/src/dashboard/util/useNumericColumns.ts
new file mode 100644
index 0000000000..7a4b839ac0
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/useNumericColumns.ts
@@ -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;
diff --git a/superset-frontend/src/dashboard/util/whatIf.ts b/superset-frontend/src/dashboard/util/whatIf.ts
index caed76f04b..9d7d6f11b6 100644
--- a/superset-frontend/src/dashboard/util/whatIf.ts
+++ b/superset-frontend/src/dashboard/util/whatIf.ts
@@ -16,11 +16,40 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ensureIsArray, getColumnLabel } from '@superset-ui/core';
+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
*/
@@ -156,8 +185,8 @@ export function extractColumnsFromSlice(slice: Slice): Set<string> {
if (!formData) return columns;
// Helper to add column - handles both physical columns (strings) and adhoc columns
- const addColumn = (col: any) => {
- if (col) {
+ const addColumn = (col: unknown) => {
+ if (isQueryFormColumn(col)) {
const label = getColumnLabel(col);
if (label) columns.add(label);
}
@@ -172,7 +201,7 @@ export function extractColumnsFromSlice(slice: Slice): Set<string> {
}
// Extract metrics - get column names from metric definitions
- ensureIsArray(formData.metrics).forEach((metric: any) => {
+ ensureIsArray(formData.metrics).forEach((metric: string | FormDataMetric) => {
if (typeof metric === 'string') {
// Saved metric name - we can't extract columns from it
return;
@@ -181,7 +210,7 @@ export function extractColumnsFromSlice(slice: Slice): Set<string> {
const metricColumn = metric.column;
if (typeof metricColumn === 'string') {
columns.add(metricColumn);
- } else if (metricColumn?.column_name) {
+ } else if (metricColumn && typeof metricColumn === 'object' && 'column_name' in metricColumn) {
columns.add(metricColumn.column_name);
}
}
@@ -189,12 +218,12 @@ export function extractColumnsFromSlice(slice: Slice): Set<string> {
// Extract metric (singular) - used by pie charts and other single-metric charts
if (formData.metric && typeof formData.metric === 'object') {
- const metric = formData.metric as any;
- if ('column' in metric) {
+ const metric = formData.metric as FormDataMetric;
+ if ('column' in metric && metric.column) {
const metricColumn = metric.column;
if (typeof metricColumn === 'string') {
columns.add(metricColumn);
- } else if (metricColumn?.column_name) {
+ } else if (typeof metricColumn === 'object' && 'column_name' in metricColumn) {
columns.add(metricColumn.column_name);
}
}
@@ -211,7 +240,7 @@ export function extractColumnsFromSlice(slice: Slice): Set<string> {
}
// Extract columns from filters
- ensureIsArray(formData.adhoc_filters).forEach((filter: any) => {
+ ensureIsArray(formData.adhoc_filters).forEach((filter: FormDataFilter) => {
if (filter?.subject && typeof filter.subject === 'string') {
columns.add(filter.subject);
}
@@ -327,3 +356,24 @@ export function sliceUsesColumn(slice: Slice, columnName: string): boolean {
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}%`;
+}