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 <[email protected]> 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}%`; +}
