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 d37f73490d58aa85c07671b26e824b1b91a73e45 Author: Kamil Gabryjelski <[email protected]> AuthorDate: Fri Dec 19 10:45:44 2025 +0100 Load simulations from url and whatif management page --- .../DashboardBuilder/DashboardBuilder.tsx | 29 +- .../components/WhatIfDrawer/WhatIfHeaderMenu.tsx | 34 +- .../dashboard/components/WhatIfDrawer/index.tsx | 117 ++++++- .../dashboard/components/WhatIfDrawer/styles.ts | 7 +- .../components/WhatIfDrawer/useWhatIfFilters.ts | 2 + .../WhatIfSimulationList/EditSimulationModal.tsx | 383 +++++++++++++++++++++ .../src/pages/WhatIfSimulationList/index.tsx | 170 ++++++++- superset/initialization/__init__.py | 11 + superset/views/what_if.py | 39 +++ 9 files changed, 735 insertions(+), 57 deletions(-) diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 0e030815a0..6b913d8c67 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -19,6 +19,7 @@ /* eslint-env browser */ import cx from 'classnames'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; import { addAlpha, JsonObject, t, useElementOnScreen } from '@superset-ui/core'; import { css, styled, useTheme } from '@apache-superset/core/ui'; import { useDispatch, useSelector } from 'react-redux'; @@ -392,9 +393,31 @@ const DashboardBuilder = () => { ({ dashboardState }) => dashboardState.whatIfPanelOpen ?? false, ); + // Read simulation ID from URL query parameter + const location = useLocation(); + const history = useHistory(); + const initialSimulationId = useMemo(() => { + const params = new URLSearchParams(location.search); + const simParam = params.get('simulation'); + return simParam ? parseInt(simParam, 10) : null; + }, [location.search]); + + // Auto-open What-If panel if simulation ID is in URL + useEffect(() => { + if (initialSimulationId && !whatIfPanelOpen) { + dispatch(toggleWhatIfPanel(true)); + } + }, [initialSimulationId, dispatch, whatIfPanelOpen]); + const handleCloseWhatIfPanel = useCallback(() => { dispatch(toggleWhatIfPanel(false)); - }, [dispatch]); + // Clear simulation param from URL when closing + const params = new URLSearchParams(location.search); + if (params.has('simulation')) { + params.delete('simulation'); + history.replace({ search: params.toString() }); + } + }, [dispatch, location.search, history]); const handleChangeTab = useCallback( ({ pathToTabIndex }: { pathToTabIndex: string[] }) => { @@ -722,10 +745,12 @@ const DashboardBuilder = () => { ) : ( <Loading /> )} - {!editMode && whatIfPanelOpen && ( + {!editMode && ( <WhatIfPanel + visible={whatIfPanelOpen} onClose={handleCloseWhatIfPanel} topOffset={barTopOffset} + initialSimulationId={initialSimulationId} /> )} {editMode && <BuilderComponentPane topOffset={barTopOffset} />} diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfHeaderMenu.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfHeaderMenu.tsx index 1b0a7d5de7..b4a9a0b2df 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfHeaderMenu.tsx +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfHeaderMenu.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useState, useEffect, useCallback, Key } from 'react'; +import { useState, useCallback, Key } from 'react'; import { t } from '@superset-ui/core'; import { css, useTheme } from '@apache-superset/core/ui'; import { Menu } from '@superset-ui/core/components/Menu'; @@ -28,7 +28,7 @@ import { } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { Link } from 'react-router-dom'; -import { fetchSimulations, WhatIfSimulation } from './whatIfApi'; +import { WhatIfSimulation } from './whatIfApi'; enum MenuKeys { LoadSimulation = 'load-simulation', @@ -38,14 +38,13 @@ enum MenuKeys { } interface WhatIfHeaderMenuProps { - dashboardId: number; selectedSimulation: WhatIfSimulation | null; onSelectSimulation: (simulation: WhatIfSimulation | null) => void; onSaveClick: () => void; onSaveAsNewClick: () => void; hasModifications: boolean; - refreshTrigger?: number; - addDangerToast: (msg: string) => void; + simulations: WhatIfSimulation[]; + simulationsLoading: boolean; } const VerticalDotsTrigger = () => { @@ -65,35 +64,16 @@ const VerticalDotsTrigger = () => { }; const WhatIfHeaderMenu = ({ - dashboardId, selectedSimulation, onSelectSimulation, onSaveClick, onSaveAsNewClick, hasModifications, - refreshTrigger, - addDangerToast, + simulations, + simulationsLoading, }: WhatIfHeaderMenuProps) => { const theme = useTheme(); const [isDropdownVisible, setIsDropdownVisible] = useState(false); - const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]); - const [loading, setLoading] = useState(false); - - const loadSimulations = useCallback(async () => { - setLoading(true); - try { - const result = await fetchSimulations(dashboardId); - setSimulations(result); - } catch (error) { - addDangerToast(t('Failed to load saved simulations')); - } finally { - setLoading(false); - } - }, [dashboardId, addDangerToast]); - - useEffect(() => { - loadSimulations(); - }, [loadSimulations, refreshTrigger]); const handleMenuClick = useCallback( ({ key }: { key: Key }) => { @@ -189,7 +169,7 @@ const WhatIfHeaderMenu = ({ { type: 'submenu' as const, key: MenuKeys.LoadSimulation, - label: loading ? t('Loading...') : t('Load simulation'), + label: simulationsLoading ? t('Loading...') : t('Load simulation'), icon: <Icons.FolderOpenOutlined />, children: simulationMenuItems, }, diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx index ed3cbf9812..13171daeb5 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { t } from '@superset-ui/core'; import { css, Alert, useTheme } from '@apache-superset/core/ui'; @@ -34,6 +34,7 @@ import { useToasts } from 'src/components/MessageToasts/withToasts'; import { useNumericColumns } from 'src/dashboard/util/useNumericColumns'; import { RootState, Datasource } from 'src/dashboard/types'; import WhatIfAIInsights from './WhatIfAIInsights'; +import { useAllChartsLoaded } from './useChartComparison'; import HarryPotterWandLoader from './HarryPotterWandLoader'; import FilterButton from './FilterButton'; import ModificationsDisplay from './ModificationsDisplay'; @@ -41,7 +42,7 @@ import WhatIfHeaderMenu from './WhatIfHeaderMenu'; import SaveSimulationModal from './SaveSimulationModal'; import { useWhatIfFilters } from './useWhatIfFilters'; import { useWhatIfApply } from './useWhatIfApply'; -import { WhatIfSimulation } from './whatIfApi'; +import { WhatIfSimulation, fetchSimulations } from './whatIfApi'; import { SLIDER_MIN, SLIDER_MAX, @@ -71,11 +72,18 @@ import { export { WHAT_IF_PANEL_WIDTH }; interface WhatIfPanelProps { + visible: boolean; onClose: () => void; topOffset: number; + initialSimulationId?: number | null; } -const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { +const WhatIfPanel = ({ + visible, + onClose, + topOffset, + initialSimulationId, +}: WhatIfPanelProps) => { const theme = useTheme(); const { addSuccessToast, addDangerToast } = useToasts(); @@ -93,7 +101,11 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { const [selectedSimulation, setSelectedSimulation] = useState<WhatIfSimulation | null>(null); const [saveModalVisible, setSaveModalVisible] = useState(false); - const [simulationRefreshTrigger, setSimulationRefreshTrigger] = useState(0); + const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]); + const [simulationsLoading, setSimulationsLoading] = useState(false); + + // Track if initial simulation from URL has been loaded + const initialSimulationLoadedRef = useRef(false); // Custom hook for filter management const { @@ -101,6 +113,7 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { filterPopoverVisible, currentAdhocFilter, setFilterPopoverVisible, + setFilters, handleOpenFilterPopover, handleEditFilter, handleFilterChange, @@ -131,9 +144,19 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { }); // Get numeric columns and datasources - const { numericColumns } = useNumericColumns(); + const { numericColumns, columnToChartIds } = useNumericColumns(); const datasources = useSelector((state: RootState) => state.datasources); + // Get all chart IDs that could be affected by what-if analysis + const allDashboardChartIds = useMemo(() => { + const chartIds = new Set<number>(); + columnToChartIds.forEach(ids => ids.forEach(id => chartIds.add(id))); + return Array.from(chartIds); + }, [columnToChartIds]); + + // Check if all dashboard charts have completed their initial load + const initialChartsLoaded = useAllChartsLoaded(allDashboardChartIds); + // Column options for the select dropdown const columnOptions = useMemo( () => @@ -195,6 +218,13 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { setSliderValue((firstMod.multiplier - 1) * 100); setEnableCascadingEffects(simulation.cascadingEffectsEnabled); + // Load filters from the first modification + if (firstMod.filters && firstMod.filters.length > 0) { + setFilters(firstMod.filters); + } else { + clearFilters(); + } + // Convert to extended modifications with isAISuggested flag // First modification is the user's, rest are AI-suggested const extendedModifications = simulation.modifications.map( @@ -217,14 +247,70 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { clearModifications(); } }, - [clearFilters, loadModificationsDirectly, clearModifications], + [clearFilters, setFilters, loadModificationsDirectly, clearModifications], ); + // Load simulations list from API + const loadSimulations = useCallback(async () => { + if (!dashboardId) return; + setSimulationsLoading(true); + try { + const result = await fetchSimulations(dashboardId); + setSimulations(result); + } catch (error) { + addDangerToast(t('Failed to load saved simulations')); + } finally { + setSimulationsLoading(false); + } + }, [dashboardId, addDangerToast]); + + // Fetch simulations when dashboard is ready + useEffect(() => { + if (dashboardId) { + loadSimulations(); + } + }, [dashboardId, loadSimulations]); + + // Load initial simulation from URL parameter + // Wait until: + // 1. simulations are loaded (from the earlier useEffect) + // 2. columnToChartIds is populated (chart metadata is available) + // 3. initialChartsLoaded is true (charts have finished their initial queries) + // This ensures we can properly save original chart data before applying what-if modifications + useEffect(() => { + if ( + initialSimulationId && + !initialSimulationLoadedRef.current && + simulations.length > 0 && + columnToChartIds.size > 0 && + initialChartsLoaded + ) { + initialSimulationLoadedRef.current = true; + const simulation = simulations.find(s => s.id === initialSimulationId); + if (simulation) { + handleLoadSimulation(simulation); + } else { + addDangerToast(t('Simulation not found')); + } + } + }, [ + initialSimulationId, + simulations, + addDangerToast, + columnToChartIds.size, + initialChartsLoaded, + handleLoadSimulation, + ]); + // Handle saving a simulation - const handleSaveSimulation = useCallback((simulation: WhatIfSimulation) => { - setSelectedSimulation(simulation); - setSimulationRefreshTrigger(prev => prev + 1); - }, []); + const handleSaveSimulation = useCallback( + (simulation: WhatIfSimulation) => { + setSelectedSimulation(simulation); + // Refresh the simulations list to include the newly saved simulation + loadSimulations(); + }, + [loadSimulations], + ); // Track if we're saving as new (vs updating existing) const [isSavingAsNew, setIsSavingAsNew] = useState(false); @@ -248,7 +334,11 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { !selectedColumn || sliderValue === SLIDER_DEFAULT || isLoadingSuggestions; return ( - <PanelContainer data-test="what-if-panel" topOffset={topOffset}> + <PanelContainer + data-test="what-if-panel" + topOffset={topOffset} + visible={visible} + > <PanelHeader> <PanelTitle> <Icons.StarFilled @@ -261,14 +351,13 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { </PanelTitle> <HeaderButtonsContainer> <WhatIfHeaderMenu - dashboardId={dashboardId} selectedSimulation={selectedSimulation} onSelectSimulation={handleLoadSimulation} onSaveClick={handleOpenSaveModal} onSaveAsNewClick={handleOpenSaveAsNewModal} hasModifications={appliedModifications.length > 0} - refreshTrigger={simulationRefreshTrigger} - addDangerToast={addDangerToast} + simulations={simulations} + simulationsLoading={simulationsLoading} /> <CloseButton onClick={onClose} aria-label={t('Close')}> <Icons.CloseOutlined iconSize="m" /> diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts index 4f43c41e30..62b6f55262 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts @@ -21,14 +21,17 @@ 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 }>` +export const PanelContainer = styled.div<{ + topOffset: number; + visible: boolean; +}>` grid-column: 2; grid-row: 1 / -1; /* Span all rows */ width: ${WHAT_IF_PANEL_WIDTH}px; min-width: ${WHAT_IF_PANEL_WIDTH}px; background-color: ${({ theme }) => theme.colorBgContainer}; border-left: 1px solid ${({ theme }) => theme.colorBorderSecondary}; - display: flex; + display: ${({ visible }) => (visible ? 'flex' : 'none')}; flex-direction: column; overflow: hidden; position: sticky; diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfFilters.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfFilters.ts index 173f4c98ae..1e6c821987 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfFilters.ts +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfFilters.ts @@ -30,6 +30,7 @@ export interface UseWhatIfFiltersReturn { editingFilterIndex: number | null; currentAdhocFilter: AdhocFilter | null; setFilterPopoverVisible: (visible: boolean) => void; + setFilters: (filters: WhatIfFilter[]) => void; handleOpenFilterPopover: () => void; handleEditFilter: (index: number) => void; handleFilterChange: (adhocFilter: AdhocFilter) => void; @@ -213,6 +214,7 @@ export function useWhatIfFilters(): UseWhatIfFiltersReturn { editingFilterIndex, currentAdhocFilter, setFilterPopoverVisible, + setFilters, handleOpenFilterPopover, handleEditFilter, handleFilterChange, diff --git a/superset-frontend/src/pages/WhatIfSimulationList/EditSimulationModal.tsx b/superset-frontend/src/pages/WhatIfSimulationList/EditSimulationModal.tsx new file mode 100644 index 0000000000..0d4bfc75c6 --- /dev/null +++ b/superset-frontend/src/pages/WhatIfSimulationList/EditSimulationModal.tsx @@ -0,0 +1,383 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState, useCallback, useMemo } from 'react'; +import { t } from '@superset-ui/core'; +import { css, styled, useTheme } from '@apache-superset/core/ui'; +import { Modal, Tag, Button, Input } from '@superset-ui/core/components'; +import Slider from '@superset-ui/core/components/Slider'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { WhatIfModification, WhatIfFilter } from 'src/dashboard/types'; +import { formatPercentageChange } from 'src/dashboard/util/whatIf'; +import { + WhatIfSimulation, + updateSimulation, +} from 'src/dashboard/components/WhatIfDrawer/whatIfApi'; +import { + SLIDER_MIN, + SLIDER_MAX, + SLIDER_MARKS, + SLIDER_TOOLTIP_CONFIG, +} from 'src/dashboard/components/WhatIfDrawer/constants'; + +interface EditSimulationModalProps { + simulation: WhatIfSimulation | null; + onHide: () => void; + onSaved: () => void; + addSuccessToast: (msg: string) => void; + addDangerToast: (msg: string) => void; +} + +const ModificationRow = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + padding: ${({ theme }) => theme.sizeUnit * 3}px; + background: ${({ theme }) => theme.colorBgLayout}; + border-radius: ${({ theme }) => theme.borderRadius}px; + margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; +`; + +const ModificationHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${({ theme }) => theme.sizeUnit * 2}px; +`; + +const ColumnName = styled.span` + font-weight: ${({ theme }) => theme.fontWeightStrong}; + font-size: ${({ theme }) => theme.fontSizeLG}px; +`; + +const SliderContainer = styled.div` + padding: 0 ${({ theme }) => theme.sizeUnit}px; + & .ant-slider-mark { + font-size: ${({ theme }) => theme.fontSizeSM}px; + } +`; + +const FiltersContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${({ theme }) => theme.sizeUnit}px; +`; + +const FilterLabel = styled.div` + font-size: ${({ theme }) => theme.fontSizeSM}px; + color: ${({ theme }) => theme.colorTextSecondary}; + margin-bottom: ${({ theme }) => theme.sizeUnit}px; +`; + +const EmptyState = styled.div` + text-align: center; + padding: ${({ theme }) => theme.sizeUnit * 6}px; + color: ${({ theme }) => theme.colorTextSecondary}; +`; + +const AddModificationButton = styled(Button)` + margin-top: ${({ theme }) => theme.sizeUnit * 2}px; +`; + +const NewModificationForm = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + padding: ${({ theme }) => theme.sizeUnit * 3}px; + background: ${({ theme }) => theme.colorBgLayout}; + border-radius: ${({ theme }) => theme.borderRadius}px; + border: 1px dashed ${({ theme }) => theme.colorBorder}; + margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; +`; + +/** + * Format a WhatIfFilter for display + */ +function formatFilterLabel(filter: WhatIfFilter): string { + const { col, op, val } = filter; + + let valStr: string; + if (Array.isArray(val)) { + valStr = val.join(', '); + } else if (typeof val === 'boolean') { + valStr = val ? 'true' : 'false'; + } else { + valStr = String(val); + } + // Truncate long values + if (valStr.length > 20) { + valStr = `${valStr.substring(0, 17)}...`; + } + return `${col} ${op} ${valStr}`; +} + +interface EditableModification extends WhatIfModification { + sliderValue: number; // (multiplier - 1) * 100 +} + +function EditSimulationModal({ + simulation, + onHide, + onSaved, + addSuccessToast, + addDangerToast, +}: EditSimulationModalProps) { + const theme = useTheme(); + + // Convert modifications to editable format with slider values + const initialModifications = useMemo( + () => + simulation?.modifications.map(mod => ({ + ...mod, + sliderValue: (mod.multiplier - 1) * 100, + })) ?? [], + [simulation], + ); + + const [modifications, setModifications] = + useState<EditableModification[]>(initialModifications); + const [saving, setSaving] = useState(false); + const [showNewModification, setShowNewModification] = useState(false); + const [newColumnName, setNewColumnName] = useState(''); + const [newSliderValue, setNewSliderValue] = useState(0); + + // Reset state when simulation changes + useMemo(() => { + setModifications(initialModifications); + setShowNewModification(false); + setNewColumnName(''); + setNewSliderValue(0); + }, [initialModifications]); + + const handleSliderChange = useCallback((index: number, value: number) => { + setModifications(prev => + prev.map((mod, i) => + i === index + ? { ...mod, sliderValue: value, multiplier: 1 + value / 100 } + : mod, + ), + ); + }, []); + + const handleRemoveModification = useCallback((index: number) => { + setModifications(prev => prev.filter((_, i) => i !== index)); + }, []); + + const handleAddModification = useCallback(() => { + if (!newColumnName.trim()) return; + + const newMod: EditableModification = { + column: newColumnName.trim(), + multiplier: 1 + newSliderValue / 100, + sliderValue: newSliderValue, + }; + setModifications(prev => [...prev, newMod]); + setShowNewModification(false); + setNewColumnName(''); + setNewSliderValue(0); + }, [newColumnName, newSliderValue]); + + const handleSave = useCallback(async () => { + if (!simulation) return; + + setSaving(true); + try { + const updatedModifications: WhatIfModification[] = modifications.map( + mod => ({ + column: mod.column, + multiplier: mod.multiplier, + filters: mod.filters, + }), + ); + + await updateSimulation(simulation.id, { + modifications: updatedModifications, + }); + + addSuccessToast(t('Simulation updated successfully')); + onSaved(); + onHide(); + } catch (error) { + addDangerToast(t('Failed to update simulation')); + } finally { + setSaving(false); + } + }, [ + simulation, + modifications, + onSaved, + onHide, + addSuccessToast, + addDangerToast, + ]); + + const hasChanges = useMemo(() => { + if (!simulation) return false; + if (modifications.length !== simulation.modifications.length) return true; + + return modifications.some((mod, i) => { + const original = simulation.modifications[i]; + return mod.multiplier !== original.multiplier; + }); + }, [simulation, modifications]); + + if (!simulation) return null; + + return ( + <Modal + show + onHide={onHide} + title={t('Edit simulation: %s', simulation.name)} + primaryButtonName={t('Save')} + onHandledPrimaryAction={handleSave} + primaryButtonLoading={saving} + disablePrimaryButton={!hasChanges || saving} + centered + > + {modifications.length === 0 && !showNewModification ? ( + <EmptyState> + <Icons.WarningOutlined + iconSize="xl" + css={css` + color: ${theme.colorWarning}; + margin-bottom: ${theme.sizeUnit * 2}px; + `} + /> + <div>{t('No modifications in this simulation')}</div> + </EmptyState> + ) : ( + modifications.map((mod, index) => ( + <ModificationRow key={`${mod.column}-${index}`}> + <ModificationHeader> + <ColumnName>{mod.column}</ColumnName> + <div + css={css` + display: flex; + align-items: center; + gap: ${theme.sizeUnit * 2}px; + `} + > + <span + css={css` + font-weight: ${theme.fontWeightStrong}; + font-size: ${theme.fontSizeLG}px; + color: ${mod.multiplier >= 1 + ? theme.colorSuccess + : theme.colorError}; + `} + > + {formatPercentageChange(mod.multiplier, 0)} + </span> + <Button + buttonSize="small" + onClick={() => handleRemoveModification(index)} + buttonStyle="tertiary" + aria-label={t('Remove modification')} + > + <Icons.DeleteOutlined iconSize="s" /> + </Button> + </div> + </ModificationHeader> + + <SliderContainer> + <Slider + min={SLIDER_MIN} + max={SLIDER_MAX} + value={mod.sliderValue} + onChange={value => handleSliderChange(index, value)} + marks={SLIDER_MARKS} + tooltip={SLIDER_TOOLTIP_CONFIG} + /> + </SliderContainer> + + {mod.filters && mod.filters.length > 0 && ( + <div> + <FilterLabel>{t('Filters')}</FilterLabel> + <FiltersContainer> + {mod.filters.map((filter, filterIndex) => ( + <Tag key={`${filter.col}-${filterIndex}`}> + {formatFilterLabel(filter)} + </Tag> + ))} + </FiltersContainer> + </div> + )} + </ModificationRow> + )) + )} + + {showNewModification && ( + <NewModificationForm> + <Input + placeholder={t('Column name')} + value={newColumnName} + onChange={e => setNewColumnName(e.target.value)} + /> + <SliderContainer> + <Slider + min={SLIDER_MIN} + max={SLIDER_MAX} + value={newSliderValue} + onChange={setNewSliderValue} + marks={SLIDER_MARKS} + tooltip={SLIDER_TOOLTIP_CONFIG} + /> + </SliderContainer> + <div + css={css` + display: flex; + gap: ${theme.sizeUnit}px; + `} + > + <Button + buttonStyle="primary" + buttonSize="small" + onClick={handleAddModification} + disabled={!newColumnName.trim() || newSliderValue === 0} + > + {t('Add')} + </Button> + <Button + buttonSize="small" + onClick={() => { + setShowNewModification(false); + setNewColumnName(''); + setNewSliderValue(0); + }} + > + {t('Cancel')} + </Button> + </div> + </NewModificationForm> + )} + + {!showNewModification && ( + <AddModificationButton + buttonStyle="tertiary" + onClick={() => setShowNewModification(true)} + > + <Icons.PlusOutlined iconSize="s" /> + {t('Add modification')} + </AddModificationButton> + )} + </Modal> + ); +} + +export default EditSimulationModal; diff --git a/superset-frontend/src/pages/WhatIfSimulationList/index.tsx b/superset-frontend/src/pages/WhatIfSimulationList/index.tsx index 102f92bbd2..5f4e768092 100644 --- a/superset-frontend/src/pages/WhatIfSimulationList/index.tsx +++ b/superset-frontend/src/pages/WhatIfSimulationList/index.tsx @@ -18,9 +18,9 @@ */ import { useMemo, useState, useEffect, useCallback } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useHistory } from 'react-router-dom'; import { t, SupersetClient } from '@superset-ui/core'; -import { css, styled } from '@apache-superset/core/ui'; +import { css, styled, useTheme } from '@apache-superset/core/ui'; import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates'; import { @@ -28,7 +28,10 @@ import { DeleteModal, Empty, Skeleton, + Tag, + Tooltip, } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; import { ListView, ListViewActionsBar, @@ -37,12 +40,15 @@ import { } from 'src/components'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import withToasts from 'src/components/MessageToasts/withToasts'; +import { WhatIfFilter, WhatIfModification } from 'src/dashboard/types'; +import { formatPercentageChange } from 'src/dashboard/util/whatIf'; import { fetchAllSimulations, deleteSimulation, WhatIfSimulation, } from 'src/dashboard/components/WhatIfDrawer/whatIfApi'; +import EditSimulationModal from './EditSimulationModal'; const PAGE_SIZE = 25; @@ -58,7 +64,7 @@ interface DashboardInfo { } const PageContainer = styled.div` - padding: ${({ theme }) => theme.gridUnit * 4}px; + padding: ${({ theme }) => theme.sizeUnit * 4}px; `; const EmptyContainer = styled.div` @@ -66,13 +72,102 @@ const EmptyContainer = styled.div` flex-direction: column; align-items: center; justify-content: center; - padding: ${({ theme }) => theme.gridUnit * 16}px; + padding: ${({ theme }) => theme.sizeUnit * 16}px; `; +const ModificationsContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.sizeUnit}px; +`; + +const ModificationTagsRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${({ theme }) => theme.sizeUnit}px; +`; + +const FilterBadge = styled.span` + font-size: 10px; + color: ${({ theme }) => theme.colorTextSecondary}; + margin-left: ${({ theme }) => theme.sizeUnit}px; +`; + +/** + * Format a WhatIfFilter for display + */ +function formatFilterLabel(filter: WhatIfFilter): string { + const { col, op, val } = filter; + + let valStr: string; + if (Array.isArray(val)) { + valStr = val.join(', '); + } else if (typeof val === 'boolean') { + valStr = val ? 'true' : 'false'; + } else { + valStr = String(val); + } + // Truncate long values + if (valStr.length > 15) { + valStr = `${valStr.substring(0, 12)}...`; + } + return `${col} ${op} ${valStr}`; +} + +/** + * Component to render a single modification with its filters + */ +function ModificationTag({ + modification, +}: { + modification: WhatIfModification; +}) { + const theme = useTheme(); + const hasFilters = modification.filters && modification.filters.length > 0; + + const tagContent = ( + <Tag + css={css` + display: inline-flex; + align-items: center; + gap: ${theme.sizeUnit}px; + margin: 0; + `} + > + <span>{modification.column}</span> + <span + css={css` + font-weight: ${theme.fontWeightStrong}; + color: ${modification.multiplier >= 1 + ? theme.colorSuccess + : theme.colorError}; + `} + > + {formatPercentageChange(modification.multiplier, 0)} + </span> + {hasFilters && ( + <FilterBadge> + <Icons.FilterOutlined iconSize="xs" /> + </FilterBadge> + )} + </Tag> + ); + + if (hasFilters) { + const filterTooltip = modification + .filters!.map(f => formatFilterLabel(f)) + .join(', '); + return <Tooltip title={filterTooltip}>{tagContent}</Tooltip>; + } + + return tagContent; +} + function WhatIfSimulationList({ addDangerToast, addSuccessToast, }: WhatIfSimulationListProps) { + const history = useHistory(); const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]); const [dashboards, setDashboards] = useState<Record<number, DashboardInfo>>( {}, @@ -80,6 +175,8 @@ function WhatIfSimulationList({ const [loading, setLoading] = useState(true); const [simulationCurrentlyDeleting, setSimulationCurrentlyDeleting] = useState<WhatIfSimulation | null>(null); + const [simulationCurrentlyEditing, setSimulationCurrentlyEditing] = + useState<WhatIfSimulation | null>(null); const loadSimulations = useCallback(async () => { setLoading(true); @@ -156,7 +253,7 @@ function WhatIfSimulationList({ ); const menuData: SubMenuProps = { - name: t('What-If Simulations'), + name: t('What-if simulations'), }; const initialSort = [{ id: 'changedOn', desc: true }]; @@ -166,8 +263,22 @@ function WhatIfSimulationList({ { accessor: 'name', Header: t('Name'), - size: 'xxl', + size: 'lg', id: 'name', + Cell: ({ + row: { + original: { id, name, dashboardId }, + }, + }: { + row: { original: WhatIfSimulation }; + }) => { + const dashboard = dashboards[dashboardId]; + const dashboardUrl = dashboard?.slug + ? `/superset/dashboard/${dashboard.slug}/` + : `/superset/dashboard/${dashboardId}/`; + const url = `${dashboardUrl}?simulation=${id}`; + return <Link to={url}>{name}</Link>; + }, }, { accessor: 'description', @@ -185,7 +296,7 @@ function WhatIfSimulationList({ { accessor: 'dashboardId', Header: t('Dashboard'), - size: 'lg', + size: 'md', id: 'dashboardId', Cell: ({ row: { @@ -205,7 +316,7 @@ function WhatIfSimulationList({ { accessor: 'modifications', Header: t('Modifications'), - size: 'md', + size: 'xxl', id: 'modifications', disableSortBy: true, Cell: ({ @@ -214,12 +325,28 @@ function WhatIfSimulationList({ }, }: { row: { original: WhatIfSimulation }; - }) => modifications.length, + }) => { + if (modifications.length === 0) { + return <span>-</span>; + } + return ( + <ModificationsContainer> + <ModificationTagsRow> + {modifications.map((mod, idx) => ( + <ModificationTag + key={`${mod.column}-${idx}`} + modification={mod} + /> + ))} + </ModificationTagsRow> + </ModificationsContainer> + ); + }, }, { accessor: 'changedOn', Header: t('Last modified'), - size: 'lg', + size: 'md', id: 'changedOn', Cell: ({ row: { @@ -239,10 +366,12 @@ function WhatIfSimulationList({ const dashboardUrl = dashboard?.slug ? `/superset/dashboard/${dashboard.slug}/` : `/superset/dashboard/${original.dashboardId}/`; + const simulationUrl = `${dashboardUrl}?simulation=${original.id}`; const handleOpen = () => { - window.location.href = dashboardUrl; + history.push(simulationUrl); }; + const handleEdit = () => setSimulationCurrentlyEditing(original); const handleDelete = () => setSimulationCurrentlyDeleting(original); const actions = [ @@ -253,6 +382,13 @@ function WhatIfSimulationList({ icon: 'ExportOutlined', onClick: handleOpen, }, + { + label: 'edit-action', + tooltip: t('Edit modifications'), + placement: 'bottom', + icon: 'EditOutlined', + onClick: handleEdit, + }, { label: 'delete-action', tooltip: t('Delete simulation'), @@ -268,10 +404,11 @@ function WhatIfSimulationList({ }, Header: t('Actions'), id: 'actions', + size: 'sm', disableSortBy: true, }, ], - [dashboards], + [dashboards, history], ); const emptyState = { @@ -342,6 +479,15 @@ function WhatIfSimulationList({ title={t('Delete Simulation?')} /> )} + {simulationCurrentlyEditing && ( + <EditSimulationModal + simulation={simulationCurrentlyEditing} + onHide={() => setSimulationCurrentlyEditing(null)} + onSaved={loadSimulations} + addSuccessToast={addSuccessToast} + addDangerToast={addDangerToast} + /> + )} <ConfirmStatusChange title={t('Please confirm')} description={t( diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 8d29cd471f..9b84779810 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -223,6 +223,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.views.user_registrations import UserRegistrationsView from superset.views.users.api import CurrentUserRestApi, UserRestApi from superset.views.users_list import UsersListView + from superset.views.what_if import WhatIfSimulationView from superset.what_if.api import WhatIfRestApi set_app_error_handlers(self.superset_app) @@ -432,6 +433,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_view_no_menu(RoleRestAPI) appbuilder.add_view_no_menu(UserInfoView) + appbuilder.add_view( + WhatIfSimulationView, + "What-If Simulations", + label=_("What-if simulations"), + href="/whatif/simulations/", + icon="fa-flask", + category="Manage", + category_label=_("Manage"), + ) + # # Add links # diff --git a/superset/views/what_if.py b/superset/views/what_if.py new file mode 100644 index 0000000000..ebf92e1362 --- /dev/null +++ b/superset/views/what_if.py @@ -0,0 +1,39 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""View for What-If simulations list page.""" + +from flask_appbuilder import permission_name +from flask_appbuilder.api import expose +from flask_appbuilder.security.decorators import has_access + +from superset.superset_typing import FlaskResponse + +from .base import BaseSupersetView + + +class WhatIfSimulationView(BaseSupersetView): + """View for the What-If simulations list page.""" + + route_base = "/whatif" + class_permission_name = "Dashboard" + + @expose("/simulations/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + """Render the What-If simulations list page.""" + return super().render_app_template()
