mirror of
https://github.com/apache/superset.git
synced 2026-04-28 12:34:23 +00:00
Compare commits
31 Commits
semantic-l
...
what-if
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
592969820f | ||
|
|
8d40dbd422 | ||
|
|
4eb5ab4641 | ||
|
|
7e802ef7b9 | ||
|
|
2ae11fc2b8 | ||
|
|
5881b60492 | ||
|
|
d37f73490d | ||
|
|
106baa67e5 | ||
|
|
45f1982407 | ||
|
|
f7bb0c8ed3 | ||
|
|
888e14eb0c | ||
|
|
15c5740b77 | ||
|
|
1370810a9c | ||
|
|
790f15d8f2 | ||
|
|
fd43d2facd | ||
|
|
6737ec8282 | ||
|
|
ab8144a501 | ||
|
|
4eaf707aab | ||
|
|
a5ef75cc06 | ||
|
|
d86628918b | ||
|
|
fce4fc039f | ||
|
|
4a1471aef5 | ||
|
|
0a6bba1a14 | ||
|
|
5d6a697e32 | ||
|
|
990f6b2f03 | ||
|
|
54518daea0 | ||
|
|
3df0521b2d | ||
|
|
48fca802dc | ||
|
|
b4165736d5 | ||
|
|
263e20e439 | ||
|
|
4dab58f8c0 |
@@ -66,6 +66,7 @@ import {
|
||||
ExclamationCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ExpandOutlined,
|
||||
ExperimentOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
FallOutlined,
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FolderOpenOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
@@ -204,6 +206,7 @@ const AntdIcons = {
|
||||
ExclamationCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ExpandOutlined,
|
||||
ExperimentOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
FacebookOutlined,
|
||||
@@ -215,6 +218,7 @@ const AntdIcons = {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FolderOpenOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
|
||||
@@ -43,7 +43,8 @@ type ExtractedExtra = ExtraFilterQueryField & {
|
||||
export default function extractExtras(formData: QueryFormData): ExtractedExtra {
|
||||
const applied_time_extras: AppliedTimeExtras = {};
|
||||
const filters: QueryObjectFilterClause[] = [];
|
||||
const extras: QueryObjectExtras = {};
|
||||
// Preserve existing extras from formData (e.g., what_if modifications)
|
||||
const extras: QueryObjectExtras = { ...formData.extras };
|
||||
const extract: ExtractedExtra = {
|
||||
filters,
|
||||
extras,
|
||||
|
||||
@@ -77,6 +77,14 @@ export type QueryObjectExtras = Partial<{
|
||||
|
||||
/** If true, WHERE/HAVING clauses need transpilation to target dialect */
|
||||
transpile_to_dialect?: boolean;
|
||||
|
||||
/** What-if analysis: column value modifications */
|
||||
what_if?: {
|
||||
modifications: Array<{
|
||||
column: string;
|
||||
multiplier: number;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type ResidualQueryObjectData = {
|
||||
|
||||
@@ -32,7 +32,7 @@ export const buildTimeRangeString = (since: string, until: string): string =>
|
||||
const formatDateEndpoint = (dttm: string, isStart?: boolean): string =>
|
||||
dttm.replace('T00:00:00', '') || (isStart ? '-∞' : '∞');
|
||||
|
||||
export const formatTimeRange = (
|
||||
export const formatTimeRangeLabel = (
|
||||
timeRange: string,
|
||||
columnPlaceholder = 'col',
|
||||
) => {
|
||||
@@ -86,7 +86,7 @@ export const fetchTimeRange = async (
|
||||
response?.json?.result[0]?.until || '',
|
||||
);
|
||||
return {
|
||||
value: formatTimeRange(timeRangeString, columnPlaceholder),
|
||||
value: formatTimeRangeLabel(timeRangeString, columnPlaceholder),
|
||||
};
|
||||
}
|
||||
const timeRanges = response?.json?.result.map((result: any) =>
|
||||
|
||||
@@ -26,5 +26,9 @@ export {
|
||||
getTimeOffset,
|
||||
computeCustomDateTime,
|
||||
} from './getTimeOffset';
|
||||
export { SEPARATOR, fetchTimeRange } from './fetchTimeRange';
|
||||
export {
|
||||
SEPARATOR,
|
||||
fetchTimeRange,
|
||||
formatTimeRangeLabel,
|
||||
} from './fetchTimeRange';
|
||||
export { customTimeRangeDecode } from './customTimeRangeDecode';
|
||||
|
||||
@@ -167,7 +167,21 @@ const MessageSpan = styled.span`
|
||||
text-align: center;
|
||||
margin: ${({ theme }) => theme.sizeUnit * 4}px auto;
|
||||
width: fit-content;
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
color: ${({ theme }) => theme.colorPrimary};
|
||||
`;
|
||||
|
||||
const LoadingOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: ${({ theme }) => theme.colorBgBase}cc;
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
class Chart extends PureComponent<ChartProps, {}> {
|
||||
@@ -314,6 +328,22 @@ class Chart extends PureComponent<ChartProps, {}> {
|
||||
);
|
||||
}
|
||||
|
||||
renderLoadingOverlay(databaseName: string | undefined) {
|
||||
const message = databaseName
|
||||
? t('Waiting on %s', databaseName)
|
||||
: t('Waiting on database...');
|
||||
|
||||
return (
|
||||
<LoadingOverlay>
|
||||
<Loading
|
||||
position="inline-centered"
|
||||
size={this.props.dashboardId ? 's' : 'm'}
|
||||
/>
|
||||
<MessageSpan>{message}</MessageSpan>
|
||||
</LoadingOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
height,
|
||||
@@ -373,6 +403,30 @@ class Chart extends PureComponent<ChartProps, {}> {
|
||||
);
|
||||
}
|
||||
|
||||
const hasExistingData =
|
||||
ensureIsArray(queriesResponse).length > 0 &&
|
||||
queriesResponse?.some(response => response?.data);
|
||||
const isFirstLoad = isLoading && !hasExistingData;
|
||||
|
||||
if (isFirstLoad) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onError={this.handleRenderContainerFailure}
|
||||
showMessage={false}
|
||||
>
|
||||
<Styles
|
||||
data-ui-anchor="chart"
|
||||
className="chart-container"
|
||||
data-test="chart-container"
|
||||
height={height}
|
||||
width={width}
|
||||
>
|
||||
{this.renderSpinner(databaseName)}
|
||||
</Styles>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onError={this.handleRenderContainerFailure}
|
||||
@@ -385,9 +439,8 @@ class Chart extends PureComponent<ChartProps, {}> {
|
||||
height={height}
|
||||
width={width}
|
||||
>
|
||||
{isLoading
|
||||
? this.renderSpinner(databaseName)
|
||||
: this.renderChartContainer()}
|
||||
{this.renderChartContainer()}
|
||||
{isLoading && this.renderLoadingOverlay(databaseName)}
|
||||
</Styles>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -279,8 +279,14 @@ class ChartRenderer extends Component {
|
||||
render() {
|
||||
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
|
||||
|
||||
// Skip chart rendering
|
||||
if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
|
||||
// Skip chart rendering for errors
|
||||
if (chartAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasData =
|
||||
this.mutableQueriesResponse && this.mutableQueriesResponse.length > 0;
|
||||
if (!hasData && (chartStatus === 'loading' || chartStatus === null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -603,6 +603,22 @@ export function refreshChart(chartKey, force, dashboardId) {
|
||||
};
|
||||
}
|
||||
|
||||
// What-If caching actions
|
||||
export const SAVE_ORIGINAL_CHART_DATA = 'SAVE_ORIGINAL_CHART_DATA';
|
||||
export function saveOriginalChartData(key) {
|
||||
return { type: SAVE_ORIGINAL_CHART_DATA, key };
|
||||
}
|
||||
|
||||
export const RESTORE_ORIGINAL_CHART_DATA = 'RESTORE_ORIGINAL_CHART_DATA';
|
||||
export function restoreOriginalChartData(key) {
|
||||
return { type: RESTORE_ORIGINAL_CHART_DATA, key };
|
||||
}
|
||||
|
||||
export const CLEAR_ORIGINAL_CHART_DATA = 'CLEAR_ORIGINAL_CHART_DATA';
|
||||
export function clearOriginalChartData(key) {
|
||||
return { type: CLEAR_ORIGINAL_CHART_DATA, key };
|
||||
}
|
||||
|
||||
export const getDatasourceSamples = async (
|
||||
datasourceType,
|
||||
datasourceId,
|
||||
|
||||
@@ -177,6 +177,33 @@ export default function chartReducer(
|
||||
annotationQuery,
|
||||
};
|
||||
},
|
||||
[actions.SAVE_ORIGINAL_CHART_DATA](state) {
|
||||
// Only save if we don't already have cached data
|
||||
if (state.originalQueriesResponse) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
originalQueriesResponse: state.queriesResponse,
|
||||
};
|
||||
},
|
||||
[actions.RESTORE_ORIGINAL_CHART_DATA](state) {
|
||||
if (!state.originalQueriesResponse) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
queriesResponse: state.originalQueriesResponse,
|
||||
originalQueriesResponse: null,
|
||||
chartStatus: 'success',
|
||||
};
|
||||
},
|
||||
[actions.CLEAR_ORIGINAL_CHART_DATA](state) {
|
||||
return {
|
||||
...state,
|
||||
originalQueriesResponse: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
@@ -774,6 +774,22 @@ export function clearAllChartStates() {
|
||||
return { type: CLEAR_ALL_CHART_STATES };
|
||||
}
|
||||
|
||||
// What-If Analysis actions
|
||||
export const SET_WHAT_IF_MODIFICATIONS = 'SET_WHAT_IF_MODIFICATIONS';
|
||||
export function setWhatIfModifications(modifications) {
|
||||
return { type: SET_WHAT_IF_MODIFICATIONS, modifications };
|
||||
}
|
||||
|
||||
export const CLEAR_WHAT_IF_MODIFICATIONS = 'CLEAR_WHAT_IF_MODIFICATIONS';
|
||||
export function clearWhatIfModifications() {
|
||||
return { type: CLEAR_WHAT_IF_MODIFICATIONS };
|
||||
}
|
||||
|
||||
export const TOGGLE_WHAT_IF_PANEL = 'TOGGLE_WHAT_IF_PANEL';
|
||||
export function toggleWhatIfPanel(isOpen) {
|
||||
return { type: TOGGLE_WHAT_IF_PANEL, isOpen };
|
||||
}
|
||||
|
||||
// Undo history ---------------------------------------------------------------
|
||||
export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
|
||||
export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {
|
||||
|
||||
@@ -312,6 +312,7 @@ export const hydrateDashboard =
|
||||
dashboardState?.datasetsStatus || ResourceStatus.Loading,
|
||||
chartStates: chartStates || dashboardState?.chartStates || {},
|
||||
chartCustomizationItems,
|
||||
whatIfModifications: [],
|
||||
},
|
||||
dashboardLayout,
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ const TABS_KEYS = {
|
||||
|
||||
const BuilderComponentPane = ({ topOffset = 0 }) => (
|
||||
<div
|
||||
className="dashboard-builder-sidepane"
|
||||
data-test="dashboard-builder-sidepane"
|
||||
css={css`
|
||||
position: sticky;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
/* eslint-env browser */
|
||||
import cx from 'classnames';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useHistory } from 'react-router-dom';
|
||||
import { addAlpha, JsonObject, t, useElementOnScreen } from '@superset-ui/core';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/ui';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
import {
|
||||
setDirectPathToChild,
|
||||
setEditMode,
|
||||
toggleWhatIfPanel,
|
||||
} from 'src/dashboard/actions/dashboardState';
|
||||
import {
|
||||
deleteTopLevelTabs,
|
||||
@@ -55,6 +57,8 @@ import {
|
||||
DashboardStandaloneMode,
|
||||
} from 'src/dashboard/util/constants';
|
||||
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
|
||||
import WhatIfPanel from 'src/dashboard/components/WhatIfDrawer';
|
||||
import WhatIfBanner from 'src/dashboard/components/WhatIfBanner';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import ResizableSidebar from 'src/components/ResizableSidebar';
|
||||
import {
|
||||
@@ -272,12 +276,16 @@ const DashboardContentWrapper = styled.div`
|
||||
const StyledDashboardContent = styled.div<{
|
||||
editMode: boolean;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
hasWhatIfPanel: boolean;
|
||||
}>`
|
||||
${({ theme, editMode, marginLeft }) => css`
|
||||
${({ theme, editMode, marginLeft, marginRight, hasWhatIfPanel }) => css`
|
||||
background-color: ${theme.colorBgLayout};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr ${hasWhatIfPanel ? 'auto' : ''} ${editMode
|
||||
? 'auto'
|
||||
: ''};
|
||||
grid-template-rows: auto 1fr;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
|
||||
@@ -287,27 +295,23 @@ const StyledDashboardContent = styled.div<{
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
/* without this, the grid will not get smaller upon toggling the builder panel on */
|
||||
width: 0;
|
||||
flex: 1;
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
margin: ${theme.sizeUnit * 4}px;
|
||||
margin-left: ${marginLeft}px;
|
||||
|
||||
${editMode &&
|
||||
`
|
||||
max-width: calc(100% - ${
|
||||
BUILDER_SIDEPANEL_WIDTH + theme.sizeUnit * 16
|
||||
}px);
|
||||
`}
|
||||
margin-right: ${marginRight}px;
|
||||
min-width: 0; /* Prevent grid blowout */
|
||||
|
||||
/* this is the ParentSize wrapper */
|
||||
& > div:first-child {
|
||||
& > div:first-child {
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-builder-sidepane {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / -1; /* Span all rows */
|
||||
width: ${BUILDER_SIDEPANEL_WIDTH}px;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -385,6 +389,35 @@ const DashboardBuilder = () => {
|
||||
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
|
||||
({ dashboardInfo }) => dashboardInfo.filterBarOrientation,
|
||||
);
|
||||
const whatIfPanelOpen = useSelector<RootState, boolean>(
|
||||
({ dashboardState }) => dashboardState.whatIfPanelOpen ?? false,
|
||||
);
|
||||
|
||||
// Read simulation ID from URL query parameter
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const initialSimulationId = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const simParam = params.get('simulation');
|
||||
return simParam ? parseInt(simParam, 10) : null;
|
||||
}, [location.search]);
|
||||
|
||||
// Auto-open What-If panel if simulation ID is in URL
|
||||
useEffect(() => {
|
||||
if (initialSimulationId && !whatIfPanelOpen) {
|
||||
dispatch(toggleWhatIfPanel(true));
|
||||
}
|
||||
}, [initialSimulationId, dispatch, whatIfPanelOpen]);
|
||||
|
||||
const handleCloseWhatIfPanel = useCallback(() => {
|
||||
dispatch(toggleWhatIfPanel(false));
|
||||
// Clear simulation param from URL when closing
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.has('simulation')) {
|
||||
params.delete('simulation');
|
||||
history.replace({ search: params.toString() });
|
||||
}
|
||||
}, [dispatch, location.search, history]);
|
||||
|
||||
const handleChangeTab = useCallback(
|
||||
({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
|
||||
@@ -563,6 +596,8 @@ const DashboardBuilder = () => {
|
||||
? theme.sizeUnit * 4
|
||||
: theme.sizeUnit * 8;
|
||||
|
||||
const dashboardContentMarginRight = theme.sizeUnit * 4;
|
||||
|
||||
const renderChild = useCallback(
|
||||
adjustedWidth => {
|
||||
const filterBarWidth = dashboardFiltersOpen
|
||||
@@ -674,7 +709,10 @@ const DashboardBuilder = () => {
|
||||
className="dashboard-content"
|
||||
editMode={editMode}
|
||||
marginLeft={dashboardContentMarginLeft}
|
||||
marginRight={dashboardContentMarginRight}
|
||||
hasWhatIfPanel={!editMode && whatIfPanelOpen}
|
||||
>
|
||||
{!editMode && <WhatIfBanner topOffset={barTopOffset} />}
|
||||
{showDashboard ? (
|
||||
missingInitialFilters.length > 0 ? (
|
||||
<div
|
||||
@@ -684,6 +722,8 @@ const DashboardBuilder = () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
& div {
|
||||
width: 500px;
|
||||
}
|
||||
@@ -705,6 +745,14 @@ const DashboardBuilder = () => {
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
{!editMode && (
|
||||
<WhatIfPanel
|
||||
visible={whatIfPanelOpen}
|
||||
onClose={handleCloseWhatIfPanel}
|
||||
topOffset={barTopOffset}
|
||||
initialSimulationId={initialSimulationId}
|
||||
/>
|
||||
)}
|
||||
{editMode && <BuilderComponentPane topOffset={barTopOffset} />}
|
||||
</StyledDashboardContent>
|
||||
</DashboardContentWrapper>
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
setMaxUndoHistoryExceeded,
|
||||
setRefreshFrequency,
|
||||
setUnsavedChanges,
|
||||
toggleWhatIfPanel,
|
||||
} from '../../actions/dashboardState';
|
||||
import { logEvent } from '../../../logger/actions';
|
||||
import { dashboardInfoChanged } from '../../actions/dashboardInfo';
|
||||
@@ -106,6 +107,26 @@ const editButtonStyle = theme => css`
|
||||
color: ${theme.colorPrimary};
|
||||
`;
|
||||
|
||||
const whatIfButtonStyle = theme => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${theme.sizeUnit * 8}px;
|
||||
height: ${theme.sizeUnit * 8}px;
|
||||
margin-right: ${theme.sizeUnit * 2}px;
|
||||
border: 1px solid ${theme.colorBorder};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
background: ${theme.colorBgContainer};
|
||||
color: ${theme.colorText};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${theme.colorPrimary};
|
||||
color: ${theme.colorPrimary};
|
||||
}
|
||||
`;
|
||||
|
||||
const actionButtonsStyle = theme => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -195,6 +216,7 @@ const Header = () => {
|
||||
maxUndoHistoryExceeded,
|
||||
editMode,
|
||||
lastModifiedTime,
|
||||
whatIfPanelOpen,
|
||||
} = useSelector(
|
||||
state => ({
|
||||
expandedSlices: state.dashboardState.expandedSlices,
|
||||
@@ -210,6 +232,7 @@ const Header = () => {
|
||||
maxUndoHistoryExceeded: !!state.dashboardState.maxUndoHistoryExceeded,
|
||||
editMode: !!state.dashboardState.editMode,
|
||||
lastModifiedTime: state.lastModifiedTime,
|
||||
whatIfPanelOpen: !!state.dashboardState.whatIfPanelOpen,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
@@ -715,6 +738,17 @@ const Header = () => {
|
||||
) : (
|
||||
<div css={actionButtonsStyle}>
|
||||
{NavExtension && <NavExtension />}
|
||||
<Tooltip title={t('What-if playground')}>
|
||||
<button
|
||||
type="button"
|
||||
css={whatIfButtonStyle}
|
||||
onClick={() => dispatch(toggleWhatIfPanel(!whatIfPanelOpen))}
|
||||
data-test="what-if-button"
|
||||
aria-label={t('What-if playground')}
|
||||
>
|
||||
<Icons.StarFilled iconSize="m" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{userCanEdit && (
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
@@ -749,6 +783,7 @@ const Header = () => {
|
||||
undoLength,
|
||||
userCanEdit,
|
||||
userCanSaveAs,
|
||||
whatIfPanelOpen,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { styled, useTheme } from '@apache-superset/core/ui';
|
||||
import { Button } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { clearWhatIfModifications } from 'src/dashboard/actions/dashboardState';
|
||||
import { restoreOriginalChartData } from 'src/components/Chart/chartAction';
|
||||
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
|
||||
import { useNumericColumns } from 'src/dashboard/util/useNumericColumns';
|
||||
import { RootState, WhatIfModification } from 'src/dashboard/types';
|
||||
|
||||
const EMPTY_MODIFICATIONS: WhatIfModification[] = [];
|
||||
|
||||
/**
|
||||
* Banner container positioned at top of dashboard content, next to the What-If panel.
|
||||
*
|
||||
* Layout strategy:
|
||||
* - Grid positioning: column 1, row 1 (above dashboard content, next to panel)
|
||||
* - position: sticky with top: topOffset to stick below the dashboard header
|
||||
* - z-index: 10 to stay above chart content while scrolling
|
||||
* - align-self: start prevents the banner from stretching vertically
|
||||
*/
|
||||
const BannerContainer = styled.div<{ topOffset: number }>`
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 2}px
|
||||
${({ theme }) => theme.sizeUnit * 4}px;
|
||||
margin-bottom: 0;
|
||||
background-color: ${({ theme }) => theme.colorSuccessBg};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colorSuccessBorder};
|
||||
position: sticky;
|
||||
top: ${({ topOffset }) => topOffset}px;
|
||||
z-index: 10;
|
||||
align-self: start;
|
||||
`;
|
||||
|
||||
const BannerContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
color: ${({ theme }) => theme.colorSuccess};
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
`;
|
||||
|
||||
const Separator = styled.span`
|
||||
color: ${({ theme }) => theme.colorSuccess};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const ExitButton = styled(Button)`
|
||||
&& {
|
||||
color: ${({ theme }) => theme.colorSuccess};
|
||||
border-color: ${({ theme }) => theme.colorSuccess};
|
||||
background-color: ${({ theme }) => theme.colorSuccessBg};
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.colorSuccessHover};
|
||||
border-color: ${({ theme }) => theme.colorSuccessHover};
|
||||
background-color: ${({ theme }) => theme.colorSuccessBgHover};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface WhatIfBannerProps {
|
||||
topOffset: number;
|
||||
}
|
||||
|
||||
const WhatIfBanner = ({ topOffset }: WhatIfBannerProps) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const whatIfModifications = useSelector<RootState, WhatIfModification[]>(
|
||||
state => state.dashboardState.whatIfModifications ?? EMPTY_MODIFICATIONS,
|
||||
);
|
||||
|
||||
const { columnToChartIds, numericColumns } = useNumericColumns();
|
||||
|
||||
const handleExitWhatIf = useCallback(() => {
|
||||
const affectedChartIds = new Set<number>();
|
||||
whatIfModifications.forEach(mod => {
|
||||
const chartIds = columnToChartIds.get(mod.column) || [];
|
||||
chartIds.forEach(id => affectedChartIds.add(id));
|
||||
});
|
||||
|
||||
// Clear what-if modifications
|
||||
dispatch(clearWhatIfModifications());
|
||||
|
||||
// Restore original chart data from cache (instant, no re-query needed)
|
||||
affectedChartIds.forEach(chartId => {
|
||||
dispatch(restoreOriginalChartData(chartId));
|
||||
});
|
||||
}, [dispatch, whatIfModifications, columnToChartIds]);
|
||||
|
||||
if (whatIfModifications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modification = whatIfModifications[0];
|
||||
const percentageChange = formatPercentageChange(modification.multiplier);
|
||||
|
||||
// Get verbose name from numericColumns if available
|
||||
const columnInfo = numericColumns.find(
|
||||
col => col.columnName === modification.column,
|
||||
);
|
||||
const displayName = columnInfo?.verboseName || modification.column;
|
||||
|
||||
return (
|
||||
<BannerContainer data-test="what-if-banner" topOffset={topOffset}>
|
||||
<BannerContent>
|
||||
<Icons.ExperimentOutlined iconSize="m" iconColor={theme.colorSuccess} />
|
||||
<span>{t('What-if mode active')}</span>
|
||||
<Separator>|</Separator>
|
||||
<span>
|
||||
{t('Showing simulated data with %s %s', displayName, percentageChange)}
|
||||
</span>
|
||||
</BannerContent>
|
||||
<ExitButton
|
||||
buttonSize="small"
|
||||
onClick={handleExitWhatIf}
|
||||
data-test="exit-what-if-button"
|
||||
>
|
||||
<Icons.CloseOutlined iconSize="s" />
|
||||
{t('Exit what-if mode')}
|
||||
</ExitButton>
|
||||
</BannerContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatIfBanner;
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { Tooltip, Popover } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Datasource } from 'src/dashboard/types';
|
||||
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
|
||||
import AdhocFilterEditPopover from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopover';
|
||||
import { FilterButtonStyled, FilterPopoverContent } from './styles';
|
||||
|
||||
interface FilterButtonProps {
|
||||
filterPopoverVisible: boolean;
|
||||
currentAdhocFilter: AdhocFilter | null;
|
||||
selectedColumn: string | undefined;
|
||||
selectedDatasource: Datasource | null;
|
||||
filterColumnOptions: Datasource['columns'];
|
||||
onOpenFilterPopover: () => void;
|
||||
onFilterPopoverVisibleChange: (visible: boolean) => void;
|
||||
onFilterChange: (adhocFilter: AdhocFilter) => void;
|
||||
onFilterPopoverClose: () => void;
|
||||
onFilterPopoverResize: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for rendering the filter button with popover.
|
||||
* Uses memo to prevent unnecessary re-renders when parent state changes
|
||||
* that don't affect this component.
|
||||
*/
|
||||
const FilterButton = memo(function FilterButton({
|
||||
filterPopoverVisible,
|
||||
currentAdhocFilter,
|
||||
selectedColumn,
|
||||
selectedDatasource,
|
||||
filterColumnOptions,
|
||||
onOpenFilterPopover,
|
||||
onFilterPopoverVisibleChange,
|
||||
onFilterChange,
|
||||
onFilterPopoverClose,
|
||||
onFilterPopoverResize,
|
||||
}: FilterButtonProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={filterPopoverVisible}
|
||||
onOpenChange={onFilterPopoverVisibleChange}
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={
|
||||
currentAdhocFilter && selectedDatasource ? (
|
||||
<FilterPopoverContent>
|
||||
<AdhocFilterEditPopover
|
||||
adhocFilter={currentAdhocFilter}
|
||||
options={filterColumnOptions}
|
||||
datasource={selectedDatasource}
|
||||
onChange={onFilterChange}
|
||||
onClose={onFilterPopoverClose}
|
||||
onResize={onFilterPopoverResize}
|
||||
requireSave
|
||||
/>
|
||||
</FilterPopoverContent>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
title={
|
||||
selectedColumn
|
||||
? t('Add filter to scope the modification')
|
||||
: t('Select a column first')
|
||||
}
|
||||
>
|
||||
<FilterButtonStyled
|
||||
onClick={onOpenFilterPopover}
|
||||
disabled={!selectedColumn || !selectedDatasource}
|
||||
aria-label={t('Add filter')}
|
||||
buttonStyle="tertiary"
|
||||
>
|
||||
<Icons.FilterOutlined iconSize="m" />
|
||||
</FilterButtonStyled>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export default FilterButton;
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { styled } from '@apache-superset/core/ui';
|
||||
import { t } from '@superset-ui/core';
|
||||
|
||||
// eslint-disable theme-colors/no-literal-colors
|
||||
|
||||
// Casting spell motion - dramatic swish and flick!
|
||||
const castSpell = keyframes`
|
||||
0% {
|
||||
transform: rotate(-30deg) translateY(0);
|
||||
}
|
||||
15% {
|
||||
transform: rotate(-45deg) translateY(-5px);
|
||||
}
|
||||
30% {
|
||||
transform: rotate(25deg) translateY(0);
|
||||
}
|
||||
45% {
|
||||
transform: rotate(15deg) translateY(-3px);
|
||||
}
|
||||
60% {
|
||||
transform: rotate(-10deg) translateY(0);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(5deg) translateY(-2px);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(-30deg) translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Magic particles floating upward
|
||||
const floatUp = keyframes`
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) translateX(0) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100px) translateX(var(--drift-x, 0px)) scale(0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Spiral magic effect
|
||||
const spiral = keyframes`
|
||||
0% {
|
||||
transform: rotate(0deg) translateX(20px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(720deg) translateX(80px) rotate(-720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
// Glowing tip pulse
|
||||
const glowPulse = keyframes`
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 8px #fff) drop-shadow(0 0 15px #87CEEB) drop-shadow(0 0 25px #4169E1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 15px #fff) drop-shadow(0 0 30px #87CEEB) drop-shadow(0 0 45px #4169E1);
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
// Stars twinkling
|
||||
const twinkle = keyframes`
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(90deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8) rotate(180deg);
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2) rotate(270deg);
|
||||
}
|
||||
`;
|
||||
|
||||
// Lumos light burst
|
||||
const lumosBurst = keyframes`
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
`;
|
||||
|
||||
const textGlow = keyframes`
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 10px #87CEEB, 0 0 20px #4169E1, 0 0 30px #4169E1;
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 20px #87CEEB, 0 0 40px #4169E1, 0 0 60px #4169E1, 0 0 80px #6495ED;
|
||||
}
|
||||
`;
|
||||
|
||||
/* eslint-disable theme-colors/no-literal-colors */
|
||||
const Overlay = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(ellipse at center, rgba(20, 20, 40, 0.65) 0%, rgba(10, 10, 25, 0.75) 50%, rgba(0, 0, 10, 0.85) 100%);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 99999;
|
||||
animation: ${fadeIn} 0.5s ease-out;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StarsBackground = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Star = styled.div<{ size: number; x: number; y: number; delay: number }>`
|
||||
position: absolute;
|
||||
width: ${({ size }) => size}px;
|
||||
height: ${({ size }) => size}px;
|
||||
left: ${({ x }) => x}%;
|
||||
top: ${({ y }) => y}%;
|
||||
background: radial-gradient(circle, #fff 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
animation: ${twinkle} ${({ delay }) => 2 + delay}s ease-in-out infinite;
|
||||
animation-delay: ${({ delay }) => delay}s;
|
||||
`;
|
||||
|
||||
const WandScene = styled.div`
|
||||
position: relative;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const WandWrapper = styled.div`
|
||||
position: relative;
|
||||
animation: ${castSpell} 2.5s ease-in-out infinite;
|
||||
transform-origin: 85% 85%;
|
||||
`;
|
||||
|
||||
const WandSvg = styled.svg`
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
transform: rotate(-45deg);
|
||||
`;
|
||||
|
||||
const WandTipGlow = styled.div`
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: radial-gradient(circle, #fff 0%, #87CEEB 30%, #4169E1 60%, transparent 80%);
|
||||
border-radius: 50%;
|
||||
animation: ${glowPulse} 1s ease-in-out infinite;
|
||||
`;
|
||||
|
||||
const LumosBurst = styled.div`
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.8) 0%, rgba(135,206,235,0.4) 40%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
animation: ${lumosBurst} 2s ease-out infinite;
|
||||
`;
|
||||
|
||||
const MagicParticle = styled.div<{ delay: number; driftX: number; duration: number }>`
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: radial-gradient(circle, #fff 0%, #87CEEB 50%, transparent 100%);
|
||||
border-radius: 50%;
|
||||
--drift-x: ${({ driftX }) => driftX}px;
|
||||
animation: ${floatUp} ${({ duration }) => duration}s ease-out infinite;
|
||||
animation-delay: ${({ delay }) => delay}s;
|
||||
`;
|
||||
|
||||
const SpiralMagic = styled.div<{ delay: number; color: string }>`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: ${({ color }) => color};
|
||||
border-radius: 50%;
|
||||
animation: ${spiral} 3s ease-out infinite;
|
||||
animation-delay: ${({ delay }) => delay}s;
|
||||
box-shadow: 0 0 10px ${({ color }) => color};
|
||||
`;
|
||||
|
||||
const MagicStar = styled.div<{ x: number; y: number; delay: number; size: number }>`
|
||||
position: absolute;
|
||||
left: ${({ x }) => x}%;
|
||||
top: ${({ y }) => y}%;
|
||||
width: ${({ size }) => size}px;
|
||||
height: ${({ size }) => size}px;
|
||||
animation: ${twinkle} 1.5s ease-in-out infinite;
|
||||
animation-delay: ${({ delay }) => delay}s;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #fff 0%, #87CEEB 50%, #4169E1 100%);
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
`;
|
||||
|
||||
const SpellText = styled.div`
|
||||
margin-top: 40px;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-style: italic;
|
||||
color: #B8E0F0;
|
||||
animation: ${textGlow} 2s ease-in-out infinite;
|
||||
letter-spacing: 3px;
|
||||
`;
|
||||
|
||||
const SubText = styled.div`
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: #E0E8F0;
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
const DismissHint = styled.div`
|
||||
margin-top: 32px;
|
||||
font-size: 12px;
|
||||
color: #B0BEC5;
|
||||
`;
|
||||
/* eslint-enable theme-colors/no-literal-colors */
|
||||
|
||||
// Generate random stars for background
|
||||
const backgroundStars = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: i,
|
||||
size: Math.random() * 3 + 1,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
delay: Math.random() * 3,
|
||||
}));
|
||||
|
||||
// Magic particles around wand tip
|
||||
const particles = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: i,
|
||||
delay: i * 0.15,
|
||||
driftX: (Math.random() - 0.5) * 60,
|
||||
duration: 1.5 + Math.random() * 0.5,
|
||||
}));
|
||||
|
||||
// Spiral magic colors
|
||||
/* eslint-disable theme-colors/no-literal-colors */
|
||||
const spiralColors = ['#87CEEB', '#4169E1', '#6495ED', '#B0C4DE', '#ADD8E6', '#fff'];
|
||||
/* eslint-enable theme-colors/no-literal-colors */
|
||||
|
||||
const spirals = spiralColors.map((color, i) => ({
|
||||
id: i,
|
||||
delay: i * 0.5,
|
||||
color,
|
||||
}));
|
||||
|
||||
// Magic stars around the scene
|
||||
const magicStars = [
|
||||
{ x: 15, y: 20, delay: 0, size: 16 },
|
||||
{ x: 80, y: 25, delay: 0.3, size: 12 },
|
||||
{ x: 25, y: 70, delay: 0.6, size: 14 },
|
||||
{ x: 75, y: 65, delay: 0.9, size: 10 },
|
||||
{ x: 10, y: 45, delay: 0.2, size: 8 },
|
||||
{ x: 88, y: 50, delay: 0.5, size: 11 },
|
||||
{ x: 50, y: 10, delay: 0.4, size: 13 },
|
||||
{ x: 45, y: 85, delay: 0.7, size: 9 },
|
||||
];
|
||||
|
||||
interface HarryPotterWandLoaderProps {
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const HarryPotterWandLoader = ({ onDismiss }: HarryPotterWandLoaderProps) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onDismiss]);
|
||||
|
||||
const content = (
|
||||
<Overlay data-test="harry-potter-wand-loader" onClick={onDismiss}>
|
||||
<StarsBackground>
|
||||
{backgroundStars.map(star => (
|
||||
<Star
|
||||
key={star.id}
|
||||
size={star.size}
|
||||
x={star.x}
|
||||
y={star.y}
|
||||
delay={star.delay}
|
||||
/>
|
||||
))}
|
||||
</StarsBackground>
|
||||
|
||||
<WandScene>
|
||||
{magicStars.map((star, i) => (
|
||||
<MagicStar
|
||||
key={i}
|
||||
x={star.x}
|
||||
y={star.y}
|
||||
delay={star.delay}
|
||||
size={star.size}
|
||||
/>
|
||||
))}
|
||||
|
||||
{spirals.map(s => (
|
||||
<SpiralMagic key={s.id} delay={s.delay} color={s.color} />
|
||||
))}
|
||||
|
||||
<WandWrapper>
|
||||
<LumosBurst />
|
||||
<WandTipGlow />
|
||||
{particles.map(p => (
|
||||
<MagicParticle
|
||||
key={p.id}
|
||||
delay={p.delay}
|
||||
driftX={p.driftX}
|
||||
duration={p.duration}
|
||||
/>
|
||||
))}
|
||||
|
||||
<WandSvg viewBox="0 0 100 100" fill="none">
|
||||
<defs>
|
||||
{/* Wood grain gradient for authentic wand look */}
|
||||
<linearGradient id="hpWandWood" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#2C1810" />
|
||||
<stop offset="20%" stopColor="#4A2C1A" />
|
||||
<stop offset="40%" stopColor="#3D2314" />
|
||||
<stop offset="60%" stopColor="#5C3A22" />
|
||||
<stop offset="80%" stopColor="#3D2314" />
|
||||
<stop offset="100%" stopColor="#2C1810" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Handle gradient - darker, more ornate */}
|
||||
<linearGradient id="hpWandHandle" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#1A0F0A" />
|
||||
<stop offset="30%" stopColor="#2C1810" />
|
||||
<stop offset="50%" stopColor="#3D2314" />
|
||||
<stop offset="70%" stopColor="#2C1810" />
|
||||
<stop offset="100%" stopColor="#1A0F0A" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glowing tip */}
|
||||
<radialGradient id="hpWandGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#fff" />
|
||||
<stop offset="40%" stopColor="#87CEEB" />
|
||||
<stop offset="100%" stopColor="#4169E1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* Wand body - tapered shape like Elder Wand style */}
|
||||
<path
|
||||
d="M 50 10
|
||||
Q 52 15 52 20
|
||||
L 53 45
|
||||
Q 54 55 55 65
|
||||
L 57 80
|
||||
Q 58 85 56 88
|
||||
L 54 90
|
||||
Q 50 92 46 90
|
||||
L 44 88
|
||||
Q 42 85 43 80
|
||||
L 45 65
|
||||
Q 46 55 47 45
|
||||
L 48 20
|
||||
Q 48 15 50 10
|
||||
Z"
|
||||
fill="url(#hpWandWood)"
|
||||
/>
|
||||
|
||||
{/* Handle bumps - Elder Wand style nodules */}
|
||||
<ellipse cx="50" cy="75" rx="6" ry="3" fill="url(#hpWandHandle)" />
|
||||
<ellipse cx="50" cy="82" rx="5" ry="2.5" fill="url(#hpWandHandle)" />
|
||||
<ellipse cx="50" cy="88" rx="4" ry="2" fill="url(#hpWandHandle)" />
|
||||
|
||||
{/* Wood grain lines */}
|
||||
<path
|
||||
d="M 49 25 Q 50 40 49 55"
|
||||
stroke="#1A0F0A"
|
||||
strokeWidth="0.5"
|
||||
fill="none"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<path
|
||||
d="M 51 30 Q 52 45 51 60"
|
||||
stroke="#1A0F0A"
|
||||
strokeWidth="0.5"
|
||||
fill="none"
|
||||
opacity="0.3"
|
||||
/>
|
||||
|
||||
{/* Glowing tip */}
|
||||
<circle cx="50" cy="8" r="6" fill="url(#hpWandGlow)" />
|
||||
<circle cx="50" cy="8" r="3" fill="#fff" opacity="0.9" />
|
||||
</WandSvg>
|
||||
</WandWrapper>
|
||||
</WandScene>
|
||||
|
||||
<SpellText>{t('Revelio...')}</SpellText>
|
||||
<SubText>{t('Discovering hidden connections')}</SubText>
|
||||
{onDismiss && (
|
||||
<DismissHint>{t('Click anywhere or press Esc to cancel')}</DismissHint>
|
||||
)}
|
||||
</Overlay>
|
||||
);
|
||||
|
||||
// Use portal to render at document.body level to escape any stacking context issues
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default HarryPotterWandLoader;
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/ui';
|
||||
import { t } from '@superset-ui/core';
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const waveWand = keyframes`
|
||||
0%, 100% {
|
||||
transform: rotate(-15deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
`;
|
||||
|
||||
const sparkle = keyframes`
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
}
|
||||
`;
|
||||
|
||||
const float = keyframes`
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
`;
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Overlay = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${({ theme }) => theme.colorBgMask};
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
animation: ${fadeIn} 0.3s ease-out;
|
||||
`;
|
||||
|
||||
const WandContainer = styled.div`
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: ${float} 2s ease-in-out infinite;
|
||||
`;
|
||||
|
||||
const Wand = styled.div`
|
||||
position: relative;
|
||||
animation: ${waveWand} 1s ease-in-out infinite;
|
||||
transform-origin: bottom center;
|
||||
`;
|
||||
|
||||
const WandSvg = styled.svg`
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
filter: drop-shadow(0 0 20px ${({ theme }) => theme.colorWarning});
|
||||
`;
|
||||
|
||||
const Sparkle = styled.div<{ delay: number; x: number; y: number }>`
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
animation: ${sparkle} 1.5s ease-in-out infinite;
|
||||
animation-delay: ${({ delay }) => delay}s;
|
||||
left: ${({ x }) => x}%;
|
||||
top: ${({ y }) => y}%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
${({ theme }) => theme.colorWarning} 0%,
|
||||
${({ theme }) => theme.colorWhite} 50%,
|
||||
${({ theme }) => theme.colorWarning} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
`;
|
||||
|
||||
const GlowOrb = styled.div`
|
||||
position: absolute;
|
||||
top: 15%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
${({ theme }) => theme.colorWarning} 0%,
|
||||
${({ theme }) => theme.colorWarningBg} 40%,
|
||||
transparent 70%
|
||||
);
|
||||
border-radius: 50%;
|
||||
animation: ${pulse} 0.8s ease-in-out infinite;
|
||||
`;
|
||||
|
||||
const LoadingText = styled.div`
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * 6}px;
|
||||
color: ${({ theme }) => theme.colorWhite};
|
||||
font-size: ${({ theme }) => theme.fontSizeLG}px;
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
text-shadow: 0 2px 10px ${({ theme }) => theme.colorBgMask};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const SubText = styled.div`
|
||||
margin-top: ${({ theme }) => theme.sizeUnit}px;
|
||||
color: ${({ theme }) => theme.colorTextLightSolid};
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
`;
|
||||
|
||||
const DismissHint = styled.div`
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
color: ${({ theme }) => theme.colorTextLightSolid};
|
||||
font-size: ${({ theme }) => theme.fontSizeXS}px;
|
||||
opacity: 0.7;
|
||||
`;
|
||||
|
||||
const sparklePositions = [
|
||||
{ x: 20, y: 10, delay: 0 },
|
||||
{ x: 75, y: 15, delay: 0.3 },
|
||||
{ x: 85, y: 35, delay: 0.6 },
|
||||
{ x: 15, y: 30, delay: 0.9 },
|
||||
{ x: 60, y: 5, delay: 0.2 },
|
||||
{ x: 40, y: 20, delay: 0.5 },
|
||||
{ x: 80, y: 25, delay: 0.8 },
|
||||
{ x: 25, y: 18, delay: 0.4 },
|
||||
];
|
||||
|
||||
// Magic wand SVG colors - using warm brown/gold tones for the wand aesthetic
|
||||
/* eslint-disable theme-colors/no-literal-colors */
|
||||
const WAND_COLORS = {
|
||||
woodDark: '#654321',
|
||||
woodMid: '#8B4513',
|
||||
woodLight: '#A0522D',
|
||||
goldDark: '#B8860B',
|
||||
goldMid: '#DAA520',
|
||||
starLight: '#FFF8DC',
|
||||
};
|
||||
/* eslint-enable theme-colors/no-literal-colors */
|
||||
|
||||
interface MagicWandLoaderProps {
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const MagicWandLoader = ({ onDismiss }: MagicWandLoaderProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onDismiss]);
|
||||
|
||||
return (
|
||||
<Overlay data-test="magic-wand-loader" onClick={onDismiss}>
|
||||
<WandContainer>
|
||||
{sparklePositions.map((pos, i) => (
|
||||
<Sparkle key={i} x={pos.x} y={pos.y} delay={pos.delay} />
|
||||
))}
|
||||
<GlowOrb />
|
||||
<Wand>
|
||||
<WandSvg viewBox="0 0 64 64" fill="none">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="wandGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor={WAND_COLORS.woodMid} />
|
||||
<stop offset="50%" stopColor={WAND_COLORS.woodLight} />
|
||||
<stop offset="100%" stopColor={WAND_COLORS.woodDark} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Thin wand body */}
|
||||
<rect
|
||||
x="30.5"
|
||||
y="16"
|
||||
width="3"
|
||||
height="42"
|
||||
rx="1.5"
|
||||
fill="url(#wandGradient)"
|
||||
css={css`
|
||||
filter: drop-shadow(1px 1px 2px ${theme.colorBgMask});
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Small star at tip */}
|
||||
<circle
|
||||
cx="32"
|
||||
cy="12"
|
||||
r="4"
|
||||
fill={theme.colorWarning}
|
||||
css={css`
|
||||
filter: drop-shadow(0 0 6px ${theme.colorWarning});
|
||||
`}
|
||||
/>
|
||||
</WandSvg>
|
||||
</Wand>
|
||||
</WandContainer>
|
||||
<LoadingText>{t('Analyzing relationships...')}</LoadingText>
|
||||
<SubText>{t('AI is finding connected columns')}</SubText>
|
||||
{onDismiss && (
|
||||
<DismissHint>{t('Click anywhere or press Esc to cancel')}</DismissHint>
|
||||
)}
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
|
||||
export default MagicWandLoader;
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/ui';
|
||||
import { Tag } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
|
||||
import { ExtendedWhatIfModification } from './types';
|
||||
import {
|
||||
ModificationsSection,
|
||||
ModificationTagsContainer,
|
||||
AIBadge,
|
||||
AIReasoningSection,
|
||||
AIReasoningToggle,
|
||||
AIReasoningContent,
|
||||
AIReasoningItem,
|
||||
} from './styles';
|
||||
|
||||
interface ModificationsDisplayProps {
|
||||
modifications: ExtendedWhatIfModification[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying applied modifications as tags with AI reasoning.
|
||||
* Uses memo to prevent unnecessary re-renders when modifications haven't changed.
|
||||
*/
|
||||
const ModificationsDisplay = memo(function ModificationsDisplay({
|
||||
modifications,
|
||||
}: ModificationsDisplayProps) {
|
||||
const theme = useTheme();
|
||||
const [showAIReasoning, setShowAIReasoning] = useState(false);
|
||||
|
||||
const toggleAIReasoning = useCallback(() => {
|
||||
setShowAIReasoning(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const hasAIReasoning = modifications.some(mod => mod.reasoning);
|
||||
|
||||
if (modifications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ModificationsSection>
|
||||
<ModificationTagsContainer>
|
||||
{modifications.map((mod, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
css={css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
margin: 0;
|
||||
`}
|
||||
>
|
||||
<span>{mod.verboseName || mod.column}</span>
|
||||
{mod.isAISuggested && <AIBadge>{t('AI')}</AIBadge>}
|
||||
<span
|
||||
css={css`
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${mod.multiplier >= 1
|
||||
? theme.colorSuccess
|
||||
: theme.colorError};
|
||||
`}
|
||||
>
|
||||
{formatPercentageChange(mod.multiplier, 0)}
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
</ModificationTagsContainer>
|
||||
|
||||
{hasAIReasoning && (
|
||||
<AIReasoningSection>
|
||||
<AIReasoningToggle onClick={toggleAIReasoning}>
|
||||
{showAIReasoning ? (
|
||||
<Icons.DownOutlined iconSize="xs" />
|
||||
) : (
|
||||
<Icons.RightOutlined iconSize="xs" />
|
||||
)}
|
||||
{t('How AI chose these')}
|
||||
</AIReasoningToggle>
|
||||
{showAIReasoning && (
|
||||
<AIReasoningContent>
|
||||
{modifications
|
||||
.filter(mod => mod.reasoning)
|
||||
.map((mod, idx) => (
|
||||
<AIReasoningItem key={idx}>
|
||||
<strong>
|
||||
{mod.verboseName || mod.column}{' '}
|
||||
{formatPercentageChange(mod.multiplier, 0)}
|
||||
</strong>
|
||||
<div>{mod.reasoning}</div>
|
||||
</AIReasoningItem>
|
||||
))}
|
||||
</AIReasoningContent>
|
||||
)}
|
||||
</AIReasoningSection>
|
||||
)}
|
||||
</ModificationsSection>
|
||||
);
|
||||
});
|
||||
|
||||
export default ModificationsDisplay;
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/ui';
|
||||
import { Form, Input } from '@superset-ui/core/components';
|
||||
import { StandardModal } from 'src/components/Modal';
|
||||
import { WhatIfModification } from './types';
|
||||
import {
|
||||
createSimulation,
|
||||
updateSimulation,
|
||||
WhatIfSimulation,
|
||||
} from './whatIfApi';
|
||||
|
||||
const ModalContent = styled.div`
|
||||
padding: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 6}px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
padding-bottom: ${({ theme }) => theme.sizeUnit}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface SaveSimulationModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onSaved: (simulation: WhatIfSimulation) => void;
|
||||
dashboardId: number;
|
||||
modifications: WhatIfModification[];
|
||||
cascadingEffectsEnabled: boolean;
|
||||
existingSimulation?: WhatIfSimulation | null;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
const SaveSimulationModal = ({
|
||||
show,
|
||||
onHide,
|
||||
onSaved,
|
||||
dashboardId,
|
||||
modifications,
|
||||
cascadingEffectsEnabled,
|
||||
existingSimulation,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
}: SaveSimulationModalProps) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const isUpdate = Boolean(existingSimulation);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
form.setFieldsValue({
|
||||
name: existingSimulation?.name || '',
|
||||
description: existingSimulation?.description || '',
|
||||
});
|
||||
}
|
||||
}, [show, existingSimulation, form]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (isUpdate && existingSimulation) {
|
||||
await updateSimulation(existingSimulation.id, {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
modifications,
|
||||
cascadingEffectsEnabled,
|
||||
});
|
||||
const updatedSimulation: WhatIfSimulation = {
|
||||
...existingSimulation,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
modifications,
|
||||
cascadingEffectsEnabled,
|
||||
};
|
||||
onSaved(updatedSimulation);
|
||||
addSuccessToast(t('Simulation updated successfully'));
|
||||
} else {
|
||||
const simulation = await createSimulation({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
dashboardId,
|
||||
modifications,
|
||||
cascadingEffectsEnabled,
|
||||
});
|
||||
onSaved(simulation);
|
||||
addSuccessToast(t('Simulation saved successfully'));
|
||||
}
|
||||
|
||||
onHide();
|
||||
} catch (error) {
|
||||
addDangerToast(t('Failed to save simulation'));
|
||||
}
|
||||
}, [
|
||||
form,
|
||||
isUpdate,
|
||||
existingSimulation,
|
||||
modifications,
|
||||
cascadingEffectsEnabled,
|
||||
dashboardId,
|
||||
onSaved,
|
||||
onHide,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
form.resetFields();
|
||||
onHide();
|
||||
}, [form, onHide]);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
show={show}
|
||||
onHide={handleCancel}
|
||||
onSave={handleSave}
|
||||
title={isUpdate ? t('Update Simulation') : t('Save Simulation')}
|
||||
width={500}
|
||||
saveText={isUpdate ? t('Update') : t('Save')}
|
||||
>
|
||||
<ModalContent>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('Name')}
|
||||
rules={[{ required: true, message: t('Please enter a name') }]}
|
||||
>
|
||||
<Input placeholder={t('My What-If Scenario')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label={t('Description')}>
|
||||
<TextArea
|
||||
placeholder={t('Optional description of this simulation')}
|
||||
rows={3}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</ModalContent>
|
||||
</StandardModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveSimulationModal;
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { styled, Alert } from '@apache-superset/core/ui';
|
||||
import { Icons, IconType } from '@superset-ui/core/components/Icons';
|
||||
import { Collapse, Skeleton } from '@superset-ui/core/components/';
|
||||
import { RootState, WhatIfModification } from 'src/dashboard/types';
|
||||
import { whatIfHighlightStyles } from 'src/dashboard/util/useWhatIfHighlightStyles';
|
||||
import { fetchWhatIfInterpretation } from './whatIfApi';
|
||||
import { useChartComparison, useAllChartsLoaded } from './useChartComparison';
|
||||
import {
|
||||
GroupedWhatIfInsights,
|
||||
WhatIfAIStatus,
|
||||
WhatIfInsightType,
|
||||
WhatIfInterpretResponse,
|
||||
} from './types';
|
||||
|
||||
// Static Skeleton paragraph configs to avoid recreation on each render
|
||||
const SKELETON_PARAGRAPH_3 = { rows: 3 };
|
||||
const SKELETON_PARAGRAPH_2 = { rows: 2 };
|
||||
|
||||
// Configuration for each insight type
|
||||
const INSIGHT_TYPE_CONFIG: Record<
|
||||
WhatIfInsightType,
|
||||
{ label: string; icon: React.ComponentType<IconType> }
|
||||
> = {
|
||||
observation: { label: t('Observations'), icon: Icons.EyeOutlined },
|
||||
implication: { label: t('Implications'), icon: Icons.WarningOutlined },
|
||||
recommendation: {
|
||||
label: t('Recommendations'),
|
||||
icon: Icons.CheckCircleOutlined,
|
||||
},
|
||||
};
|
||||
|
||||
// Order in which insight types should appear
|
||||
const INSIGHT_TYPE_ORDER: WhatIfInsightType[] = [
|
||||
'observation',
|
||||
'implication',
|
||||
'recommendation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a stable key from modifications for comparison.
|
||||
* This allows us to detect when modifications have meaningfully changed.
|
||||
*/
|
||||
function getModificationsKey(modifications: WhatIfModification[]): string {
|
||||
return modifications
|
||||
.map(m => `${m.column}:${m.multiplier}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
const InsightsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
padding-top: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
border-top: 1px solid ${({ theme }) => theme.colorBorderSecondary};
|
||||
`;
|
||||
|
||||
const InsightsHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
`;
|
||||
|
||||
const InsightCard = styled.div<{ insightType: string }>`
|
||||
padding: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
background-color: ${({ theme, insightType }) => {
|
||||
switch (insightType) {
|
||||
case 'observation':
|
||||
return theme.colorInfoBg;
|
||||
case 'implication':
|
||||
return theme.colorWarningBg;
|
||||
case 'recommendation':
|
||||
return theme.colorSuccessBg;
|
||||
default:
|
||||
return theme.colorBgElevated;
|
||||
}
|
||||
}};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
border-left: 3px solid
|
||||
${({ theme, insightType }) => {
|
||||
switch (insightType) {
|
||||
case 'observation':
|
||||
return theme.colorInfo;
|
||||
case 'implication':
|
||||
return theme.colorWarning;
|
||||
case 'recommendation':
|
||||
return theme.colorSuccess;
|
||||
default:
|
||||
return theme.colorBorder;
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const InsightTitle = styled.div`
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
const InsightDescription = styled.div`
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const Summary = styled.div`
|
||||
font-size: ${({ theme }) => theme.fontSize}px;
|
||||
line-height: 1.6;
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
padding: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
background-color: ${({ theme }) => theme.colorBgElevated};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
${whatIfHighlightStyles}
|
||||
`;
|
||||
|
||||
const StyledCollapse = styled(Collapse)`
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none;
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
background: ${({ theme }) => theme.colorBgElevated};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px !important;
|
||||
overflow: hidden;
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid ${({ theme }) => theme.colorBorderSecondary};
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CollapsePanelHeader = styled.div<{ insightType: WhatIfInsightType }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
color: ${({ theme, insightType }) => {
|
||||
switch (insightType) {
|
||||
case 'observation':
|
||||
return theme.colorInfo;
|
||||
case 'implication':
|
||||
return theme.colorWarning;
|
||||
case 'recommendation':
|
||||
return theme.colorSuccess;
|
||||
default:
|
||||
return theme.colorText;
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
interface WhatIfAIInsightsProps {
|
||||
affectedChartIds: number[];
|
||||
modifications: WhatIfModification[];
|
||||
/** Ref to register the abort function for external control */
|
||||
abortRef?: React.MutableRefObject<(() => void) | null>;
|
||||
}
|
||||
|
||||
const WhatIfAIInsights = ({
|
||||
affectedChartIds,
|
||||
modifications,
|
||||
abortRef,
|
||||
}: WhatIfAIInsightsProps) => {
|
||||
const [status, setStatus] = useState<WhatIfAIStatus>('idle');
|
||||
const [response, setResponse] = useState<WhatIfInterpretResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const dashboardTitle = useSelector<RootState, string>(
|
||||
// @ts-ignore
|
||||
state => state.dashboardInfo?.dashboard_title || 'Dashboard',
|
||||
);
|
||||
|
||||
const chartComparisons = useChartComparison(affectedChartIds);
|
||||
const allChartsLoaded = useAllChartsLoaded(affectedChartIds);
|
||||
|
||||
// AbortController for cancelling in-flight /interpret requests
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Cleanup: cancel any pending requests on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
abortControllerRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Register abort function with external ref for parent control
|
||||
useEffect(() => {
|
||||
if (abortRef) {
|
||||
abortRef.current = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
if (abortRef) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [abortRef]);
|
||||
|
||||
// Track modification changes to reset status when user adjusts the slider
|
||||
const modificationsKey = getModificationsKey(modifications);
|
||||
const prevModificationsKeyRef = useRef<string>(modificationsKey);
|
||||
|
||||
// Reset status when modifications change (user adjusts the slider)
|
||||
useEffect(() => {
|
||||
if (
|
||||
modificationsKey !== prevModificationsKeyRef.current &&
|
||||
modifications.length > 0
|
||||
) {
|
||||
// Cancel any in-flight request when modifications change
|
||||
abortControllerRef.current?.abort();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: resetting state when modifications change
|
||||
setStatus('idle');
|
||||
setResponse(null);
|
||||
prevModificationsKeyRef.current = modificationsKey;
|
||||
}
|
||||
}, [modificationsKey, modifications.length]);
|
||||
|
||||
const fetchInsights = useCallback(async () => {
|
||||
if (modifications.length === 0 || chartComparisons.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight request before starting a new one
|
||||
abortControllerRef.current?.abort();
|
||||
|
||||
// Create a new AbortController for this request
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await fetchWhatIfInterpretation(
|
||||
{
|
||||
modifications,
|
||||
charts: chartComparisons,
|
||||
dashboardName: dashboardTitle,
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
setResponse(result);
|
||||
setStatus('success');
|
||||
} catch (err) {
|
||||
// Don't update state if the request was aborted
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t('Failed to generate AI insights'),
|
||||
);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [modifications, chartComparisons, dashboardTitle]);
|
||||
|
||||
// Automatically fetch insights when all affected charts have finished loading.
|
||||
// We wait for allChartsLoaded to prevent race conditions where we'd send
|
||||
// stale data before charts have re-queried with the what-if modifications.
|
||||
// The setState call here is intentional - we're synchronizing with prop changes.
|
||||
useEffect(() => {
|
||||
if (
|
||||
modifications.length > 0 &&
|
||||
chartComparisons.length > 0 &&
|
||||
allChartsLoaded &&
|
||||
status === 'idle'
|
||||
) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: triggering async fetch based on prop changes
|
||||
fetchInsights();
|
||||
}
|
||||
}, [modifications, chartComparisons, allChartsLoaded, status, fetchInsights]);
|
||||
|
||||
// Reset state when modifications are cleared.
|
||||
// The setState calls here are intentional - we're resetting local state when props change.
|
||||
useEffect(() => {
|
||||
if (modifications.length === 0) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: resetting state when modifications cleared
|
||||
setStatus('idle');
|
||||
setResponse(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [modifications]);
|
||||
|
||||
// Group insights by type
|
||||
const insights = response?.insights;
|
||||
const groupedInsights = useMemo(() => {
|
||||
if (!insights) return {} as GroupedWhatIfInsights;
|
||||
return insights.reduce<GroupedWhatIfInsights>((acc, insight) => {
|
||||
if (!acc[insight.type]) {
|
||||
acc[insight.type] = [];
|
||||
}
|
||||
acc[insight.type].push(insight);
|
||||
return acc;
|
||||
}, {} as GroupedWhatIfInsights);
|
||||
}, [insights]);
|
||||
|
||||
// Build collapse items from grouped insights
|
||||
const collapseItems = useMemo(
|
||||
() =>
|
||||
INSIGHT_TYPE_ORDER.filter(type => groupedInsights[type]?.length > 0).map(
|
||||
type => {
|
||||
const typeInsights = groupedInsights[type];
|
||||
const config = INSIGHT_TYPE_CONFIG[type];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return {
|
||||
key: type,
|
||||
label: (
|
||||
<CollapsePanelHeader insightType={type}>
|
||||
<IconComponent iconSize="m" />
|
||||
{config.label}
|
||||
</CollapsePanelHeader>
|
||||
),
|
||||
children: typeInsights.map((insight, index) => (
|
||||
<InsightCard key={index} insightType={insight.type}>
|
||||
<InsightTitle>{insight.title}</InsightTitle>
|
||||
<InsightDescription>{insight.description}</InsightDescription>
|
||||
</InsightCard>
|
||||
)),
|
||||
};
|
||||
},
|
||||
),
|
||||
[groupedInsights],
|
||||
);
|
||||
|
||||
if (modifications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InsightsContainer data-test="what-if-ai-insights">
|
||||
<InsightsHeader>
|
||||
<Icons.BulbOutlined iconSize="m" />
|
||||
{t('AI Insights')}
|
||||
</InsightsHeader>
|
||||
|
||||
{status === 'loading' && (
|
||||
<Skeleton active paragraph={SKELETON_PARAGRAPH_3} />
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={t('Failed to generate insights')}
|
||||
description={error}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'success' && response && (
|
||||
<>
|
||||
<Summary>{response.summary}</Summary>
|
||||
|
||||
<StyledCollapse
|
||||
defaultActiveKey={INSIGHT_TYPE_ORDER}
|
||||
items={collapseItems}
|
||||
ghost
|
||||
bordered
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'idle' && !allChartsLoaded && (
|
||||
<Skeleton active paragraph={SKELETON_PARAGRAPH_2} />
|
||||
)}
|
||||
</InsightsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatIfAIInsights;
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, Key } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/ui';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import {
|
||||
NoAnimationDropdown,
|
||||
Button,
|
||||
Tooltip,
|
||||
} from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { WhatIfSimulation } from './whatIfApi';
|
||||
|
||||
enum MenuKeys {
|
||||
LoadSimulation = 'load-simulation',
|
||||
SaveSimulation = 'save-simulation',
|
||||
SaveAsNew = 'save-as-new',
|
||||
ManageSimulations = 'manage-simulations',
|
||||
}
|
||||
|
||||
interface WhatIfHeaderMenuProps {
|
||||
selectedSimulation: WhatIfSimulation | null;
|
||||
onSelectSimulation: (simulation: WhatIfSimulation | null) => void;
|
||||
onSaveClick: () => void;
|
||||
onSaveAsNewClick: () => void;
|
||||
hasModifications: boolean;
|
||||
simulations: WhatIfSimulation[];
|
||||
simulationsLoading: boolean;
|
||||
}
|
||||
|
||||
const VerticalDotsTrigger = () => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Icons.EllipsisOutlined
|
||||
css={css`
|
||||
transform: rotate(90deg);
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
`}
|
||||
iconSize="xl"
|
||||
iconColor={theme.colorTextLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const WhatIfHeaderMenu = ({
|
||||
selectedSimulation,
|
||||
onSelectSimulation,
|
||||
onSaveClick,
|
||||
onSaveAsNewClick,
|
||||
hasModifications,
|
||||
simulations,
|
||||
simulationsLoading,
|
||||
}: WhatIfHeaderMenuProps) => {
|
||||
const theme = useTheme();
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
|
||||
const handleMenuClick = useCallback(
|
||||
({ key }: { key: Key }) => {
|
||||
const keyStr = String(key);
|
||||
|
||||
if (keyStr === MenuKeys.SaveSimulation) {
|
||||
onSaveClick();
|
||||
setIsDropdownVisible(false);
|
||||
} else if (keyStr === MenuKeys.SaveAsNew) {
|
||||
onSaveAsNewClick();
|
||||
setIsDropdownVisible(false);
|
||||
} else if (keyStr.startsWith('load-sim-')) {
|
||||
const simId = parseInt(keyStr.replace('load-sim-', ''), 10);
|
||||
const sim = simulations.find(s => s.id === simId);
|
||||
if (sim) {
|
||||
onSelectSimulation(sim);
|
||||
}
|
||||
setIsDropdownVisible(false);
|
||||
} else if (keyStr === 'clear-simulation') {
|
||||
onSelectSimulation(null);
|
||||
setIsDropdownVisible(false);
|
||||
}
|
||||
},
|
||||
[simulations, onSelectSimulation, onSaveClick, onSaveAsNewClick],
|
||||
);
|
||||
|
||||
const simulationMenuItems =
|
||||
simulations.length > 0
|
||||
? [
|
||||
...(selectedSimulation
|
||||
? [
|
||||
{
|
||||
key: 'clear-simulation',
|
||||
label: t('Clear current simulation'),
|
||||
icon: <Icons.CloseOutlined />,
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
]
|
||||
: []),
|
||||
...simulations.map(sim => ({
|
||||
key: `load-sim-${sim.id}`,
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
<span
|
||||
css={css`
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`}
|
||||
>
|
||||
{sim.name}
|
||||
{selectedSimulation?.id === sim.id && (
|
||||
<Icons.CheckOutlined
|
||||
css={css`
|
||||
margin-left: ${theme.sizeUnit}px;
|
||||
color: ${theme.colorSuccess};
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{sim.description && (
|
||||
<Tooltip title={sim.description}>
|
||||
<Icons.InfoCircleOutlined
|
||||
onClick={e => e.stopPropagation()}
|
||||
css={css`
|
||||
color: ${theme.colorTextSecondary};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'no-simulations',
|
||||
label: t('No saved simulations'),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
type: 'submenu' as const,
|
||||
key: MenuKeys.LoadSimulation,
|
||||
label: simulationsLoading ? t('Loading...') : t('Load simulation'),
|
||||
icon: <Icons.FolderOpenOutlined />,
|
||||
children: simulationMenuItems,
|
||||
},
|
||||
{
|
||||
key: MenuKeys.SaveSimulation,
|
||||
label: selectedSimulation ? t('Update simulation') : t('Save simulation'),
|
||||
icon: <Icons.SaveOutlined />,
|
||||
disabled: !hasModifications,
|
||||
},
|
||||
...(selectedSimulation
|
||||
? [
|
||||
{
|
||||
key: MenuKeys.SaveAsNew,
|
||||
label: t('Save as new'),
|
||||
icon: <Icons.PlusOutlined />,
|
||||
disabled: !hasModifications,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: MenuKeys.ManageSimulations,
|
||||
label: <Link to="/whatif/simulations/">{t('Manage simulations')}</Link>,
|
||||
icon: <Icons.SettingOutlined />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NoAnimationDropdown
|
||||
popupRender={() => (
|
||||
<Menu
|
||||
onClick={handleMenuClick}
|
||||
data-test="what-if-header-menu"
|
||||
selectable={false}
|
||||
items={menuItems}
|
||||
/>
|
||||
)}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
open={isDropdownVisible}
|
||||
onOpenChange={visible => setIsDropdownVisible(visible)}
|
||||
>
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
aria-label={t('More Options')}
|
||||
aria-haspopup="true"
|
||||
css={css`
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
`}
|
||||
>
|
||||
<VerticalDotsTrigger />
|
||||
</Button>
|
||||
</NoAnimationDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatIfHeaderMenu;
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
Select,
|
||||
Checkbox,
|
||||
Tooltip,
|
||||
Tag,
|
||||
CheckboxChangeEvent,
|
||||
} from '@superset-ui/core/components';
|
||||
import Slider from '@superset-ui/core/components/Slider';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { useNumericColumns } from 'src/dashboard/util/useNumericColumns';
|
||||
import { RootState, Datasource } from 'src/dashboard/types';
|
||||
import WhatIfAIInsights from './WhatIfAIInsights';
|
||||
import { useAllChartsLoaded } from './useChartComparison';
|
||||
import HarryPotterWandLoader from './HarryPotterWandLoader';
|
||||
import FilterButton from './FilterButton';
|
||||
import ModificationsDisplay from './ModificationsDisplay';
|
||||
import WhatIfHeaderMenu from './WhatIfHeaderMenu';
|
||||
import SaveSimulationModal from './SaveSimulationModal';
|
||||
import { useWhatIfFilters } from './useWhatIfFilters';
|
||||
import { useWhatIfApply } from './useWhatIfApply';
|
||||
import { WhatIfSimulation, fetchSimulations } from './whatIfApi';
|
||||
import {
|
||||
SLIDER_MIN,
|
||||
SLIDER_MAX,
|
||||
SLIDER_DEFAULT,
|
||||
SLIDER_MARKS,
|
||||
SLIDER_TOOLTIP_CONFIG,
|
||||
WHAT_IF_PANEL_WIDTH,
|
||||
} from './constants';
|
||||
import {
|
||||
PanelContainer,
|
||||
PanelHeader,
|
||||
PanelTitle,
|
||||
CloseButton,
|
||||
PanelContent,
|
||||
FormSection,
|
||||
Label,
|
||||
SliderContainer,
|
||||
ApplyButton,
|
||||
CheckboxContainer,
|
||||
ColumnSelectRow,
|
||||
ColumnSelectWrapper,
|
||||
FiltersSection,
|
||||
FilterTagsContainer,
|
||||
HeaderButtonsContainer,
|
||||
} from './styles';
|
||||
|
||||
export { WHAT_IF_PANEL_WIDTH };
|
||||
|
||||
interface WhatIfPanelProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
topOffset: number;
|
||||
initialSimulationId?: number | null;
|
||||
}
|
||||
|
||||
const WhatIfPanel = ({
|
||||
visible,
|
||||
onClose,
|
||||
topOffset,
|
||||
initialSimulationId,
|
||||
}: WhatIfPanelProps) => {
|
||||
const theme = useTheme();
|
||||
const { addSuccessToast, addDangerToast } = useToasts();
|
||||
|
||||
// Get dashboard ID from Redux
|
||||
const dashboardId = useSelector((state: RootState) => state.dashboardInfo.id);
|
||||
|
||||
// Local state for column selection and slider
|
||||
const [selectedColumn, setSelectedColumn] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [sliderValue, setSliderValue] = useState<number>(SLIDER_DEFAULT);
|
||||
const [enableCascadingEffects, setEnableCascadingEffects] = useState(false);
|
||||
|
||||
// Simulation state
|
||||
const [selectedSimulation, setSelectedSimulation] =
|
||||
useState<WhatIfSimulation | null>(null);
|
||||
const [saveModalVisible, setSaveModalVisible] = useState(false);
|
||||
const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]);
|
||||
const [simulationsLoading, setSimulationsLoading] = useState(false);
|
||||
|
||||
// Track if initial simulation from URL has been loaded
|
||||
const initialSimulationLoadedRef = useRef(false);
|
||||
|
||||
// Custom hook for filter management
|
||||
const {
|
||||
filters,
|
||||
filterPopoverVisible,
|
||||
currentAdhocFilter,
|
||||
setFilterPopoverVisible,
|
||||
setFilters,
|
||||
handleOpenFilterPopover,
|
||||
handleEditFilter,
|
||||
handleFilterChange,
|
||||
handleRemoveFilter,
|
||||
handleFilterPopoverClose,
|
||||
handleFilterPopoverResize,
|
||||
clearFilters,
|
||||
formatFilterLabel,
|
||||
} = useWhatIfFilters();
|
||||
|
||||
// Custom hook for apply logic and modifications
|
||||
const {
|
||||
appliedModifications,
|
||||
affectedChartIds,
|
||||
isLoadingSuggestions,
|
||||
applyCounter,
|
||||
handleApply,
|
||||
handleDismissLoader,
|
||||
aiInsightsModifications,
|
||||
loadModificationsDirectly,
|
||||
clearModifications,
|
||||
interpretAbortRef,
|
||||
} = useWhatIfApply({
|
||||
selectedColumn,
|
||||
sliderValue,
|
||||
filters,
|
||||
enableCascadingEffects,
|
||||
});
|
||||
|
||||
// Get numeric columns and datasources
|
||||
const { numericColumns, columnToChartIds } = useNumericColumns();
|
||||
const datasources = useSelector((state: RootState) => state.datasources);
|
||||
|
||||
// Get all chart IDs that could be affected by what-if analysis
|
||||
const allDashboardChartIds = useMemo(() => {
|
||||
const chartIds = new Set<number>();
|
||||
columnToChartIds.forEach(ids => ids.forEach(id => chartIds.add(id)));
|
||||
return Array.from(chartIds);
|
||||
}, [columnToChartIds]);
|
||||
|
||||
// Check if all dashboard charts have completed their initial load
|
||||
const initialChartsLoaded = useAllChartsLoaded(allDashboardChartIds);
|
||||
|
||||
// Column options for the select dropdown
|
||||
const columnOptions = useMemo(
|
||||
() =>
|
||||
numericColumns.map(col => ({
|
||||
value: col.columnName,
|
||||
label: col.verboseName || col.columnName,
|
||||
})),
|
||||
[numericColumns],
|
||||
);
|
||||
|
||||
// Find info about the selected column
|
||||
const selectedColumnInfo = useMemo(
|
||||
() => numericColumns.find(col => col.columnName === selectedColumn),
|
||||
[numericColumns, selectedColumn],
|
||||
);
|
||||
|
||||
// Find the datasource for the selected column
|
||||
const selectedDatasource = useMemo((): Datasource | null => {
|
||||
if (!selectedColumnInfo) return null;
|
||||
// Find datasource by ID - keys are in format "id__type"
|
||||
const datasourceEntry = Object.entries(datasources).find(([key]) => {
|
||||
const [idStr] = key.split('__');
|
||||
return parseInt(idStr, 10) === selectedColumnInfo.datasourceId;
|
||||
});
|
||||
return datasourceEntry ? datasourceEntry[1] : null;
|
||||
}, [datasources, selectedColumnInfo]);
|
||||
|
||||
// Get all columns from the selected datasource for filter options
|
||||
const filterColumnOptions = useMemo(() => {
|
||||
if (!selectedDatasource?.columns) return [];
|
||||
return selectedDatasource.columns;
|
||||
}, [selectedDatasource]);
|
||||
|
||||
// Handle column selection change - also clears filters
|
||||
const handleColumnChange = useCallback(
|
||||
(value: string | undefined) => {
|
||||
setSelectedColumn(value);
|
||||
// Clear filters when column changes since they're tied to the datasource
|
||||
clearFilters();
|
||||
},
|
||||
[clearFilters],
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback((value: number) => {
|
||||
setSliderValue(value);
|
||||
}, []);
|
||||
|
||||
const handleCascadingEffectsChange = useCallback((e: CheckboxChangeEvent) => {
|
||||
setEnableCascadingEffects(e.target.checked);
|
||||
}, []);
|
||||
|
||||
// Handle loading a saved simulation
|
||||
const handleLoadSimulation = useCallback(
|
||||
(simulation: WhatIfSimulation | null) => {
|
||||
setSelectedSimulation(simulation);
|
||||
if (simulation && simulation.modifications.length > 0) {
|
||||
const firstMod = simulation.modifications[0];
|
||||
setSelectedColumn(firstMod.column);
|
||||
setSliderValue((firstMod.multiplier - 1) * 100);
|
||||
setEnableCascadingEffects(simulation.cascadingEffectsEnabled);
|
||||
|
||||
// Load filters from the first modification
|
||||
if (firstMod.filters && firstMod.filters.length > 0) {
|
||||
setFilters(firstMod.filters);
|
||||
} else {
|
||||
clearFilters();
|
||||
}
|
||||
|
||||
// Convert to extended modifications with isAISuggested flag
|
||||
// First modification is the user's, rest are AI-suggested
|
||||
const extendedModifications = simulation.modifications.map(
|
||||
(mod, index) => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
filters: mod.filters,
|
||||
isAISuggested: index > 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Load all modifications directly and trigger chart queries + /interpret
|
||||
loadModificationsDirectly(extendedModifications);
|
||||
} else if (!simulation) {
|
||||
// Clear state when deselecting
|
||||
setSelectedColumn(undefined);
|
||||
setSliderValue(SLIDER_DEFAULT);
|
||||
setEnableCascadingEffects(false);
|
||||
clearFilters();
|
||||
clearModifications();
|
||||
}
|
||||
},
|
||||
[clearFilters, setFilters, loadModificationsDirectly, clearModifications],
|
||||
);
|
||||
|
||||
// Load simulations list from API
|
||||
const loadSimulations = useCallback(async () => {
|
||||
if (!dashboardId) return;
|
||||
setSimulationsLoading(true);
|
||||
try {
|
||||
const result = await fetchSimulations(dashboardId);
|
||||
setSimulations(result);
|
||||
} catch (error) {
|
||||
addDangerToast(t('Failed to load saved simulations'));
|
||||
} finally {
|
||||
setSimulationsLoading(false);
|
||||
}
|
||||
}, [dashboardId, addDangerToast]);
|
||||
|
||||
// Fetch simulations when dashboard is ready
|
||||
useEffect(() => {
|
||||
if (dashboardId) {
|
||||
loadSimulations();
|
||||
}
|
||||
}, [dashboardId, loadSimulations]);
|
||||
|
||||
// Load initial simulation from URL parameter
|
||||
// Wait until:
|
||||
// 1. simulations are loaded (from the earlier useEffect)
|
||||
// 2. columnToChartIds is populated (chart metadata is available)
|
||||
// 3. initialChartsLoaded is true (charts have finished their initial queries)
|
||||
// This ensures we can properly save original chart data before applying what-if modifications
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialSimulationId &&
|
||||
!initialSimulationLoadedRef.current &&
|
||||
simulations.length > 0 &&
|
||||
columnToChartIds.size > 0 &&
|
||||
initialChartsLoaded
|
||||
) {
|
||||
initialSimulationLoadedRef.current = true;
|
||||
const simulation = simulations.find(s => s.id === initialSimulationId);
|
||||
if (simulation) {
|
||||
handleLoadSimulation(simulation);
|
||||
} else {
|
||||
addDangerToast(t('Simulation not found'));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
initialSimulationId,
|
||||
simulations,
|
||||
addDangerToast,
|
||||
columnToChartIds.size,
|
||||
initialChartsLoaded,
|
||||
handleLoadSimulation,
|
||||
]);
|
||||
|
||||
// Handle saving a simulation
|
||||
const handleSaveSimulation = useCallback(
|
||||
(simulation: WhatIfSimulation) => {
|
||||
setSelectedSimulation(simulation);
|
||||
// Refresh the simulations list to include the newly saved simulation
|
||||
loadSimulations();
|
||||
},
|
||||
[loadSimulations],
|
||||
);
|
||||
|
||||
// Track if we're saving as new (vs updating existing)
|
||||
const [isSavingAsNew, setIsSavingAsNew] = useState(false);
|
||||
|
||||
const handleOpenSaveModal = useCallback(() => {
|
||||
setIsSavingAsNew(false);
|
||||
setSaveModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenSaveAsNewModal = useCallback(() => {
|
||||
setIsSavingAsNew(true);
|
||||
setSaveModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseSaveModal = useCallback(() => {
|
||||
setSaveModalVisible(false);
|
||||
setIsSavingAsNew(false);
|
||||
}, []);
|
||||
|
||||
const isApplyDisabled =
|
||||
!selectedColumn || sliderValue === SLIDER_DEFAULT || isLoadingSuggestions;
|
||||
|
||||
return (
|
||||
<PanelContainer
|
||||
data-test="what-if-panel"
|
||||
topOffset={topOffset}
|
||||
visible={visible}
|
||||
>
|
||||
<PanelHeader>
|
||||
<PanelTitle>
|
||||
<Icons.StarFilled
|
||||
iconSize="m"
|
||||
css={css`
|
||||
color: ${theme.colorWarning};
|
||||
`}
|
||||
/>
|
||||
{t('What-if playground')}
|
||||
</PanelTitle>
|
||||
<HeaderButtonsContainer>
|
||||
<WhatIfHeaderMenu
|
||||
selectedSimulation={selectedSimulation}
|
||||
onSelectSimulation={handleLoadSimulation}
|
||||
onSaveClick={handleOpenSaveModal}
|
||||
onSaveAsNewClick={handleOpenSaveAsNewModal}
|
||||
hasModifications={appliedModifications.length > 0}
|
||||
simulations={simulations}
|
||||
simulationsLoading={simulationsLoading}
|
||||
/>
|
||||
<CloseButton onClick={onClose} aria-label={t('Close')}>
|
||||
<Icons.CloseOutlined iconSize="m" />
|
||||
</CloseButton>
|
||||
</HeaderButtonsContainer>
|
||||
</PanelHeader>
|
||||
|
||||
<PanelContent>
|
||||
<FormSection>
|
||||
<Label>{t('Select column to adjust')}</Label>
|
||||
<ColumnSelectRow>
|
||||
<ColumnSelectWrapper>
|
||||
<Select
|
||||
value={selectedColumn}
|
||||
onChange={handleColumnChange}
|
||||
options={columnOptions}
|
||||
placeholder={t('Choose a column...')}
|
||||
allowClear
|
||||
showSearch
|
||||
ariaLabel={t('Select column to adjust')}
|
||||
/>
|
||||
</ColumnSelectWrapper>
|
||||
<FilterButton
|
||||
filterPopoverVisible={filterPopoverVisible}
|
||||
currentAdhocFilter={currentAdhocFilter}
|
||||
selectedColumn={selectedColumn}
|
||||
selectedDatasource={selectedDatasource}
|
||||
filterColumnOptions={filterColumnOptions}
|
||||
onOpenFilterPopover={handleOpenFilterPopover}
|
||||
onFilterPopoverVisibleChange={setFilterPopoverVisible}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterPopoverClose={handleFilterPopoverClose}
|
||||
onFilterPopoverResize={handleFilterPopoverResize}
|
||||
/>
|
||||
</ColumnSelectRow>
|
||||
{filters.length > 0 && (
|
||||
<FiltersSection>
|
||||
<Label
|
||||
css={css`
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
`}
|
||||
>
|
||||
{t('Filters')}
|
||||
</Label>
|
||||
<FilterTagsContainer>
|
||||
{filters.map((filter, index) => (
|
||||
<Tag
|
||||
key={`${filter.col}-${filter.op}-${index}`}
|
||||
closable
|
||||
onClose={e => handleRemoveFilter(e, index)}
|
||||
onClick={() => handleEditFilter(index)}
|
||||
css={css`
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{formatFilterLabel(filter)}
|
||||
</Tag>
|
||||
))}
|
||||
</FilterTagsContainer>
|
||||
</FiltersSection>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<Label>{t('Adjust value')}</Label>
|
||||
<SliderContainer>
|
||||
<Slider
|
||||
min={SLIDER_MIN}
|
||||
max={SLIDER_MAX}
|
||||
value={sliderValue}
|
||||
onChange={handleSliderChange}
|
||||
marks={SLIDER_MARKS}
|
||||
tooltip={SLIDER_TOOLTIP_CONFIG}
|
||||
/>
|
||||
</SliderContainer>
|
||||
</FormSection>
|
||||
|
||||
<CheckboxContainer>
|
||||
<Checkbox
|
||||
checked={enableCascadingEffects}
|
||||
onChange={handleCascadingEffectsChange}
|
||||
>
|
||||
{t('Show the bigger picture with AI')}
|
||||
</Checkbox>
|
||||
<Tooltip
|
||||
title={t(
|
||||
'Automatically includes related metrics and columns affected by this change. AI infers relationships based on how metrics and columns are used across the dashboard.',
|
||||
)}
|
||||
>
|
||||
<Icons.InfoCircleOutlined
|
||||
iconSize="s"
|
||||
css={css`
|
||||
color: ${theme.colorTextSecondary};
|
||||
cursor: help;
|
||||
`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</CheckboxContainer>
|
||||
|
||||
<ApplyButton
|
||||
buttonStyle="primary"
|
||||
onClick={handleApply}
|
||||
disabled={isApplyDisabled}
|
||||
loading={isLoadingSuggestions}
|
||||
>
|
||||
<Icons.StarFilled iconSize="s" />
|
||||
{isLoadingSuggestions
|
||||
? t('Analyzing relationships...')
|
||||
: t('See what if')}
|
||||
</ApplyButton>
|
||||
|
||||
<ModificationsDisplay modifications={appliedModifications} />
|
||||
|
||||
{affectedChartIds.length > 0 && (
|
||||
<WhatIfAIInsights
|
||||
key={applyCounter}
|
||||
affectedChartIds={affectedChartIds}
|
||||
modifications={aiInsightsModifications}
|
||||
abortRef={interpretAbortRef}
|
||||
/>
|
||||
)}
|
||||
</PanelContent>
|
||||
|
||||
{isLoadingSuggestions && (
|
||||
<HarryPotterWandLoader onDismiss={handleDismissLoader} />
|
||||
)}
|
||||
|
||||
<SaveSimulationModal
|
||||
show={saveModalVisible}
|
||||
onHide={handleCloseSaveModal}
|
||||
onSaved={handleSaveSimulation}
|
||||
dashboardId={dashboardId}
|
||||
modifications={appliedModifications}
|
||||
cascadingEffectsEnabled={enableCascadingEffects}
|
||||
existingSimulation={isSavingAsNew ? null : selectedSimulation}
|
||||
addSuccessToast={addSuccessToast}
|
||||
addDangerToast={addDangerToast}
|
||||
/>
|
||||
</PanelContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatIfPanel;
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { styled } from '@apache-superset/core/ui';
|
||||
import { Button } from '@superset-ui/core/components';
|
||||
import { WHAT_IF_PANEL_WIDTH } from './constants';
|
||||
|
||||
export const PanelContainer = styled.div<{
|
||||
topOffset: number;
|
||||
visible: boolean;
|
||||
}>`
|
||||
grid-column: 2;
|
||||
grid-row: 1 / -1; /* Span all rows */
|
||||
width: ${WHAT_IF_PANEL_WIDTH}px;
|
||||
min-width: ${WHAT_IF_PANEL_WIDTH}px;
|
||||
background-color: ${({ theme }) => theme.colorBgContainer};
|
||||
border-left: 1px solid ${({ theme }) => theme.colorBorderSecondary};
|
||||
display: ${({ visible }) => (visible ? 'flex' : 'none')};
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
top: ${({ topOffset }) => topOffset}px;
|
||||
height: calc(100vh - ${({ topOffset }) => topOffset}px);
|
||||
align-self: start;
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
export const PanelHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 3}px
|
||||
${({ theme }) => theme.sizeUnit * 4}px;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colorBorderSecondary};
|
||||
`;
|
||||
|
||||
export const PanelTitle = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
font-size: ${({ theme }) => theme.fontSizeLG}px;
|
||||
`;
|
||||
|
||||
export const CloseButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: ${({ theme }) => theme.sizeUnit}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.colorBgTextHover};
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
}
|
||||
`;
|
||||
|
||||
export const PanelContent = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 5}px;
|
||||
`;
|
||||
|
||||
export const FormSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
export const Label = styled.label`
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
`;
|
||||
|
||||
export const SliderContainer = styled.div`
|
||||
padding: 0 ${({ theme }) => theme.sizeUnit}px;
|
||||
& .ant-slider-mark {
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ApplyButton = styled(Button)`
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
`;
|
||||
|
||||
export const CheckboxContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
export const ModificationsSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 5}px;
|
||||
`;
|
||||
|
||||
export const ModificationTagsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
export const AIBadge = styled.span`
|
||||
font-size: 10px;
|
||||
padding: 0 4px;
|
||||
background-color: ${({ theme }) => theme.colorInfo};
|
||||
color: ${({ theme }) => theme.colorWhite};
|
||||
border-radius: 16px;
|
||||
line-height: 1.2;
|
||||
`;
|
||||
|
||||
export const AIReasoningSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
export const AIReasoningToggle = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: ${({ theme }) => theme.colorTextTertiary};
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
}
|
||||
`;
|
||||
|
||||
export const AIReasoningContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
padding-left: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
export const AIReasoningItem = styled.div`
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
`;
|
||||
|
||||
export const ColumnSelectRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
align-items: flex-start;
|
||||
`;
|
||||
|
||||
export const ColumnSelectWrapper = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
export const FilterButtonStyled = styled(Button)`
|
||||
flex-shrink: 0;
|
||||
padding: 0 ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
export const FilterPopoverContent = styled.div`
|
||||
.edit-popover-resize {
|
||||
transform: scaleX(-1);
|
||||
float: right;
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
margin-right: ${({ theme }) => theme.sizeUnit * -1}px;
|
||||
color: ${({ theme }) => theme.colorIcon};
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.filter-sql-editor {
|
||||
border: ${({ theme }) => theme.colorBorder} solid thin;
|
||||
}
|
||||
`;
|
||||
|
||||
export const FiltersSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
export const FilterTagsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
export const HeaderButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
114
superset-frontend/src/dashboard/components/WhatIfDrawer/types.ts
Normal file
114
superset-frontend/src/dashboard/components/WhatIfDrawer/types.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Import shared types for internal use
|
||||
import type {
|
||||
WhatIfFilter,
|
||||
WhatIfFilterOperator,
|
||||
WhatIfModification,
|
||||
} from 'src/dashboard/types';
|
||||
|
||||
// Re-export shared types from dashboard/types.ts
|
||||
export type { WhatIfFilter, WhatIfFilterOperator, WhatIfModification };
|
||||
|
||||
// Types specific to chart comparison display
|
||||
export interface ChartMetricComparison {
|
||||
metricName: string;
|
||||
originalValue: number;
|
||||
modifiedValue: number;
|
||||
percentageChange: number;
|
||||
}
|
||||
|
||||
export interface ChartComparison {
|
||||
chartId: number;
|
||||
chartName: string;
|
||||
chartType: string;
|
||||
metrics: ChartMetricComparison[];
|
||||
}
|
||||
|
||||
// Types for /interpret API endpoint
|
||||
export interface WhatIfInterpretRequest {
|
||||
modifications: Array<{
|
||||
column: string;
|
||||
multiplier: number;
|
||||
filters?: WhatIfFilter[];
|
||||
}>;
|
||||
charts: ChartComparison[];
|
||||
dashboardName?: string;
|
||||
}
|
||||
|
||||
export type WhatIfInsightType =
|
||||
| 'observation'
|
||||
| 'implication'
|
||||
| 'recommendation';
|
||||
|
||||
export interface WhatIfInsight {
|
||||
title: string;
|
||||
description: string;
|
||||
type: WhatIfInsightType;
|
||||
}
|
||||
|
||||
export interface WhatIfInterpretResponse {
|
||||
summary: string;
|
||||
insights: WhatIfInsight[];
|
||||
rawResponse?: string;
|
||||
}
|
||||
|
||||
export type GroupedWhatIfInsights = Record<WhatIfInsightType, WhatIfInsight[]>;
|
||||
|
||||
export type WhatIfAIStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
// Types for /suggest_related API endpoint
|
||||
export interface AvailableColumn {
|
||||
columnName: string;
|
||||
description?: string | null;
|
||||
verboseName?: string | null;
|
||||
datasourceId: number;
|
||||
}
|
||||
|
||||
export interface SuggestedModification {
|
||||
column: string;
|
||||
multiplier: number;
|
||||
reasoning: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface WhatIfSuggestRelatedRequest {
|
||||
selectedColumn: string;
|
||||
userMultiplier: number;
|
||||
availableColumns: AvailableColumn[];
|
||||
dashboardName?: string;
|
||||
}
|
||||
|
||||
export interface WhatIfSuggestRelatedResponse {
|
||||
suggestedModifications: SuggestedModification[];
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
// Extended modification type that tracks whether it came from AI
|
||||
export interface ExtendedWhatIfModification {
|
||||
column: string;
|
||||
multiplier: number;
|
||||
filters?: WhatIfFilter[];
|
||||
isAISuggested?: boolean;
|
||||
reasoning?: string;
|
||||
confidence?: 'high' | 'medium' | 'low';
|
||||
/** Human-readable column label for display */
|
||||
verboseName?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { QueryData } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/api/core';
|
||||
import {
|
||||
ActiveTabs,
|
||||
DashboardLayout,
|
||||
RootState,
|
||||
Slice,
|
||||
} from 'src/dashboard/types';
|
||||
import { ChartComparison, ChartMetricComparison } from './types';
|
||||
import { CHART_TYPE, TAB_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
|
||||
interface ChartStateWithOriginal {
|
||||
chartStatus?: string;
|
||||
queriesResponse?: QueryData[] | null;
|
||||
originalQueriesResponse?: QueryData[] | null;
|
||||
}
|
||||
|
||||
interface QueryResponse {
|
||||
data?: Array<Record<string, unknown>>;
|
||||
colnames?: string[];
|
||||
coltypes?: GenericDataType[];
|
||||
}
|
||||
|
||||
function extractMetricValue(
|
||||
data: Array<Record<string, unknown>> | undefined,
|
||||
metricName: string,
|
||||
): number | null {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
// Sum all values for the metric across rows
|
||||
let total = 0;
|
||||
let found = false;
|
||||
|
||||
for (const row of data) {
|
||||
if (metricName in row) {
|
||||
const value = row[metricName];
|
||||
if (typeof value === 'number' && !Number.isNaN(value)) {
|
||||
total += value;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found ? total : null;
|
||||
}
|
||||
|
||||
function isNumericColumn(
|
||||
data: Array<Record<string, unknown>> | undefined,
|
||||
colName: string,
|
||||
): boolean {
|
||||
if (!data || data.length === 0) return false;
|
||||
|
||||
for (const row of data) {
|
||||
if (colName in row) {
|
||||
const value = row[colName];
|
||||
return typeof value === 'number';
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a function that checks if a chart is in an active tab.
|
||||
* A chart is considered visible if:
|
||||
* 1. It has no tab parents (not inside any tab)
|
||||
* 2. All of its tab parents are in the active tabs list
|
||||
*/
|
||||
export function useIsChartInActiveTab() {
|
||||
const dashboardLayout = useSelector<RootState, DashboardLayout>(
|
||||
state => state.dashboardLayout?.present,
|
||||
);
|
||||
const activeTabs = useSelector<RootState, ActiveTabs>(
|
||||
state => state.dashboardState?.activeTabs,
|
||||
);
|
||||
|
||||
const layoutChartItems = useMemo(
|
||||
() =>
|
||||
Object.values(dashboardLayout || {}).filter(
|
||||
item => item.type === CHART_TYPE,
|
||||
),
|
||||
[dashboardLayout],
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
(chartId: number): boolean => {
|
||||
const chartLayoutItem = layoutChartItems.find(
|
||||
layoutItem => layoutItem.meta?.chartId === chartId,
|
||||
);
|
||||
const tabParents = chartLayoutItem?.parents?.filter(
|
||||
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
|
||||
);
|
||||
|
||||
// Chart is visible if it has no tab parents or all tab parents are active
|
||||
return (
|
||||
!tabParents ||
|
||||
tabParents.length === 0 ||
|
||||
tabParents.every(tab => activeTabs?.includes(tab))
|
||||
);
|
||||
},
|
||||
[dashboardLayout, layoutChartItems, activeTabs],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter chart IDs to only include those in active tabs.
|
||||
*/
|
||||
export function useChartsInActiveTabs(chartIds: number[]): number[] {
|
||||
const isChartInActiveTab = useIsChartInActiveTab();
|
||||
|
||||
return useMemo(
|
||||
() => chartIds.filter(isChartInActiveTab),
|
||||
[chartIds, isChartInActiveTab],
|
||||
);
|
||||
}
|
||||
|
||||
interface ChartComparisonData {
|
||||
chartStatus?: string;
|
||||
originalData?: Array<Record<string, unknown>>;
|
||||
modifiedData?: Array<Record<string, unknown>>;
|
||||
colnames?: string[];
|
||||
coltypes?: GenericDataType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector that extracts only the comparison-relevant data for specific chart IDs.
|
||||
* This avoids re-renders when unrelated chart data changes.
|
||||
*/
|
||||
function useChartComparisonData(
|
||||
chartIds: number[],
|
||||
): Record<number, ChartComparisonData> {
|
||||
return useSelector((state: RootState) => {
|
||||
const result: Record<number, ChartComparisonData> = {};
|
||||
for (const chartId of chartIds) {
|
||||
const chartState = state.charts[chartId] as
|
||||
| ChartStateWithOriginal
|
||||
| undefined;
|
||||
if (chartState) {
|
||||
const originalResponse = chartState.originalQueriesResponse?.[0] as
|
||||
| QueryResponse
|
||||
| undefined;
|
||||
const modifiedResponse = chartState.queriesResponse?.[0] as
|
||||
| QueryResponse
|
||||
| undefined;
|
||||
result[chartId] = {
|
||||
chartStatus: chartState.chartStatus,
|
||||
originalData: originalResponse?.data,
|
||||
modifiedData: modifiedResponse?.data,
|
||||
colnames: modifiedResponse?.colnames,
|
||||
coltypes: modifiedResponse?.coltypes,
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, shallowEqual);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector that extracts chart display names and viz types for specific chart IDs.
|
||||
* Uses sliceNameOverride from dashboard layout if available, otherwise falls back to slice_name.
|
||||
*/
|
||||
function useChartDisplayData(
|
||||
chartIds: number[],
|
||||
): Record<number, { displayName: string; viz_type: string }> {
|
||||
return useSelector((state: RootState) => {
|
||||
const slices = state.sliceEntities.slices as { [id: number]: Slice };
|
||||
const dashboardLayout = state.dashboardLayout?.present;
|
||||
const result: Record<number, { displayName: string; viz_type: string }> =
|
||||
{};
|
||||
|
||||
// Build a map of chartId -> sliceNameOverride from dashboard layout
|
||||
const nameOverrides: Record<number, string | undefined> = {};
|
||||
if (dashboardLayout) {
|
||||
for (const item of Object.values(dashboardLayout)) {
|
||||
if (item.type === CHART_TYPE && item.meta?.chartId) {
|
||||
nameOverrides[item.meta.chartId] = item.meta.sliceNameOverride;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const chartId of chartIds) {
|
||||
const slice = slices[chartId];
|
||||
if (slice) {
|
||||
result[chartId] = {
|
||||
displayName: nameOverrides[chartId] || slice.slice_name,
|
||||
viz_type: slice.viz_type,
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, shallowEqual);
|
||||
}
|
||||
|
||||
export function useChartComparison(
|
||||
affectedChartIds: number[],
|
||||
): ChartComparison[] {
|
||||
const visibleChartIds = useChartsInActiveTabs(affectedChartIds);
|
||||
const chartData = useChartComparisonData(visibleChartIds);
|
||||
const chartDisplayData = useChartDisplayData(visibleChartIds);
|
||||
|
||||
return useMemo(() => {
|
||||
const comparisons: ChartComparison[] = [];
|
||||
|
||||
for (const chartId of visibleChartIds) {
|
||||
const chartState = chartData[chartId];
|
||||
const displayData = chartDisplayData[chartId];
|
||||
|
||||
if (!chartState || !displayData) continue;
|
||||
|
||||
const { originalData, modifiedData } = chartState;
|
||||
|
||||
if (!originalData || !modifiedData) continue;
|
||||
|
||||
// Skip if original and modified data are the same reference
|
||||
// This indicates the what-if query hasn't completed yet (race condition guard)
|
||||
if (originalData === modifiedData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get column names and types from the response
|
||||
const colnames = chartState.colnames || [];
|
||||
const coltypes = chartState.coltypes || [];
|
||||
const metrics: ChartMetricComparison[] = [];
|
||||
|
||||
for (let i = 0; i < colnames.length; i += 1) {
|
||||
const metricName = colnames[i];
|
||||
const coltype = coltypes[i];
|
||||
|
||||
// Only include numeric columns (not temporal/date, string, or boolean)
|
||||
// This filters out x-axis date columns and dimension columns
|
||||
// If coltypes is available, use it; otherwise fall back to runtime check
|
||||
if (coltype !== undefined && coltype !== GenericDataType.Numeric) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Runtime check: verify the column actually contains numeric values
|
||||
// This also catches cases where coltypes is not available
|
||||
if (!isNumericColumn(modifiedData, metricName)) continue;
|
||||
|
||||
const originalValue = extractMetricValue(originalData, metricName);
|
||||
const modifiedValue = extractMetricValue(modifiedData, metricName);
|
||||
|
||||
if (
|
||||
originalValue !== null &&
|
||||
modifiedValue !== null &&
|
||||
originalValue !== 0
|
||||
) {
|
||||
const percentageChange =
|
||||
((modifiedValue - originalValue) / Math.abs(originalValue)) * 100;
|
||||
|
||||
metrics.push({
|
||||
metricName,
|
||||
originalValue,
|
||||
modifiedValue,
|
||||
percentageChange,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (metrics.length > 0) {
|
||||
comparisons.push({
|
||||
chartId,
|
||||
chartName: displayData.displayName,
|
||||
chartType: displayData.viz_type,
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return comparisons;
|
||||
}, [chartData, chartDisplayData, visibleChartIds]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector that extracts only loading statuses for specific chart IDs.
|
||||
*/
|
||||
function useChartLoadingStatuses(
|
||||
chartIds: number[],
|
||||
): Record<number, string | undefined> {
|
||||
return useSelector((state: RootState) => {
|
||||
const result: Record<number, string | undefined> = {};
|
||||
for (const chartId of chartIds) {
|
||||
const chartState = state.charts[chartId] as
|
||||
| ChartStateWithOriginal
|
||||
| undefined;
|
||||
result[chartId] = chartState?.chartStatus;
|
||||
}
|
||||
return result;
|
||||
}, shallowEqual);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all affected charts (in active tabs) have finished loading.
|
||||
* Returns true only if ALL visible charts are in a definitive complete state
|
||||
* ('success' or 'rendered'). This prevents race conditions where charts
|
||||
* might briefly be in an intermediate state.
|
||||
*/
|
||||
export function useAllChartsLoaded(chartIds: number[]): boolean {
|
||||
const visibleChartIds = useChartsInActiveTabs(chartIds);
|
||||
const chartStatuses = useChartLoadingStatuses(visibleChartIds);
|
||||
|
||||
return useMemo(() => {
|
||||
// Require explicit completion status, not just "not loading"
|
||||
// This prevents race conditions during state transitions
|
||||
// Include 'failed' to avoid waiting indefinitely for charts that errored
|
||||
const completeStatuses = ['success', 'rendered', 'failed'];
|
||||
return (
|
||||
visibleChartIds.length > 0 &&
|
||||
visibleChartIds.every(id =>
|
||||
completeStatuses.includes(chartStatuses[id] ?? ''),
|
||||
)
|
||||
);
|
||||
}, [chartStatuses, visibleChartIds]);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { logging } from '@superset-ui/core';
|
||||
import { setWhatIfModifications } from 'src/dashboard/actions/dashboardState';
|
||||
import {
|
||||
triggerQuery,
|
||||
saveOriginalChartData,
|
||||
} from 'src/components/Chart/chartAction';
|
||||
import { RootState, WhatIfFilter } from 'src/dashboard/types';
|
||||
import { useNumericColumns } from 'src/dashboard/util/useNumericColumns';
|
||||
import { fetchRelatedColumnSuggestions } from './whatIfApi';
|
||||
import { ExtendedWhatIfModification, WhatIfModification } from './types';
|
||||
|
||||
export interface UseWhatIfApplyParams {
|
||||
selectedColumn: string | undefined;
|
||||
sliderValue: number;
|
||||
filters: WhatIfFilter[];
|
||||
enableCascadingEffects: boolean;
|
||||
}
|
||||
|
||||
export interface UseWhatIfApplyReturn {
|
||||
appliedModifications: ExtendedWhatIfModification[];
|
||||
affectedChartIds: number[];
|
||||
isLoadingSuggestions: boolean;
|
||||
applyCounter: number;
|
||||
handleApply: () => Promise<void>;
|
||||
handleDismissLoader: () => void;
|
||||
aiInsightsModifications: WhatIfModification[];
|
||||
loadModificationsDirectly: (
|
||||
modifications: ExtendedWhatIfModification[],
|
||||
) => void;
|
||||
clearModifications: () => void;
|
||||
/** Ref to register an abort function from WhatIfAIInsights */
|
||||
interpretAbortRef: React.MutableRefObject<(() => void) | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing what-if apply logic and modifications state.
|
||||
* Handles:
|
||||
* - Applied modifications tracking
|
||||
* - AI suggestions fetching with cascading effects
|
||||
* - Redux dispatching for what-if state
|
||||
* - Chart query triggering
|
||||
*/
|
||||
export function useWhatIfApply({
|
||||
selectedColumn,
|
||||
sliderValue,
|
||||
filters,
|
||||
enableCascadingEffects,
|
||||
}: UseWhatIfApplyParams): UseWhatIfApplyReturn {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [appliedModifications, setAppliedModifications] = useState<
|
||||
ExtendedWhatIfModification[]
|
||||
>([]);
|
||||
const [affectedChartIds, setAffectedChartIds] = useState<number[]>([]);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
// Counter that increments each time Apply is clicked, used as key to reset AI insights
|
||||
const [applyCounter, setApplyCounter] = useState(0);
|
||||
|
||||
// AbortController for cancelling in-flight /suggest_related requests
|
||||
const suggestionsAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Ref to hold the abort function from WhatIfAIInsights for /interpret requests
|
||||
const interpretAbortRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const { numericColumns, columnToChartIds } = useNumericColumns();
|
||||
const dashboardInfo = useSelector((state: RootState) => state.dashboardInfo);
|
||||
|
||||
// Cleanup: cancel any pending requests on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
suggestionsAbortControllerRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!selectedColumn) return;
|
||||
|
||||
// Cancel any in-flight requests
|
||||
suggestionsAbortControllerRef.current?.abort();
|
||||
interpretAbortRef.current?.();
|
||||
|
||||
// Immediately clear previous results and increment counter to reset AI insights component
|
||||
setAppliedModifications([]);
|
||||
setAffectedChartIds([]);
|
||||
setApplyCounter(c => c + 1);
|
||||
|
||||
const multiplier = 1 + sliderValue / 100;
|
||||
|
||||
// Find verbose name for the selected column
|
||||
const selectedColumnInfo = numericColumns.find(
|
||||
col => col.columnName === selectedColumn,
|
||||
);
|
||||
|
||||
// Base user modification with filters
|
||||
const userModification: ExtendedWhatIfModification = {
|
||||
column: selectedColumn,
|
||||
multiplier,
|
||||
isAISuggested: false,
|
||||
filters: filters.length > 0 ? filters : undefined,
|
||||
verboseName: selectedColumnInfo?.verboseName,
|
||||
};
|
||||
|
||||
let allModifications: ExtendedWhatIfModification[] = [userModification];
|
||||
|
||||
// If cascading effects enabled, fetch AI suggestions
|
||||
if (enableCascadingEffects) {
|
||||
// Create a new AbortController for this request
|
||||
const abortController = new AbortController();
|
||||
suggestionsAbortControllerRef.current = abortController;
|
||||
|
||||
setIsLoadingSuggestions(true);
|
||||
try {
|
||||
const suggestions = await fetchRelatedColumnSuggestions(
|
||||
{
|
||||
selectedColumn,
|
||||
userMultiplier: multiplier,
|
||||
availableColumns: numericColumns.map(col => ({
|
||||
columnName: col.columnName,
|
||||
description: col.description,
|
||||
verboseName: col.verboseName,
|
||||
datasourceId: col.datasourceId,
|
||||
})),
|
||||
dashboardName: dashboardInfo?.dash_edit_perm
|
||||
? dashboardInfo?.dashboard_title
|
||||
: undefined,
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// Add AI suggestions to modifications (with same filters as user modification)
|
||||
const aiModifications: ExtendedWhatIfModification[] =
|
||||
suggestions.suggestedModifications.map(mod => {
|
||||
const colInfo = numericColumns.find(
|
||||
col => col.columnName === mod.column,
|
||||
);
|
||||
return {
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
isAISuggested: true,
|
||||
reasoning: mod.reasoning,
|
||||
confidence: mod.confidence,
|
||||
filters: filters.length > 0 ? filters : undefined,
|
||||
verboseName: colInfo?.verboseName,
|
||||
};
|
||||
});
|
||||
|
||||
allModifications = [...allModifications, ...aiModifications];
|
||||
} catch (error) {
|
||||
// Don't log or update state if the request was aborted
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logging.error('Failed to get AI suggestions:', error);
|
||||
// Continue with just user modification
|
||||
}
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
|
||||
setAppliedModifications(allModifications);
|
||||
|
||||
// Collect all affected chart IDs from all modifications
|
||||
const allAffectedChartIds = new Set<number>();
|
||||
allModifications.forEach(mod => {
|
||||
const chartIds = columnToChartIds.get(mod.column) || [];
|
||||
chartIds.forEach(id => allAffectedChartIds.add(id));
|
||||
});
|
||||
const chartIdsArray = Array.from(allAffectedChartIds);
|
||||
|
||||
// Save original chart data before applying what-if modifications
|
||||
chartIdsArray.forEach(chartId => {
|
||||
dispatch(saveOriginalChartData(chartId));
|
||||
});
|
||||
|
||||
// Set the what-if modifications in Redux state (all modifications)
|
||||
dispatch(
|
||||
setWhatIfModifications(
|
||||
allModifications.map(mod => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
filters: mod.filters,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
// Trigger queries for all affected charts
|
||||
// This sets chart status to 'loading', which is important for AI insights timing
|
||||
chartIdsArray.forEach(chartId => {
|
||||
dispatch(triggerQuery(true, chartId));
|
||||
});
|
||||
|
||||
// Set affected chart IDs AFTER Redux updates and query triggers
|
||||
// This ensures WhatIfAIInsights mounts when charts are already loading,
|
||||
// preventing it from immediately fetching with stale data
|
||||
setAffectedChartIds(chartIdsArray);
|
||||
}, [
|
||||
dispatch,
|
||||
selectedColumn,
|
||||
sliderValue,
|
||||
columnToChartIds,
|
||||
enableCascadingEffects,
|
||||
numericColumns,
|
||||
dashboardInfo,
|
||||
filters,
|
||||
]);
|
||||
|
||||
const handleDismissLoader = useCallback(() => {
|
||||
suggestionsAbortControllerRef.current?.abort();
|
||||
setIsLoadingSuggestions(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Load modifications directly without fetching AI suggestions.
|
||||
* Used when loading a saved simulation.
|
||||
*/
|
||||
const loadModificationsDirectly = useCallback(
|
||||
(modifications: ExtendedWhatIfModification[]) => {
|
||||
// Cancel any in-flight requests
|
||||
suggestionsAbortControllerRef.current?.abort();
|
||||
interpretAbortRef.current?.();
|
||||
setIsLoadingSuggestions(false);
|
||||
|
||||
// Increment counter to reset AI insights component
|
||||
setApplyCounter(c => c + 1);
|
||||
|
||||
// Populate verbose names for loaded modifications
|
||||
const modificationsWithVerboseNames = modifications.map(mod => {
|
||||
const colInfo = numericColumns.find(
|
||||
col => col.columnName === mod.column,
|
||||
);
|
||||
return {
|
||||
...mod,
|
||||
verboseName: mod.verboseName || colInfo?.verboseName,
|
||||
};
|
||||
});
|
||||
|
||||
setAppliedModifications(modificationsWithVerboseNames);
|
||||
|
||||
// Collect all affected chart IDs from all modifications
|
||||
const allAffectedChartIds = new Set<number>();
|
||||
modifications.forEach(mod => {
|
||||
const chartIds = columnToChartIds.get(mod.column) || [];
|
||||
chartIds.forEach(id => allAffectedChartIds.add(id));
|
||||
});
|
||||
const chartIdsArray = Array.from(allAffectedChartIds);
|
||||
|
||||
// Save original chart data before applying what-if modifications
|
||||
chartIdsArray.forEach(chartId => {
|
||||
dispatch(saveOriginalChartData(chartId));
|
||||
});
|
||||
|
||||
// Set the what-if modifications in Redux state
|
||||
dispatch(
|
||||
setWhatIfModifications(
|
||||
modificationsWithVerboseNames.map(mod => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
filters: mod.filters,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
// Trigger queries for all affected charts
|
||||
chartIdsArray.forEach(chartId => {
|
||||
dispatch(triggerQuery(true, chartId));
|
||||
});
|
||||
|
||||
// Set affected chart IDs to enable AI insights
|
||||
setAffectedChartIds(chartIdsArray);
|
||||
},
|
||||
[dispatch, columnToChartIds, numericColumns],
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear all modifications and reset state.
|
||||
*/
|
||||
const clearModifications = useCallback(() => {
|
||||
// Cancel any in-flight requests
|
||||
suggestionsAbortControllerRef.current?.abort();
|
||||
interpretAbortRef.current?.();
|
||||
setIsLoadingSuggestions(false);
|
||||
|
||||
setAppliedModifications([]);
|
||||
setAffectedChartIds([]);
|
||||
dispatch(setWhatIfModifications([]));
|
||||
}, [dispatch]);
|
||||
|
||||
// Memoize modifications array for WhatIfAIInsights to prevent unnecessary re-renders
|
||||
const aiInsightsModifications = useMemo(
|
||||
() =>
|
||||
appliedModifications.map(mod => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
filters: mod.filters,
|
||||
})),
|
||||
[appliedModifications],
|
||||
);
|
||||
|
||||
return {
|
||||
appliedModifications,
|
||||
affectedChartIds,
|
||||
isLoadingSuggestions,
|
||||
applyCounter,
|
||||
handleApply,
|
||||
handleDismissLoader,
|
||||
aiInsightsModifications,
|
||||
loadModificationsDirectly,
|
||||
clearModifications,
|
||||
interpretAbortRef,
|
||||
};
|
||||
}
|
||||
|
||||
export default useWhatIfApply;
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { formatTimeRangeLabel } from '@superset-ui/core';
|
||||
import { WhatIfFilter } from 'src/dashboard/types';
|
||||
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
|
||||
import { Clauses } from 'src/explore/components/controls/FilterControl/types';
|
||||
import { OPERATOR_ENUM_TO_OPERATOR_TYPE } from 'src/explore/constants';
|
||||
|
||||
export interface UseWhatIfFiltersReturn {
|
||||
filters: WhatIfFilter[];
|
||||
filterPopoverVisible: boolean;
|
||||
editingFilterIndex: number | null;
|
||||
currentAdhocFilter: AdhocFilter | null;
|
||||
setFilterPopoverVisible: (visible: boolean) => void;
|
||||
setFilters: (filters: WhatIfFilter[]) => void;
|
||||
handleOpenFilterPopover: () => void;
|
||||
handleEditFilter: (index: number) => void;
|
||||
handleFilterChange: (adhocFilter: AdhocFilter) => void;
|
||||
handleRemoveFilter: (e: React.MouseEvent, index: number) => void;
|
||||
handleFilterPopoverClose: () => void;
|
||||
handleFilterPopoverResize: () => void;
|
||||
clearFilters: () => void;
|
||||
formatFilterLabel: (filter: WhatIfFilter) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing what-if filter state and operations.
|
||||
* Encapsulates all filter-related logic including:
|
||||
* - Filter CRUD operations
|
||||
* - AdhocFilter <-> WhatIfFilter conversions
|
||||
* - Popover state management
|
||||
* - Filter label formatting
|
||||
*/
|
||||
export function useWhatIfFilters(): UseWhatIfFiltersReturn {
|
||||
const [filters, setFilters] = useState<WhatIfFilter[]>([]);
|
||||
const [filterPopoverVisible, setFilterPopoverVisible] = useState(false);
|
||||
const [editingFilterIndex, setEditingFilterIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [currentAdhocFilter, setCurrentAdhocFilter] =
|
||||
useState<AdhocFilter | null>(null);
|
||||
|
||||
// Convert AdhocFilter to WhatIfFilter
|
||||
const adhocFilterToWhatIfFilter = useCallback(
|
||||
(adhocFilter: AdhocFilter): WhatIfFilter | null => {
|
||||
if (!adhocFilter.isValid()) return null;
|
||||
|
||||
const { subject, operator, comparator } = adhocFilter;
|
||||
if (!subject || !operator) return null;
|
||||
|
||||
// Map operator to WhatIfFilterOperator
|
||||
let op = operator as WhatIfFilter['op'];
|
||||
|
||||
// Handle operator mapping
|
||||
if (operator === 'TEMPORAL_RANGE') {
|
||||
op = 'TEMPORAL_RANGE';
|
||||
} else if (operator === 'IN' || operator === 'in') {
|
||||
op = 'IN';
|
||||
} else if (operator === 'NOT IN' || operator === 'not in') {
|
||||
op = 'NOT IN';
|
||||
}
|
||||
|
||||
return {
|
||||
col: subject,
|
||||
op,
|
||||
val: comparator,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Convert WhatIfFilter to AdhocFilter for editing
|
||||
const whatIfFilterToAdhocFilter = useCallback(
|
||||
(filter: WhatIfFilter): AdhocFilter => {
|
||||
// Find the operatorId from the operator
|
||||
let operatorId: string | undefined;
|
||||
for (const [key, value] of Object.entries(
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
||||
)) {
|
||||
if (value.operation === filter.op) {
|
||||
operatorId = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new AdhocFilter({
|
||||
expressionType: 'SIMPLE',
|
||||
subject: filter.col,
|
||||
operator: filter.op,
|
||||
operatorId,
|
||||
comparator: filter.val,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenFilterPopover = useCallback(() => {
|
||||
// Create a new empty AdhocFilter
|
||||
const newFilter = new AdhocFilter({
|
||||
expressionType: 'SIMPLE',
|
||||
clause: Clauses.Where,
|
||||
subject: null,
|
||||
operator: null,
|
||||
comparator: null,
|
||||
isNew: true,
|
||||
});
|
||||
setCurrentAdhocFilter(newFilter);
|
||||
setEditingFilterIndex(null);
|
||||
setFilterPopoverVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleEditFilter = useCallback(
|
||||
(index: number) => {
|
||||
const filter = filters[index];
|
||||
const adhocFilter = whatIfFilterToAdhocFilter(filter);
|
||||
setCurrentAdhocFilter(adhocFilter);
|
||||
setEditingFilterIndex(index);
|
||||
setFilterPopoverVisible(true);
|
||||
},
|
||||
[filters, whatIfFilterToAdhocFilter],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(adhocFilter: AdhocFilter) => {
|
||||
const whatIfFilter = adhocFilterToWhatIfFilter(adhocFilter);
|
||||
if (!whatIfFilter) return;
|
||||
|
||||
setFilters(prevFilters => {
|
||||
if (editingFilterIndex !== null) {
|
||||
// Update existing filter
|
||||
const newFilters = [...prevFilters];
|
||||
newFilters[editingFilterIndex] = whatIfFilter;
|
||||
return newFilters;
|
||||
}
|
||||
// Add new filter
|
||||
return [...prevFilters, whatIfFilter];
|
||||
});
|
||||
setFilterPopoverVisible(false);
|
||||
setCurrentAdhocFilter(null);
|
||||
setEditingFilterIndex(null);
|
||||
},
|
||||
[adhocFilterToWhatIfFilter, editingFilterIndex],
|
||||
);
|
||||
|
||||
const handleRemoveFilter = useCallback(
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFilters(prevFilters => prevFilters.filter((_, i) => i !== index));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFilterPopoverClose = useCallback(() => {
|
||||
setFilterPopoverVisible(false);
|
||||
setCurrentAdhocFilter(null);
|
||||
setEditingFilterIndex(null);
|
||||
}, []);
|
||||
|
||||
// Intentionally empty: AdhocFilterEditPopover requires an onResize callback,
|
||||
// but we don't need dynamic resizing in this fixed-width panel context.
|
||||
const handleFilterPopoverResize = useCallback(() => {}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters([]);
|
||||
}, []);
|
||||
|
||||
// Helper to format filter for display (matching Explore filter label format)
|
||||
const formatFilterLabel = useCallback((filter: WhatIfFilter): string => {
|
||||
const { col, op, val } = filter;
|
||||
|
||||
// Special handling for TEMPORAL_RANGE to match Explore format
|
||||
if (op === 'TEMPORAL_RANGE' && typeof val === 'string') {
|
||||
return formatTimeRangeLabel(val, col);
|
||||
}
|
||||
|
||||
let valStr: string;
|
||||
if (Array.isArray(val)) {
|
||||
valStr = val.join(', ');
|
||||
} else if (typeof val === 'boolean') {
|
||||
valStr = val ? 'true' : 'false';
|
||||
} else {
|
||||
valStr = String(val);
|
||||
}
|
||||
// Truncate long values
|
||||
if (valStr.length > 20) {
|
||||
valStr = `${valStr.substring(0, 17)}...`;
|
||||
}
|
||||
return `${col} ${op} ${valStr}`;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
filters,
|
||||
filterPopoverVisible,
|
||||
editingFilterIndex,
|
||||
currentAdhocFilter,
|
||||
setFilterPopoverVisible,
|
||||
setFilters,
|
||||
handleOpenFilterPopover,
|
||||
handleEditFilter,
|
||||
handleFilterChange,
|
||||
handleRemoveFilter,
|
||||
handleFilterPopoverClose,
|
||||
handleFilterPopoverResize,
|
||||
clearFilters,
|
||||
formatFilterLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export default useWhatIfFilters;
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { SupersetClient, Signal } from '@superset-ui/core';
|
||||
import {
|
||||
WhatIfInterpretRequest,
|
||||
WhatIfInterpretResponse,
|
||||
ChartComparison,
|
||||
WhatIfFilter,
|
||||
WhatIfModification,
|
||||
WhatIfSuggestRelatedRequest,
|
||||
WhatIfSuggestRelatedResponse,
|
||||
SuggestedModification,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Simulation CRUD Types
|
||||
// =============================================================================
|
||||
|
||||
export interface WhatIfSimulation {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
dashboardId: number;
|
||||
modifications: WhatIfModification[];
|
||||
cascadingEffectsEnabled: boolean;
|
||||
createdOn?: string | null;
|
||||
changedOn?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateSimulationRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
dashboardId: number;
|
||||
modifications: WhatIfModification[];
|
||||
cascadingEffectsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateSimulationRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
modifications?: WhatIfModification[];
|
||||
cascadingEffectsEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
result: {
|
||||
summary: string;
|
||||
insights: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
type: string;
|
||||
}>;
|
||||
raw_response?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWhatIfInterpretation(
|
||||
request: WhatIfInterpretRequest,
|
||||
signal?: Signal,
|
||||
): Promise<WhatIfInterpretResponse> {
|
||||
const response = await SupersetClient.post({
|
||||
endpoint: '/api/v1/what_if/interpret',
|
||||
signal,
|
||||
jsonPayload: {
|
||||
modifications: request.modifications.map(mod => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
...(mod.filters && mod.filters.length > 0
|
||||
? {
|
||||
filters: mod.filters.map((f: WhatIfFilter) => ({
|
||||
col: f.col,
|
||||
op: f.op,
|
||||
val: f.val,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
charts: request.charts.map((chart: ChartComparison) => ({
|
||||
chart_id: chart.chartId,
|
||||
chart_name: chart.chartName,
|
||||
chart_type: chart.chartType,
|
||||
metrics: chart.metrics.map(m => ({
|
||||
metric_name: m.metricName,
|
||||
original_value: m.originalValue,
|
||||
modified_value: m.modifiedValue,
|
||||
percentage_change: m.percentageChange,
|
||||
})),
|
||||
})),
|
||||
dashboard_name: request.dashboardName,
|
||||
},
|
||||
});
|
||||
|
||||
const data = response.json as ApiResponse;
|
||||
const { result } = data;
|
||||
|
||||
return {
|
||||
summary: result.summary,
|
||||
insights: result.insights.map(insight => ({
|
||||
title: insight.title,
|
||||
description: insight.description,
|
||||
type: insight.type as 'observation' | 'implication' | 'recommendation',
|
||||
})),
|
||||
rawResponse: result.raw_response,
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiSuggestRelatedResponse {
|
||||
result: {
|
||||
suggested_modifications: Array<{
|
||||
column: string;
|
||||
multiplier: number;
|
||||
reasoning: string;
|
||||
confidence: string;
|
||||
}>;
|
||||
explanation?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRelatedColumnSuggestions(
|
||||
request: WhatIfSuggestRelatedRequest,
|
||||
signal?: Signal,
|
||||
): Promise<WhatIfSuggestRelatedResponse> {
|
||||
const response = await SupersetClient.post({
|
||||
endpoint: '/api/v1/what_if/suggest_related',
|
||||
signal,
|
||||
jsonPayload: {
|
||||
selected_column: request.selectedColumn,
|
||||
user_multiplier: request.userMultiplier,
|
||||
available_columns: request.availableColumns.map(col => ({
|
||||
column_name: col.columnName,
|
||||
description: col.description,
|
||||
verbose_name: col.verboseName,
|
||||
datasource_id: col.datasourceId,
|
||||
})),
|
||||
dashboard_name: request.dashboardName,
|
||||
},
|
||||
});
|
||||
|
||||
const data = response.json as ApiSuggestRelatedResponse;
|
||||
const { result } = data;
|
||||
|
||||
return {
|
||||
suggestedModifications: result.suggested_modifications.map(
|
||||
(mod): SuggestedModification => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
reasoning: mod.reasoning,
|
||||
confidence: mod.confidence as 'high' | 'medium' | 'low',
|
||||
}),
|
||||
),
|
||||
explanation: result.explanation,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Simulation CRUD API Functions
|
||||
// =============================================================================
|
||||
|
||||
interface SimulationListResponse {
|
||||
result: Array<{
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
dashboard_id?: number;
|
||||
modifications: Array<{
|
||||
column: string;
|
||||
multiplier: number;
|
||||
filters?: Array<{
|
||||
col: string;
|
||||
op: string;
|
||||
val: string | number | boolean | Array<string | number>;
|
||||
}>;
|
||||
}>;
|
||||
cascading_effects_enabled: boolean;
|
||||
created_on?: string | null;
|
||||
changed_on?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SimulationCreateResponse {
|
||||
id: number;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export async function fetchAllSimulations(): Promise<WhatIfSimulation[]> {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: '/api/v1/what_if/simulations',
|
||||
});
|
||||
|
||||
const data = response.json as SimulationListResponse;
|
||||
return data.result.map(sim => ({
|
||||
id: sim.id,
|
||||
uuid: sim.uuid,
|
||||
name: sim.name,
|
||||
description: sim.description,
|
||||
dashboardId: sim.dashboard_id ?? 0,
|
||||
modifications: sim.modifications.map(mod => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
filters: mod.filters?.map(f => ({
|
||||
col: f.col,
|
||||
op: f.op as WhatIfFilter['op'],
|
||||
val: f.val,
|
||||
})),
|
||||
})),
|
||||
cascadingEffectsEnabled: sim.cascading_effects_enabled,
|
||||
createdOn: sim.created_on,
|
||||
changedOn: sim.changed_on,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchSimulations(
|
||||
dashboardId: number,
|
||||
): Promise<WhatIfSimulation[]> {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/what_if/simulations/dashboard/${dashboardId}`,
|
||||
});
|
||||
|
||||
const data = response.json as SimulationListResponse;
|
||||
return data.result.map(sim => ({
|
||||
id: sim.id,
|
||||
uuid: sim.uuid,
|
||||
name: sim.name,
|
||||
description: sim.description,
|
||||
dashboardId,
|
||||
modifications: sim.modifications.map(mod => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
filters: mod.filters?.map(f => ({
|
||||
col: f.col,
|
||||
op: f.op as WhatIfFilter['op'],
|
||||
val: f.val,
|
||||
})),
|
||||
})),
|
||||
cascadingEffectsEnabled: sim.cascading_effects_enabled,
|
||||
createdOn: sim.created_on,
|
||||
changedOn: sim.changed_on,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createSimulation(
|
||||
request: CreateSimulationRequest,
|
||||
): Promise<WhatIfSimulation> {
|
||||
const response = await SupersetClient.post({
|
||||
endpoint: '/api/v1/what_if/simulations',
|
||||
jsonPayload: {
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
dashboard_id: request.dashboardId,
|
||||
modifications: request.modifications.map(mod => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
filters: mod.filters?.map(f => ({
|
||||
col: f.col,
|
||||
op: f.op,
|
||||
val: f.val,
|
||||
})),
|
||||
})),
|
||||
cascading_effects_enabled: request.cascadingEffectsEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
const data = response.json as SimulationCreateResponse;
|
||||
return {
|
||||
id: data.id,
|
||||
uuid: data.uuid,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
dashboardId: request.dashboardId,
|
||||
modifications: request.modifications,
|
||||
cascadingEffectsEnabled: request.cascadingEffectsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateSimulation(
|
||||
simulationId: number,
|
||||
request: UpdateSimulationRequest,
|
||||
): Promise<void> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (request.name !== undefined) payload.name = request.name;
|
||||
if (request.description !== undefined)
|
||||
payload.description = request.description;
|
||||
if (request.cascadingEffectsEnabled !== undefined) {
|
||||
payload.cascading_effects_enabled = request.cascadingEffectsEnabled;
|
||||
}
|
||||
if (request.modifications !== undefined) {
|
||||
payload.modifications = request.modifications.map(mod => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
filters: mod.filters?.map(f => ({
|
||||
col: f.col,
|
||||
op: f.op,
|
||||
val: f.val,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/what_if/simulations/${simulationId}`,
|
||||
jsonPayload: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSimulation(simulationId: number): Promise<void> {
|
||||
await SupersetClient.delete({
|
||||
endpoint: `/api/v1/what_if/simulations/${simulationId}`,
|
||||
});
|
||||
}
|
||||
@@ -98,13 +98,6 @@ const ChartWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const ChartOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
`;
|
||||
|
||||
const SliceContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -367,6 +360,9 @@ const Chart = props => {
|
||||
state.dashboardInfo?.metadata?.shared_label_colors,
|
||||
),
|
||||
);
|
||||
const whatIfModifications = useSelector(
|
||||
state => state.dashboardState.whatIfModifications || EMPTY_ARRAY,
|
||||
);
|
||||
|
||||
const formData = useMemo(
|
||||
() =>
|
||||
@@ -386,6 +382,7 @@ const Chart = props => {
|
||||
labelsColorMap,
|
||||
sharedLabelsColors,
|
||||
ownColorScheme,
|
||||
whatIfModifications,
|
||||
}),
|
||||
[
|
||||
chart.id,
|
||||
@@ -403,6 +400,7 @@ const Chart = props => {
|
||||
labelsColorMap,
|
||||
sharedLabelsColors,
|
||||
ownColorScheme,
|
||||
whatIfModifications,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -410,9 +408,13 @@ const Chart = props => {
|
||||
|
||||
const ownState = useMemo(() => {
|
||||
const baseOwnState = dataMask[props.id]?.ownState || EMPTY_OBJECT;
|
||||
// Create a chartState-like object that includes state from chartState or formData fallbacks
|
||||
const chartStateForConversion = {
|
||||
state: getChartStateWithFallback(chartState, formData, slice.viz_type),
|
||||
};
|
||||
return createOwnStateWithChartState(
|
||||
baseOwnState,
|
||||
chartState,
|
||||
chartStateForConversion,
|
||||
slice.viz_type,
|
||||
);
|
||||
}, [
|
||||
@@ -420,6 +422,7 @@ const Chart = props => {
|
||||
props.id,
|
||||
slice.viz_type,
|
||||
chartState?.state,
|
||||
formData,
|
||||
]);
|
||||
|
||||
const onExploreChart = useCallback(
|
||||
@@ -596,7 +599,6 @@ const Chart = props => {
|
||||
return <MissingChart height={getChartHeight()} />;
|
||||
}
|
||||
|
||||
const isLoading = chartStatus === 'loading';
|
||||
const cachedDttm =
|
||||
// eslint-disable-next-line camelcase
|
||||
queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
|
||||
@@ -668,15 +670,6 @@ const Chart = props => {
|
||||
className={cx('dashboard-chart')}
|
||||
aria-label={slice.description}
|
||||
>
|
||||
{isLoading && (
|
||||
<ChartOverlay
|
||||
style={{
|
||||
width,
|
||||
height: getChartHeight(),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChartContainer
|
||||
width={width}
|
||||
height={getChartHeight()}
|
||||
@@ -693,17 +686,7 @@ const Chart = props => {
|
||||
formData={formData}
|
||||
labelsColor={labelsColor}
|
||||
labelsColorMap={labelsColorMap}
|
||||
ownState={createOwnStateWithChartState(
|
||||
dataMask[props.id]?.ownState || EMPTY_OBJECT,
|
||||
{
|
||||
state: getChartStateWithFallback(
|
||||
chartState,
|
||||
formData,
|
||||
slice.viz_type,
|
||||
),
|
||||
},
|
||||
slice.viz_type,
|
||||
)}
|
||||
ownState={ownState}
|
||||
filterState={dataMask[props.id]?.filterState}
|
||||
queriesResponse={chart.queriesResponse}
|
||||
timeout={timeout}
|
||||
|
||||
@@ -31,6 +31,7 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
|
||||
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
|
||||
import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath';
|
||||
import useFilterFocusHighlightStyles from 'src/dashboard/util/useFilterFocusHighlightStyles';
|
||||
import useWhatIfHighlightStyles from 'src/dashboard/util/useWhatIfHighlightStyles';
|
||||
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
import {
|
||||
GRID_BASE_UNIT,
|
||||
@@ -107,6 +108,7 @@ const ChartHolder = ({
|
||||
const isFullSize = fullSizeChartId === chartId;
|
||||
|
||||
const focusHighlightStyles = useFilterFocusHighlightStyles(chartId);
|
||||
const whatIfHighlightStyles = useWhatIfHighlightStyles(chartId);
|
||||
const directPathToChild = useSelector(
|
||||
(state: RootState) => state.dashboardState.directPathToChild,
|
||||
);
|
||||
@@ -260,7 +262,7 @@ const ChartHolder = ({
|
||||
ref={dragSourceRef}
|
||||
data-test="dashboard-component-chart-holder"
|
||||
style={focusHighlightStyles}
|
||||
css={isFullSize ? fullSizeStyle : undefined}
|
||||
css={[isFullSize && fullSizeStyle, whatIfHighlightStyles]}
|
||||
className={cx(
|
||||
'dashboard-component',
|
||||
'dashboard-component-chart-holder',
|
||||
@@ -325,6 +327,7 @@ const ChartHolder = ({
|
||||
onResizeStop,
|
||||
editMode,
|
||||
focusHighlightStyles,
|
||||
whatIfHighlightStyles,
|
||||
isFullSize,
|
||||
fullSizeStyle,
|
||||
chartId,
|
||||
|
||||
@@ -54,6 +54,9 @@ import {
|
||||
REMOVE_CHART_STATE,
|
||||
RESTORE_CHART_STATES,
|
||||
CLEAR_ALL_CHART_STATES,
|
||||
SET_WHAT_IF_MODIFICATIONS,
|
||||
CLEAR_WHAT_IF_MODIFICATIONS,
|
||||
TOGGLE_WHAT_IF_PANEL,
|
||||
} from '../actions/dashboardState';
|
||||
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
||||
|
||||
@@ -333,6 +336,24 @@ export default function dashboardStateReducer(state = {}, action) {
|
||||
chartStates: {},
|
||||
};
|
||||
},
|
||||
[SET_WHAT_IF_MODIFICATIONS]() {
|
||||
return {
|
||||
...state,
|
||||
whatIfModifications: action.modifications,
|
||||
};
|
||||
},
|
||||
[CLEAR_WHAT_IF_MODIFICATIONS]() {
|
||||
return {
|
||||
...state,
|
||||
whatIfModifications: [],
|
||||
};
|
||||
},
|
||||
[TOGGLE_WHAT_IF_PANEL]() {
|
||||
return {
|
||||
...state,
|
||||
whatIfPanelOpen: action.isOpen,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from './components/nativeFilters/ChartCustomization/types';
|
||||
import { GroupByCustomizationsState } from './reducers/groupByCustomizations';
|
||||
import { ChartState } from '../explore/types';
|
||||
import { SliceEntitiesState } from './actions/sliceEntities';
|
||||
|
||||
export type { Dashboard } from 'src/types/Dashboard';
|
||||
|
||||
@@ -129,6 +130,8 @@ export type DashboardState = {
|
||||
data: JsonObject;
|
||||
};
|
||||
chartStates?: Record<string, any>;
|
||||
whatIfModifications: WhatIfModification[];
|
||||
whatIfPanelOpen?: boolean;
|
||||
};
|
||||
export type DashboardInfo = {
|
||||
id: number;
|
||||
@@ -184,7 +187,7 @@ export type DatasourcesState = {
|
||||
/** Root state of redux */
|
||||
export type RootState = {
|
||||
datasources: DatasourcesState;
|
||||
sliceEntities: JsonObject;
|
||||
sliceEntities: SliceEntitiesState;
|
||||
charts: ChartsState;
|
||||
dashboardLayout: DashboardLayoutState;
|
||||
dashboardFilters: {};
|
||||
@@ -280,6 +283,40 @@ export type Slice = {
|
||||
created_by: { id: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* What-If Analysis types
|
||||
*/
|
||||
export type WhatIfFilterOperator =
|
||||
| '=='
|
||||
| '!='
|
||||
| '>'
|
||||
| '<'
|
||||
| '>='
|
||||
| '<='
|
||||
| 'IN'
|
||||
| 'NOT IN'
|
||||
| 'TEMPORAL_RANGE';
|
||||
|
||||
export interface WhatIfFilter {
|
||||
col: string;
|
||||
op: WhatIfFilterOperator;
|
||||
val: string | number | boolean | Array<string | number>;
|
||||
}
|
||||
|
||||
export interface WhatIfModification {
|
||||
column: string;
|
||||
multiplier: number;
|
||||
filters?: WhatIfFilter[];
|
||||
}
|
||||
|
||||
export interface WhatIfColumn {
|
||||
columnName: string;
|
||||
datasourceId: number;
|
||||
usedByChartIds: number[];
|
||||
description?: string | null;
|
||||
verboseName?: string | null;
|
||||
}
|
||||
|
||||
export enum MenuKeys {
|
||||
DownloadAsImage = 'download_as_image',
|
||||
ExploreChart = 'explore_chart',
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
ChartConfiguration,
|
||||
ChartQueryPayload,
|
||||
ActiveFilters,
|
||||
WhatIfModification,
|
||||
Slice,
|
||||
} from 'src/dashboard/types';
|
||||
import { ChartCustomizationItem } from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
|
||||
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
|
||||
@@ -42,6 +44,10 @@ import {
|
||||
} from './chartTypeLimitations';
|
||||
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
|
||||
import { getAllActiveFilters } from '../activeAllDashboardFilters';
|
||||
import {
|
||||
collectSqlExpressionsFromSlice,
|
||||
findColumnsInSqlExpressions,
|
||||
} from '../whatIf';
|
||||
|
||||
interface CachedFormData {
|
||||
extra_form_data?: JsonObject;
|
||||
@@ -76,6 +82,7 @@ const cachedFormdataByChart: Record<
|
||||
CachedFormData & {
|
||||
dataMask: DataMask;
|
||||
extraControls: Record<string, string | boolean | null>;
|
||||
whatIfModifications?: WhatIfModification[];
|
||||
}
|
||||
> = {};
|
||||
|
||||
@@ -97,6 +104,7 @@ export interface GetFormDataWithExtraFiltersArguments {
|
||||
allSliceIds: number[];
|
||||
chartCustomization?: JsonObject;
|
||||
activeFilters?: ActiveFilters;
|
||||
whatIfModifications?: WhatIfModification[];
|
||||
}
|
||||
|
||||
const createFilterDataMapping = (
|
||||
@@ -129,19 +137,10 @@ function extractColumnNames(columns: unknown[]): string[] {
|
||||
return columnNames;
|
||||
}
|
||||
|
||||
function buildExistingColumnsSet(chart: ChartQueryPayload): Set<string> {
|
||||
const existingColumns = new Set<string>();
|
||||
const chartType = chart.form_data?.viz_type;
|
||||
|
||||
const existingGroupBy = ensureIsArray(chart.form_data?.groupby);
|
||||
existingGroupBy.forEach((col: string) => existingColumns.add(col));
|
||||
|
||||
const xAxisColumn = chart.form_data?.x_axis;
|
||||
if (xAxisColumn && chartType !== 'heatmap' && chartType !== 'heatmap_v2') {
|
||||
existingColumns.add(xAxisColumn);
|
||||
}
|
||||
|
||||
const metrics = chart.form_data?.metrics || [];
|
||||
function extractColumnsFromMetrics(
|
||||
metrics: any[],
|
||||
existingColumns: Set<string>,
|
||||
): void {
|
||||
metrics.forEach((metric: any) => {
|
||||
if (typeof metric === 'string') {
|
||||
existingColumns.add(metric);
|
||||
@@ -158,6 +157,48 @@ function buildExistingColumnsSet(chart: ChartQueryPayload): Set<string> {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildExistingColumnsSet(chart: ChartQueryPayload): Set<string> {
|
||||
const existingColumns = new Set<string>();
|
||||
const chartType = chart.form_data?.viz_type;
|
||||
|
||||
const existingGroupBy = ensureIsArray(chart.form_data?.groupby);
|
||||
existingGroupBy.forEach((col: string) => existingColumns.add(col));
|
||||
|
||||
// Handle groupby_b for multi-query charts (e.g., Mixed Timeseries)
|
||||
const existingGroupByB = ensureIsArray(chart.form_data?.groupby_b);
|
||||
existingGroupByB.forEach((col: string) => existingColumns.add(col));
|
||||
|
||||
const xAxisColumn = chart.form_data?.x_axis;
|
||||
if (xAxisColumn && chartType !== 'heatmap' && chartType !== 'heatmap_v2') {
|
||||
existingColumns.add(xAxisColumn);
|
||||
}
|
||||
|
||||
const metrics = chart.form_data?.metrics || [];
|
||||
extractColumnsFromMetrics(metrics, existingColumns);
|
||||
|
||||
// Handle metrics_b for multi-query charts (e.g., Mixed Timeseries)
|
||||
const metricsB = chart.form_data?.metrics_b || [];
|
||||
extractColumnsFromMetrics(metricsB, existingColumns);
|
||||
|
||||
// Handle metric (singular) - used by pie charts and other single-metric charts
|
||||
const singleMetric = chart.form_data?.metric;
|
||||
if (singleMetric && typeof singleMetric === 'object') {
|
||||
const metric = singleMetric as any;
|
||||
if ('column' in metric) {
|
||||
const metricColumn = metric.column;
|
||||
if (typeof metricColumn === 'string') {
|
||||
existingColumns.add(metricColumn);
|
||||
} else if (
|
||||
metricColumn &&
|
||||
typeof metricColumn === 'object' &&
|
||||
'column_name' in metricColumn
|
||||
) {
|
||||
existingColumns.add(metricColumn.column_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seriesColumn = chart.form_data?.series;
|
||||
if (seriesColumn) existingColumns.add(seriesColumn);
|
||||
@@ -450,6 +491,7 @@ export default function getFormDataWithExtraFilters({
|
||||
allSliceIds,
|
||||
chartCustomization,
|
||||
activeFilters: passedActiveFilters,
|
||||
whatIfModifications,
|
||||
}: GetFormDataWithExtraFiltersArguments) {
|
||||
const cachedFormData = cachedFormdataByChart[sliceId];
|
||||
const dataMaskEqual = areObjectsEqual(cachedFormData?.dataMask, dataMask, {
|
||||
@@ -476,7 +518,8 @@ export default function getFormDataWithExtraFilters({
|
||||
}) &&
|
||||
areObjectsEqual(cachedFormData?.chart_customization, chartCustomization, {
|
||||
ignoreUndefined: true,
|
||||
})
|
||||
}) &&
|
||||
isEqual(cachedFormData?.whatIfModifications, whatIfModifications)
|
||||
) {
|
||||
return cachedFormData;
|
||||
}
|
||||
@@ -518,6 +561,35 @@ export default function getFormDataWithExtraFilters({
|
||||
}
|
||||
}
|
||||
|
||||
// Apply what-if modifications to charts that use the modified columns
|
||||
// Note: what_if goes in 'extras', not 'extra_form_data', because the backend
|
||||
// reads it from extras in get_sqla_query (superset/models/helpers.py)
|
||||
let whatIfExtras: { what_if?: { modifications: WhatIfModification[] } } = {};
|
||||
if (whatIfModifications && whatIfModifications.length > 0) {
|
||||
const chartColumns = buildExistingColumnsSet(chart as ChartQueryPayload);
|
||||
|
||||
// Also check if modified columns appear in SQL expressions
|
||||
// (e.g., custom metrics like AVG(orders / customers))
|
||||
const modifiedColumnNames = whatIfModifications.map(mod => mod.column);
|
||||
const sliceForSqlCheck = { form_data: chart.form_data } as Slice;
|
||||
const sqlExpressions = collectSqlExpressionsFromSlice(sliceForSqlCheck);
|
||||
const sqlReferencedColumns = findColumnsInSqlExpressions(
|
||||
sqlExpressions,
|
||||
modifiedColumnNames,
|
||||
);
|
||||
|
||||
const applicableModifications = whatIfModifications.filter(
|
||||
mod =>
|
||||
chartColumns.has(mod.column) || sqlReferencedColumns.has(mod.column),
|
||||
);
|
||||
|
||||
if (applicableModifications.length > 0) {
|
||||
whatIfExtras = {
|
||||
what_if: { modifications: applicableModifications },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let layerFilterScope: { [filterId: string]: number[] } | undefined;
|
||||
|
||||
const isDeckMultiChart = chart.form_data?.viz_type === 'deck_multi';
|
||||
@@ -571,6 +643,13 @@ export default function getFormDataWithExtraFilters({
|
||||
...groupByFormData,
|
||||
...(chartCustomization && { chart_customization: chartCustomization }),
|
||||
...(layerFilterScope && { layer_filter_scope: layerFilterScope }),
|
||||
// Merge what-if into extras (backend reads from extras, not extra_form_data)
|
||||
...(whatIfExtras.what_if && {
|
||||
extras: {
|
||||
...chart.form_data?.extras,
|
||||
...whatIfExtras,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
cachedFiltersByChart[sliceId] = filters;
|
||||
@@ -579,6 +658,7 @@ export default function getFormDataWithExtraFilters({
|
||||
dataMask,
|
||||
extraControls,
|
||||
...(chartCustomization && { chart_customization: chartCustomization }),
|
||||
whatIfModifications,
|
||||
};
|
||||
|
||||
return formData;
|
||||
|
||||
@@ -24,6 +24,24 @@ export const arrayDiff = (a: string[], b: string[]) => [
|
||||
...b.filter(x => !a.includes(x)),
|
||||
];
|
||||
|
||||
// Fields in ownState that don't require re-querying the chart when changed
|
||||
// clientView is used by TableChart to store filtered rows for export - it's a
|
||||
// derived/cached value that doesn't affect the query
|
||||
const IGNORED_OWN_STATE_FIELDS = ['clientView'];
|
||||
|
||||
const getComparableOwnState = (
|
||||
ownState: JsonObject | undefined,
|
||||
): JsonObject => {
|
||||
if (!ownState) return {};
|
||||
const result: JsonObject = {};
|
||||
Object.keys(ownState).forEach(key => {
|
||||
if (!IGNORED_OWN_STATE_FIELDS.includes(key)) {
|
||||
result[key] = ownState[key];
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getAffectedOwnDataCharts = (
|
||||
ownDataCharts: JsonObject,
|
||||
appliedOwnDataCharts: JsonObject,
|
||||
@@ -35,9 +53,10 @@ export const getAffectedOwnDataCharts = (
|
||||
);
|
||||
const checkForUpdateIds = new Set<string>([...chartIds, ...appliedChartIds]);
|
||||
checkForUpdateIds.forEach(chartId => {
|
||||
if (
|
||||
!areObjectsEqual(ownDataCharts[chartId], appliedOwnDataCharts[chartId])
|
||||
) {
|
||||
// Compare ownState excluding fields that don't require re-querying
|
||||
const currentState = getComparableOwnState(ownDataCharts[chartId]);
|
||||
const appliedState = getComparableOwnState(appliedOwnDataCharts[chartId]);
|
||||
if (!areObjectsEqual(currentState, appliedState)) {
|
||||
affectedIds.push(chartId);
|
||||
}
|
||||
});
|
||||
|
||||
59
superset-frontend/src/dashboard/util/useNumericColumns.ts
Normal file
59
superset-frontend/src/dashboard/util/useNumericColumns.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState, Slice, WhatIfColumn } from 'src/dashboard/types';
|
||||
import { getNumericColumnsForDashboard } from './whatIf';
|
||||
|
||||
/**
|
||||
* Hook to get numeric columns available for what-if analysis on the dashboard.
|
||||
* This hook memoizes the computation and provides a stable reference to avoid
|
||||
* unnecessary re-renders in consuming components.
|
||||
*
|
||||
* Returns:
|
||||
* - numericColumns: Array of WhatIfColumn objects with column metadata
|
||||
* - columnToChartIds: Map from column name to array of chart IDs that use it
|
||||
*/
|
||||
export function useNumericColumns(): {
|
||||
numericColumns: WhatIfColumn[];
|
||||
columnToChartIds: Map<string, number[]>;
|
||||
} {
|
||||
const slices = useSelector(
|
||||
(state: RootState) => state.sliceEntities.slices as { [id: number]: Slice },
|
||||
);
|
||||
const datasources = useSelector((state: RootState) => state.datasources);
|
||||
|
||||
const numericColumns = useMemo(
|
||||
() => getNumericColumnsForDashboard(slices, datasources),
|
||||
[slices, datasources],
|
||||
);
|
||||
|
||||
const columnToChartIds = useMemo(() => {
|
||||
const map = new Map<string, number[]>();
|
||||
numericColumns.forEach(col => {
|
||||
map.set(col.columnName, col.usedByChartIds);
|
||||
});
|
||||
return map;
|
||||
}, [numericColumns]);
|
||||
|
||||
return { numericColumns, columnToChartIds };
|
||||
}
|
||||
|
||||
export default useNumericColumns;
|
||||
160
superset-frontend/src/dashboard/util/useWhatIfHighlightStyles.ts
Normal file
160
superset-frontend/src/dashboard/util/useWhatIfHighlightStyles.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { RootState, WhatIfModification } from 'src/dashboard/types';
|
||||
import {
|
||||
extractColumnsFromSlice,
|
||||
collectSqlExpressionsFromSlice,
|
||||
findColumnsInSqlExpressions,
|
||||
} from './whatIf';
|
||||
|
||||
const EMPTY_STYLES = undefined;
|
||||
|
||||
/* eslint-disable theme-colors/no-literal-colors */
|
||||
const rainbowSlide = keyframes`
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 300% 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
filter: blur(2px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
filter: blur(6px);
|
||||
}
|
||||
`;
|
||||
|
||||
export const whatIfHighlightStyles = css`
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#ff8a8a,
|
||||
#ffbe8a,
|
||||
#ffff8a,
|
||||
#beff8a,
|
||||
#8aff8a,
|
||||
#8affbe,
|
||||
#8affff,
|
||||
#8abeff,
|
||||
#8a8aff,
|
||||
#be8aff,
|
||||
#ff8aff,
|
||||
#ff8abe,
|
||||
#ff8a8a,
|
||||
#ffbe8a,
|
||||
#ffff8a,
|
||||
#beff8a,
|
||||
#8aff8a,
|
||||
#8affbe,
|
||||
#8affff,
|
||||
#8abeff,
|
||||
#8a8aff,
|
||||
#be8aff,
|
||||
#ff8aff,
|
||||
#ff8abe,
|
||||
#ff8a8a
|
||||
);
|
||||
background-size: 300% 100%;
|
||||
animation:
|
||||
${rainbowSlide} 20s linear infinite,
|
||||
${pulse} 3s ease-in-out infinite;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
/* eslint-enable theme-colors/no-literal-colors */
|
||||
|
||||
/**
|
||||
* Hook that returns animated rainbow border styles for charts
|
||||
* that are affected by what-if transformations.
|
||||
*/
|
||||
const useWhatIfHighlightStyles = (chartId: number) => {
|
||||
const whatIfModifications = useSelector(
|
||||
(state: RootState) => state.dashboardState.whatIfModifications,
|
||||
);
|
||||
|
||||
const slice = useSelector(
|
||||
(state: RootState) => state.sliceEntities?.slices?.[chartId],
|
||||
);
|
||||
|
||||
const isAffected = useMemo(() => {
|
||||
if (!whatIfModifications || whatIfModifications.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!slice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chartColumns = extractColumnsFromSlice(slice);
|
||||
const modifiedColumnNames = whatIfModifications.map(
|
||||
(mod: WhatIfModification) => mod.column,
|
||||
);
|
||||
const modifiedColumns = new Set(modifiedColumnNames);
|
||||
|
||||
// Check if any of the chart's explicitly referenced columns are being modified
|
||||
for (const column of chartColumns) {
|
||||
if (modifiedColumns.has(column)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if modified columns appear in SQL expressions
|
||||
const sqlExpressions = collectSqlExpressionsFromSlice(slice);
|
||||
if (sqlExpressions.length > 0) {
|
||||
const sqlReferencedColumns = findColumnsInSqlExpressions(
|
||||
sqlExpressions,
|
||||
modifiedColumnNames,
|
||||
);
|
||||
if (sqlReferencedColumns.size > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [whatIfModifications, slice]);
|
||||
|
||||
if (!isAffected) {
|
||||
return EMPTY_STYLES;
|
||||
}
|
||||
|
||||
return whatIfHighlightStyles;
|
||||
};
|
||||
|
||||
export default useWhatIfHighlightStyles;
|
||||
211
superset-frontend/src/dashboard/util/whatIf.test.ts
Normal file
211
superset-frontend/src/dashboard/util/whatIf.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
collectSqlExpressionsFromSlice,
|
||||
findColumnsInSqlExpressions,
|
||||
sliceUsesColumn,
|
||||
} from './whatIf';
|
||||
import { Slice } from '../types';
|
||||
|
||||
const createMockSlice = (formData: Record<string, unknown>): Slice =>
|
||||
({
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Slice',
|
||||
form_data: formData,
|
||||
}) as Slice;
|
||||
|
||||
test('collectSqlExpressionsFromSlice extracts SQL from metrics', () => {
|
||||
const slice = createMockSlice({
|
||||
metrics: [
|
||||
{
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: 'AVG(orders / customers)',
|
||||
label: 'Avg Orders',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const expressions = collectSqlExpressionsFromSlice(slice);
|
||||
expect(expressions).toEqual(['AVG(orders / customers)']);
|
||||
});
|
||||
|
||||
test('collectSqlExpressionsFromSlice extracts SQL from filters', () => {
|
||||
const slice = createMockSlice({
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: 'revenue > 1000',
|
||||
clause: 'WHERE',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const expressions = collectSqlExpressionsFromSlice(slice);
|
||||
expect(expressions).toEqual(['revenue > 1000']);
|
||||
});
|
||||
|
||||
test('collectSqlExpressionsFromSlice extracts SQL from adhoc columns in groupby', () => {
|
||||
const slice = createMockSlice({
|
||||
groupby: [
|
||||
{
|
||||
sqlExpression: "DATE_TRUNC('month', order_date)",
|
||||
label: 'Month',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const expressions = collectSqlExpressionsFromSlice(slice);
|
||||
expect(expressions).toEqual(["DATE_TRUNC('month', order_date)"]);
|
||||
});
|
||||
|
||||
test('collectSqlExpressionsFromSlice extracts SQL from singular metric', () => {
|
||||
const slice = createMockSlice({
|
||||
metric: {
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: 'SUM(amount)',
|
||||
label: 'Total Amount',
|
||||
},
|
||||
});
|
||||
|
||||
const expressions = collectSqlExpressionsFromSlice(slice);
|
||||
expect(expressions).toEqual(['SUM(amount)']);
|
||||
});
|
||||
|
||||
test('collectSqlExpressionsFromSlice ignores SIMPLE expression types', () => {
|
||||
const slice = createMockSlice({
|
||||
metrics: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
column: { column_name: 'revenue' },
|
||||
aggregate: 'SUM',
|
||||
},
|
||||
],
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'status',
|
||||
operator: '==',
|
||||
comparator: 'active',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const expressions = collectSqlExpressionsFromSlice(slice);
|
||||
expect(expressions).toEqual([]);
|
||||
});
|
||||
|
||||
test('findColumnsInSqlExpressions finds exact column matches', () => {
|
||||
const sqlExpressions = ['AVG(orders / customers)', 'SUM(revenue)'];
|
||||
const columnNames = ['orders', 'customers', 'revenue', 'total'];
|
||||
|
||||
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
|
||||
expect(found).toEqual(new Set(['orders', 'customers', 'revenue']));
|
||||
});
|
||||
|
||||
test('findColumnsInSqlExpressions avoids false positives with similar names', () => {
|
||||
const sqlExpressions = ['SUM(order_count)', 'AVG(reorder_rate)'];
|
||||
const columnNames = ['order', 'orders', 'order_count', 'reorder_rate'];
|
||||
|
||||
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
|
||||
// Should only match exact column names, not partial matches
|
||||
expect(found).toEqual(new Set(['order_count', 'reorder_rate']));
|
||||
expect(found.has('order')).toBe(false);
|
||||
expect(found.has('orders')).toBe(false);
|
||||
});
|
||||
|
||||
test('findColumnsInSqlExpressions handles columns at start and end of expression', () => {
|
||||
const sqlExpressions = ['revenue + cost'];
|
||||
const columnNames = ['revenue', 'cost'];
|
||||
|
||||
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
|
||||
expect(found).toEqual(new Set(['revenue', 'cost']));
|
||||
});
|
||||
|
||||
test('findColumnsInSqlExpressions handles columns in parentheses', () => {
|
||||
const sqlExpressions = ['SUM(amount)', '(price * quantity)'];
|
||||
const columnNames = ['amount', 'price', 'quantity'];
|
||||
|
||||
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
|
||||
expect(found).toEqual(new Set(['amount', 'price', 'quantity']));
|
||||
});
|
||||
|
||||
test('findColumnsInSqlExpressions handles special regex characters in column names', () => {
|
||||
const sqlExpressions = ['SUM(col.name) + AVG(col$value)'];
|
||||
const columnNames = ['col.name', 'col$value'];
|
||||
|
||||
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
|
||||
expect(found).toEqual(new Set(['col.name', 'col$value']));
|
||||
});
|
||||
|
||||
test('findColumnsInSqlExpressions returns empty set when no matches', () => {
|
||||
const sqlExpressions = ['SUM(total)'];
|
||||
const columnNames = ['revenue', 'cost'];
|
||||
|
||||
const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
|
||||
expect(found.size).toBe(0);
|
||||
});
|
||||
|
||||
test('findColumnsInSqlExpressions returns empty set with empty inputs', () => {
|
||||
expect(findColumnsInSqlExpressions([], ['col1']).size).toBe(0);
|
||||
expect(findColumnsInSqlExpressions(['SUM(col)'], []).size).toBe(0);
|
||||
expect(findColumnsInSqlExpressions([], []).size).toBe(0);
|
||||
});
|
||||
|
||||
test('sliceUsesColumn detects columns in SQL expressions', () => {
|
||||
const slice = createMockSlice({
|
||||
metrics: [
|
||||
{
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: 'AVG(orders / customers)',
|
||||
label: 'Avg',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sliceUsesColumn(slice, 'orders')).toBe(true);
|
||||
expect(sliceUsesColumn(slice, 'customers')).toBe(true);
|
||||
expect(sliceUsesColumn(slice, 'revenue')).toBe(false);
|
||||
});
|
||||
|
||||
test('sliceUsesColumn detects explicitly referenced columns', () => {
|
||||
const slice = createMockSlice({
|
||||
groupby: ['category', 'region'],
|
||||
});
|
||||
|
||||
expect(sliceUsesColumn(slice, 'category')).toBe(true);
|
||||
expect(sliceUsesColumn(slice, 'region')).toBe(true);
|
||||
expect(sliceUsesColumn(slice, 'country')).toBe(false);
|
||||
});
|
||||
|
||||
test('sliceUsesColumn detects columns in both explicit and SQL references', () => {
|
||||
const slice = createMockSlice({
|
||||
groupby: ['category'],
|
||||
metrics: [
|
||||
{
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: 'SUM(revenue)',
|
||||
label: 'Total',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sliceUsesColumn(slice, 'category')).toBe(true);
|
||||
expect(sliceUsesColumn(slice, 'revenue')).toBe(true);
|
||||
});
|
||||
422
superset-frontend/src/dashboard/util/whatIf.ts
Normal file
422
superset-frontend/src/dashboard/util/whatIf.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
ensureIsArray,
|
||||
getColumnLabel,
|
||||
isQueryFormColumn,
|
||||
JsonValue,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/api/core';
|
||||
import { ColumnMeta } from '@superset-ui/chart-controls';
|
||||
import { DatasourcesState, Slice, WhatIfColumn } from '../types';
|
||||
|
||||
/**
|
||||
* Type definitions for form_data structures used in what-if analysis.
|
||||
* These are local types for the subset of form_data we need to inspect.
|
||||
*/
|
||||
|
||||
/** Metric definition in form_data */
|
||||
interface FormDataMetric {
|
||||
expressionType?: 'SIMPLE' | 'SQL';
|
||||
column?: string | { column_name: string };
|
||||
aggregate?: string;
|
||||
sqlExpression?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Filter definition in form_data */
|
||||
interface FormDataFilter {
|
||||
expressionType?: 'SIMPLE' | 'SQL';
|
||||
subject?: string;
|
||||
operator?: string;
|
||||
comparator?: JsonValue;
|
||||
sqlExpression?: string;
|
||||
clause?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a column is numeric based on its type_generic field
|
||||
*/
|
||||
export function isNumericColumn(column: ColumnMeta): boolean {
|
||||
return column.type_generic === GenericDataType.Numeric;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all SQL expressions from a slice's form_data.
|
||||
* This includes:
|
||||
* - Metrics with expressionType: 'SQL' (sqlExpression)
|
||||
* - Filters with expressionType: 'SQL' (sqlExpression)
|
||||
* - Adhoc columns in groupby, x_axis, series, etc. (sqlExpression)
|
||||
*/
|
||||
export function collectSqlExpressionsFromSlice(slice: Slice): string[] {
|
||||
const expressions: string[] = [];
|
||||
const formData = slice.form_data;
|
||||
if (!formData) return expressions;
|
||||
|
||||
// Helper to extract sqlExpression from adhoc columns
|
||||
const addAdhocColumnExpression = (col: unknown) => {
|
||||
if (
|
||||
col &&
|
||||
typeof col === 'object' &&
|
||||
'sqlExpression' in col &&
|
||||
typeof (col as { sqlExpression: unknown }).sqlExpression === 'string'
|
||||
) {
|
||||
expressions.push((col as { sqlExpression: string }).sqlExpression);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to extract SQL expressions from metrics array
|
||||
const addMetricSqlExpressions = (metrics: unknown[]) => {
|
||||
metrics.forEach((metric: unknown) => {
|
||||
if (
|
||||
metric &&
|
||||
typeof metric === 'object' &&
|
||||
'expressionType' in metric &&
|
||||
(metric as { expressionType: unknown }).expressionType === 'SQL' &&
|
||||
'sqlExpression' in metric &&
|
||||
typeof (metric as { sqlExpression: unknown }).sqlExpression === 'string'
|
||||
) {
|
||||
expressions.push((metric as { sqlExpression: string }).sqlExpression);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Extract SQL expressions from metrics
|
||||
addMetricSqlExpressions(ensureIsArray(formData.metrics));
|
||||
|
||||
// Handle metrics_b for multi-query charts (e.g., Mixed Timeseries)
|
||||
addMetricSqlExpressions(ensureIsArray(formData.metrics_b));
|
||||
|
||||
// Extract SQL expression from singular metric
|
||||
if (
|
||||
formData.metric &&
|
||||
typeof formData.metric === 'object' &&
|
||||
'expressionType' in formData.metric &&
|
||||
(formData.metric as { expressionType: unknown }).expressionType === 'SQL' &&
|
||||
'sqlExpression' in formData.metric &&
|
||||
typeof (formData.metric as { sqlExpression: unknown }).sqlExpression ===
|
||||
'string'
|
||||
) {
|
||||
expressions.push(
|
||||
(formData.metric as { sqlExpression: string }).sqlExpression,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract SQL expressions from filters array
|
||||
const addFilterSqlExpressions = (filters: unknown[]) => {
|
||||
filters.forEach((filter: unknown) => {
|
||||
if (
|
||||
filter &&
|
||||
typeof filter === 'object' &&
|
||||
'expressionType' in filter &&
|
||||
(filter as { expressionType: unknown }).expressionType === 'SQL' &&
|
||||
'sqlExpression' in filter &&
|
||||
typeof (filter as { sqlExpression: unknown }).sqlExpression === 'string'
|
||||
) {
|
||||
expressions.push((filter as { sqlExpression: string }).sqlExpression);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Extract SQL expressions from filters
|
||||
addFilterSqlExpressions(ensureIsArray(formData.adhoc_filters));
|
||||
|
||||
// Handle adhoc_filters_b for multi-query charts (e.g., Mixed Timeseries)
|
||||
addFilterSqlExpressions(ensureIsArray(formData.adhoc_filters_b));
|
||||
|
||||
// Extract SQL expressions from adhoc columns in groupby, x_axis, series, columns, entity
|
||||
ensureIsArray(formData.groupby).forEach(addAdhocColumnExpression);
|
||||
ensureIsArray(formData.columns).forEach(addAdhocColumnExpression);
|
||||
|
||||
// Handle groupby_b for multi-query charts (e.g., Mixed Timeseries)
|
||||
ensureIsArray(formData.groupby_b).forEach(addAdhocColumnExpression);
|
||||
|
||||
if (formData.x_axis) {
|
||||
addAdhocColumnExpression(formData.x_axis);
|
||||
}
|
||||
if (formData.series) {
|
||||
addAdhocColumnExpression(formData.series);
|
||||
}
|
||||
if (formData.entity) {
|
||||
addAdhocColumnExpression(formData.entity);
|
||||
}
|
||||
|
||||
return expressions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find column names that appear in SQL expressions.
|
||||
* Uses word boundary matching to avoid false positives
|
||||
* (e.g., "order" shouldn't match "order_id" or "reorder").
|
||||
*/
|
||||
export function findColumnsInSqlExpressions(
|
||||
sqlExpressions: string[],
|
||||
columnNames: string[],
|
||||
): Set<string> {
|
||||
const foundColumns = new Set<string>();
|
||||
|
||||
if (sqlExpressions.length === 0 || columnNames.length === 0) {
|
||||
return foundColumns;
|
||||
}
|
||||
|
||||
// Combine all SQL expressions into one string for efficient searching
|
||||
const combinedSql = sqlExpressions.join(' ');
|
||||
|
||||
columnNames.forEach(columnName => {
|
||||
// Use word boundary regex to match exact column names
|
||||
// Escape special regex characters in column name
|
||||
const escapedName = columnName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// Match column name surrounded by word boundaries or common SQL delimiters
|
||||
const regex = new RegExp(
|
||||
`(^|[^a-zA-Z0-9_])${escapedName}([^a-zA-Z0-9_]|$)`,
|
||||
);
|
||||
if (regex.test(combinedSql)) {
|
||||
foundColumns.add(columnName);
|
||||
}
|
||||
});
|
||||
|
||||
return foundColumns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract column names from a slice's form_data
|
||||
* This includes columns from groupby, metrics, x_axis, series, filters, etc.
|
||||
*/
|
||||
export function extractColumnsFromSlice(slice: Slice): Set<string> {
|
||||
const columns = new Set<string>();
|
||||
const formData = slice.form_data;
|
||||
if (!formData) return columns;
|
||||
|
||||
// Helper to add column - handles both physical columns (strings) and adhoc columns
|
||||
const addColumn = (col: unknown) => {
|
||||
if (isQueryFormColumn(col)) {
|
||||
const label = getColumnLabel(col);
|
||||
if (label) columns.add(label);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to extract columns from metrics array
|
||||
const addMetricColumns = (metrics: unknown[]) => {
|
||||
metrics.forEach((metric: unknown) => {
|
||||
if (typeof metric === 'string') {
|
||||
// Saved metric name - we can't extract columns from it
|
||||
return;
|
||||
}
|
||||
if (metric && typeof metric === 'object' && 'column' in metric) {
|
||||
const metricColumn = (metric as FormDataMetric).column;
|
||||
if (typeof metricColumn === 'string') {
|
||||
columns.add(metricColumn);
|
||||
} else if (
|
||||
metricColumn &&
|
||||
typeof metricColumn === 'object' &&
|
||||
'column_name' in metricColumn
|
||||
) {
|
||||
columns.add(metricColumn.column_name);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to extract columns from filters array
|
||||
const addFilterColumns = (filters: unknown[]) => {
|
||||
filters.forEach((filter: unknown) => {
|
||||
const f = filter as FormDataFilter;
|
||||
if (f?.subject && typeof f.subject === 'string') {
|
||||
columns.add(f.subject);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Extract groupby columns (can be physical or adhoc)
|
||||
ensureIsArray(formData.groupby).forEach(addColumn);
|
||||
|
||||
// Handle groupby_b for multi-query charts (e.g., Mixed Timeseries)
|
||||
ensureIsArray(formData.groupby_b).forEach(addColumn);
|
||||
|
||||
// Extract x_axis column (can be physical or adhoc)
|
||||
if (formData.x_axis) {
|
||||
addColumn(formData.x_axis);
|
||||
}
|
||||
|
||||
// Extract metrics - get column names from metric definitions
|
||||
addMetricColumns(ensureIsArray(formData.metrics));
|
||||
|
||||
// Handle metrics_b for multi-query charts (e.g., Mixed Timeseries)
|
||||
addMetricColumns(ensureIsArray(formData.metrics_b));
|
||||
|
||||
// Extract metric (singular) - used by pie charts and other single-metric charts
|
||||
if (formData.metric && typeof formData.metric === 'object') {
|
||||
const metric = formData.metric as FormDataMetric;
|
||||
if ('column' in metric && metric.column) {
|
||||
const metricColumn = metric.column;
|
||||
if (typeof metricColumn === 'string') {
|
||||
columns.add(metricColumn);
|
||||
} else if (typeof metricColumn === 'object' && 'column_name' in metricColumn) {
|
||||
columns.add(metricColumn.column_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract series column (can be physical or adhoc)
|
||||
if (formData.series) {
|
||||
addColumn(formData.series);
|
||||
}
|
||||
|
||||
// Extract entity column
|
||||
if (formData.entity) {
|
||||
addColumn(formData.entity);
|
||||
}
|
||||
|
||||
// Extract columns from filters
|
||||
addFilterColumns(ensureIsArray(formData.adhoc_filters));
|
||||
|
||||
// Handle adhoc_filters_b for multi-query charts (e.g., Mixed Timeseries)
|
||||
addFilterColumns(ensureIsArray(formData.adhoc_filters_b));
|
||||
|
||||
// Extract columns array (used by some chart types like box_plot)
|
||||
ensureIsArray(formData.columns).forEach(addColumn);
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the datasource key from a slice's form_data
|
||||
* Format: "datasourceId__datasourceType" e.g., "2__table"
|
||||
*/
|
||||
export function getDatasourceKey(slice: Slice): string | null {
|
||||
const datasource = slice.form_data?.datasource;
|
||||
if (!datasource || typeof datasource !== 'string') return null;
|
||||
return datasource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get numeric columns used by slices on a dashboard
|
||||
* Returns columns grouped by their usage across slices
|
||||
*
|
||||
* Uses sliceEntities.slices instead of charts state because it changes less
|
||||
* frequently (only on slice metadata updates, not on every query result change)
|
||||
*/
|
||||
export function getNumericColumnsForDashboard(
|
||||
slices: { [id: number]: Slice },
|
||||
datasources: DatasourcesState,
|
||||
): WhatIfColumn[] {
|
||||
const columnMap = new Map<string, WhatIfColumn>();
|
||||
|
||||
Object.values(slices).forEach(slice => {
|
||||
const chartId = slice.slice_id;
|
||||
const datasourceKey = getDatasourceKey(slice);
|
||||
if (!datasourceKey) return;
|
||||
|
||||
const datasource = datasources[datasourceKey];
|
||||
if (!datasource?.columns) return;
|
||||
|
||||
// Extract columns explicitly referenced by this slice
|
||||
const referencedColumns = extractColumnsFromSlice(slice);
|
||||
|
||||
// Also check SQL expressions for column references
|
||||
const sqlExpressions = collectSqlExpressionsFromSlice(slice);
|
||||
if (sqlExpressions.length > 0) {
|
||||
// Get all numeric column names from this datasource
|
||||
const numericColumnNames = datasource.columns
|
||||
.filter((c: ColumnMeta) => isNumericColumn(c))
|
||||
.map((c: ColumnMeta) => c.column_name);
|
||||
|
||||
// Find which numeric columns are referenced in SQL expressions
|
||||
const sqlReferencedColumns = findColumnsInSqlExpressions(
|
||||
sqlExpressions,
|
||||
numericColumnNames,
|
||||
);
|
||||
|
||||
// Add SQL-referenced columns to the set
|
||||
sqlReferencedColumns.forEach(colName => referencedColumns.add(colName));
|
||||
}
|
||||
|
||||
// For each referenced column, check if it's numeric
|
||||
referencedColumns.forEach(colName => {
|
||||
const colMetadata = datasource.columns.find(
|
||||
(c: ColumnMeta) => c.column_name === colName,
|
||||
);
|
||||
|
||||
if (colMetadata && isNumericColumn(colMetadata)) {
|
||||
// Create a unique key for this column (datasource + column name)
|
||||
const key = `${datasource.id}:${colName}`;
|
||||
|
||||
if (!columnMap.has(key)) {
|
||||
columnMap.set(key, {
|
||||
columnName: colName,
|
||||
datasourceId: datasource.id,
|
||||
usedByChartIds: [chartId],
|
||||
description: colMetadata.description,
|
||||
verboseName: colMetadata.verbose_name,
|
||||
});
|
||||
} else {
|
||||
const existing = columnMap.get(key)!;
|
||||
if (!existing.usedByChartIds.includes(chartId)) {
|
||||
existing.usedByChartIds.push(chartId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(columnMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a slice uses a specific column.
|
||||
* Checks both explicitly referenced columns and columns in SQL expressions.
|
||||
*/
|
||||
export function sliceUsesColumn(slice: Slice, columnName: string): boolean {
|
||||
const columns = extractColumnsFromSlice(slice);
|
||||
if (columns.has(columnName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check SQL expressions
|
||||
const sqlExpressions = collectSqlExpressionsFromSlice(slice);
|
||||
if (sqlExpressions.length > 0) {
|
||||
const sqlReferencedColumns = findColumnsInSqlExpressions(sqlExpressions, [
|
||||
columnName,
|
||||
]);
|
||||
return sqlReferencedColumns.has(columnName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a multiplier value as a percentage change string.
|
||||
* Example: 1.15 -> "+15%", 0.85 -> "-15%"
|
||||
*
|
||||
* @param multiplier - The multiplier value (1 = no change)
|
||||
* @param decimals - Number of decimal places (default: 0)
|
||||
* @returns Formatted percentage string with sign
|
||||
*/
|
||||
export function formatPercentageChange(
|
||||
multiplier: number,
|
||||
decimals: number = 0,
|
||||
): string {
|
||||
const percentChange = (multiplier - 1) * 100;
|
||||
const sign = percentChange >= 0 ? '+' : '';
|
||||
const formatted =
|
||||
decimals > 0
|
||||
? percentChange.toFixed(decimals)
|
||||
: Math.round(percentChange).toString();
|
||||
return `${sign}${formatted}%`;
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/ui';
|
||||
import { Modal, Tag, Button, Input } from '@superset-ui/core/components';
|
||||
import Slider from '@superset-ui/core/components/Slider';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { WhatIfModification, WhatIfFilter } from 'src/dashboard/types';
|
||||
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
|
||||
import {
|
||||
WhatIfSimulation,
|
||||
updateSimulation,
|
||||
} from 'src/dashboard/components/WhatIfDrawer/whatIfApi';
|
||||
import {
|
||||
SLIDER_MIN,
|
||||
SLIDER_MAX,
|
||||
SLIDER_MARKS,
|
||||
SLIDER_TOOLTIP_CONFIG,
|
||||
} from 'src/dashboard/components/WhatIfDrawer/constants';
|
||||
|
||||
/** Maps column name to verbose name for display */
|
||||
type ColumnVerboseNames = Record<string, string>;
|
||||
|
||||
interface EditSimulationModalProps {
|
||||
simulation: WhatIfSimulation | null;
|
||||
onHide: () => void;
|
||||
onSaved: () => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
columnVerboseNames: ColumnVerboseNames;
|
||||
}
|
||||
|
||||
const ModificationRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
background: ${({ theme }) => theme.colorBgLayout};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
const ModificationHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
const ColumnName = styled.span`
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
font-size: ${({ theme }) => theme.fontSizeLG}px;
|
||||
`;
|
||||
|
||||
const SliderContainer = styled.div`
|
||||
padding: 0 ${({ theme }) => theme.sizeUnit}px;
|
||||
& .ant-slider-mark {
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const FiltersContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
const FilterLabel = styled.div`
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
text-align: center;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 6}px;
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
`;
|
||||
|
||||
const AddModificationButton = styled(Button)`
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
const NewModificationForm = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
background: ${({ theme }) => theme.colorBgLayout};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
border: 1px dashed ${({ theme }) => theme.colorBorder};
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Format a WhatIfFilter for display
|
||||
*/
|
||||
function formatFilterLabel(filter: WhatIfFilter): string {
|
||||
const { col, op, val } = filter;
|
||||
|
||||
let valStr: string;
|
||||
if (Array.isArray(val)) {
|
||||
valStr = val.join(', ');
|
||||
} else if (typeof val === 'boolean') {
|
||||
valStr = val ? 'true' : 'false';
|
||||
} else {
|
||||
valStr = String(val);
|
||||
}
|
||||
// Truncate long values
|
||||
if (valStr.length > 20) {
|
||||
valStr = `${valStr.substring(0, 17)}...`;
|
||||
}
|
||||
return `${col} ${op} ${valStr}`;
|
||||
}
|
||||
|
||||
interface EditableModification extends WhatIfModification {
|
||||
sliderValue: number; // (multiplier - 1) * 100
|
||||
}
|
||||
|
||||
function EditSimulationModal({
|
||||
simulation,
|
||||
onHide,
|
||||
onSaved,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
columnVerboseNames,
|
||||
}: EditSimulationModalProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
// Convert modifications to editable format with slider values
|
||||
const initialModifications = useMemo(
|
||||
() =>
|
||||
simulation?.modifications.map(mod => ({
|
||||
...mod,
|
||||
sliderValue: (mod.multiplier - 1) * 100,
|
||||
})) ?? [],
|
||||
[simulation],
|
||||
);
|
||||
|
||||
const [modifications, setModifications] =
|
||||
useState<EditableModification[]>(initialModifications);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showNewModification, setShowNewModification] = useState(false);
|
||||
const [newColumnName, setNewColumnName] = useState('');
|
||||
const [newSliderValue, setNewSliderValue] = useState(0);
|
||||
|
||||
// Reset state when simulation changes
|
||||
useMemo(() => {
|
||||
setModifications(initialModifications);
|
||||
setShowNewModification(false);
|
||||
setNewColumnName('');
|
||||
setNewSliderValue(0);
|
||||
}, [initialModifications]);
|
||||
|
||||
const handleSliderChange = useCallback((index: number, value: number) => {
|
||||
setModifications(prev =>
|
||||
prev.map((mod, i) =>
|
||||
i === index
|
||||
? { ...mod, sliderValue: value, multiplier: 1 + value / 100 }
|
||||
: mod,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleRemoveModification = useCallback((index: number) => {
|
||||
setModifications(prev => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleAddModification = useCallback(() => {
|
||||
if (!newColumnName.trim()) return;
|
||||
|
||||
const newMod: EditableModification = {
|
||||
column: newColumnName.trim(),
|
||||
multiplier: 1 + newSliderValue / 100,
|
||||
sliderValue: newSliderValue,
|
||||
};
|
||||
setModifications(prev => [...prev, newMod]);
|
||||
setShowNewModification(false);
|
||||
setNewColumnName('');
|
||||
setNewSliderValue(0);
|
||||
}, [newColumnName, newSliderValue]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!simulation) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updatedModifications: WhatIfModification[] = modifications.map(
|
||||
mod => ({
|
||||
column: mod.column,
|
||||
multiplier: mod.multiplier,
|
||||
filters: mod.filters,
|
||||
}),
|
||||
);
|
||||
|
||||
await updateSimulation(simulation.id, {
|
||||
modifications: updatedModifications,
|
||||
});
|
||||
|
||||
addSuccessToast(t('Simulation updated successfully'));
|
||||
onSaved();
|
||||
onHide();
|
||||
} catch (error) {
|
||||
addDangerToast(t('Failed to update simulation'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
simulation,
|
||||
modifications,
|
||||
onSaved,
|
||||
onHide,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!simulation) return false;
|
||||
if (modifications.length !== simulation.modifications.length) return true;
|
||||
|
||||
return modifications.some((mod, i) => {
|
||||
const original = simulation.modifications[i];
|
||||
return mod.multiplier !== original.multiplier;
|
||||
});
|
||||
}, [simulation, modifications]);
|
||||
|
||||
if (!simulation) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show
|
||||
onHide={onHide}
|
||||
title={t('Edit simulation: %s', simulation.name)}
|
||||
primaryButtonName={t('Save')}
|
||||
onHandledPrimaryAction={handleSave}
|
||||
primaryButtonLoading={saving}
|
||||
disablePrimaryButton={!hasChanges || saving}
|
||||
centered
|
||||
>
|
||||
{modifications.length === 0 && !showNewModification ? (
|
||||
<EmptyState>
|
||||
<Icons.WarningOutlined
|
||||
iconSize="xl"
|
||||
css={css`
|
||||
color: ${theme.colorWarning};
|
||||
margin-bottom: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
/>
|
||||
<div>{t('No modifications in this simulation')}</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
modifications.map((mod, index) => (
|
||||
<ModificationRow key={`${mod.column}-${index}`}>
|
||||
<ModificationHeader>
|
||||
<ColumnName>
|
||||
{columnVerboseNames[mod.column] || mod.column}
|
||||
</ColumnName>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
<span
|
||||
css={css`
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: ${theme.fontSizeLG}px;
|
||||
color: ${mod.multiplier >= 1
|
||||
? theme.colorSuccess
|
||||
: theme.colorError};
|
||||
`}
|
||||
>
|
||||
{formatPercentageChange(mod.multiplier, 0)}
|
||||
</span>
|
||||
<Button
|
||||
buttonSize="small"
|
||||
onClick={() => handleRemoveModification(index)}
|
||||
buttonStyle="tertiary"
|
||||
aria-label={t('Remove modification')}
|
||||
>
|
||||
<Icons.DeleteOutlined iconSize="s" />
|
||||
</Button>
|
||||
</div>
|
||||
</ModificationHeader>
|
||||
|
||||
<SliderContainer>
|
||||
<Slider
|
||||
min={SLIDER_MIN}
|
||||
max={SLIDER_MAX}
|
||||
value={mod.sliderValue}
|
||||
onChange={value => handleSliderChange(index, value)}
|
||||
marks={SLIDER_MARKS}
|
||||
tooltip={SLIDER_TOOLTIP_CONFIG}
|
||||
/>
|
||||
</SliderContainer>
|
||||
|
||||
{mod.filters && mod.filters.length > 0 && (
|
||||
<div>
|
||||
<FilterLabel>{t('Filters')}</FilterLabel>
|
||||
<FiltersContainer>
|
||||
{mod.filters.map((filter, filterIndex) => (
|
||||
<Tag key={`${filter.col}-${filterIndex}`}>
|
||||
{formatFilterLabel(filter)}
|
||||
</Tag>
|
||||
))}
|
||||
</FiltersContainer>
|
||||
</div>
|
||||
)}
|
||||
</ModificationRow>
|
||||
))
|
||||
)}
|
||||
|
||||
{showNewModification && (
|
||||
<NewModificationForm>
|
||||
<Input
|
||||
placeholder={t('Column name')}
|
||||
value={newColumnName}
|
||||
onChange={e => setNewColumnName(e.target.value)}
|
||||
/>
|
||||
<SliderContainer>
|
||||
<Slider
|
||||
min={SLIDER_MIN}
|
||||
max={SLIDER_MAX}
|
||||
value={newSliderValue}
|
||||
onChange={setNewSliderValue}
|
||||
marks={SLIDER_MARKS}
|
||||
tooltip={SLIDER_TOOLTIP_CONFIG}
|
||||
/>
|
||||
</SliderContainer>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
buttonSize="small"
|
||||
onClick={handleAddModification}
|
||||
disabled={!newColumnName.trim() || newSliderValue === 0}
|
||||
>
|
||||
{t('Add')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonSize="small"
|
||||
onClick={() => {
|
||||
setShowNewModification(false);
|
||||
setNewColumnName('');
|
||||
setNewSliderValue(0);
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</NewModificationForm>
|
||||
)}
|
||||
|
||||
{!showNewModification && (
|
||||
<AddModificationButton
|
||||
buttonStyle="tertiary"
|
||||
onClick={() => setShowNewModification(true)}
|
||||
>
|
||||
<Icons.PlusOutlined iconSize="s" />
|
||||
{t('Add modification')}
|
||||
</AddModificationButton>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditSimulationModal;
|
||||
549
superset-frontend/src/pages/WhatIfSimulationList/index.tsx
Normal file
549
superset-frontend/src/pages/WhatIfSimulationList/index.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { t, SupersetClient } from '@superset-ui/core';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/ui';
|
||||
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
|
||||
|
||||
import {
|
||||
ConfirmStatusChange,
|
||||
DeleteModal,
|
||||
Empty,
|
||||
Skeleton,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import {
|
||||
ListView,
|
||||
ListViewActionsBar,
|
||||
type ListViewProps,
|
||||
type ListViewActionProps,
|
||||
} from 'src/components';
|
||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { WhatIfFilter, WhatIfModification } from 'src/dashboard/types';
|
||||
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
|
||||
|
||||
import {
|
||||
fetchAllSimulations,
|
||||
deleteSimulation,
|
||||
WhatIfSimulation,
|
||||
} from 'src/dashboard/components/WhatIfDrawer/whatIfApi';
|
||||
import EditSimulationModal from './EditSimulationModal';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface WhatIfSimulationListProps {
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
interface DashboardInfo {
|
||||
id: number;
|
||||
dashboard_title: string;
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
/** Maps column name to verbose name for display */
|
||||
type ColumnVerboseNames = Record<string, string>;
|
||||
|
||||
const PageContainer = styled.div`
|
||||
padding: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 16}px;
|
||||
`;
|
||||
|
||||
const ModificationsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
const ModificationTagsRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
const FilterBadge = styled.span`
|
||||
font-size: 10px;
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
margin-left: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Format a WhatIfFilter for display
|
||||
*/
|
||||
function formatFilterLabel(filter: WhatIfFilter): string {
|
||||
const { col, op, val } = filter;
|
||||
|
||||
let valStr: string;
|
||||
if (Array.isArray(val)) {
|
||||
valStr = val.join(', ');
|
||||
} else if (typeof val === 'boolean') {
|
||||
valStr = val ? 'true' : 'false';
|
||||
} else {
|
||||
valStr = String(val);
|
||||
}
|
||||
// Truncate long values
|
||||
if (valStr.length > 15) {
|
||||
valStr = `${valStr.substring(0, 12)}...`;
|
||||
}
|
||||
return `${col} ${op} ${valStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to render a single modification with its filters
|
||||
*/
|
||||
function ModificationTag({
|
||||
modification,
|
||||
columnVerboseNames,
|
||||
}: {
|
||||
modification: WhatIfModification;
|
||||
columnVerboseNames: ColumnVerboseNames;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const hasFilters = modification.filters && modification.filters.length > 0;
|
||||
const displayName =
|
||||
columnVerboseNames[modification.column] || modification.column;
|
||||
|
||||
const tagContent = (
|
||||
<Tag
|
||||
css={css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
margin: 0;
|
||||
`}
|
||||
>
|
||||
<span>{displayName}</span>
|
||||
<span
|
||||
css={css`
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${modification.multiplier >= 1
|
||||
? theme.colorSuccess
|
||||
: theme.colorError};
|
||||
`}
|
||||
>
|
||||
{formatPercentageChange(modification.multiplier, 0)}
|
||||
</span>
|
||||
{hasFilters && (
|
||||
<FilterBadge>
|
||||
<Icons.FilterOutlined iconSize="xs" />
|
||||
</FilterBadge>
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
if (hasFilters) {
|
||||
const filterTooltip = modification
|
||||
.filters!.map(f => formatFilterLabel(f))
|
||||
.join(', ');
|
||||
return <Tooltip title={filterTooltip}>{tagContent}</Tooltip>;
|
||||
}
|
||||
|
||||
return tagContent;
|
||||
}
|
||||
|
||||
function WhatIfSimulationList({
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}: WhatIfSimulationListProps) {
|
||||
const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]);
|
||||
const [dashboards, setDashboards] = useState<Record<number, DashboardInfo>>(
|
||||
{},
|
||||
);
|
||||
const [columnVerboseNames, setColumnVerboseNames] =
|
||||
useState<ColumnVerboseNames>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [simulationCurrentlyDeleting, setSimulationCurrentlyDeleting] =
|
||||
useState<WhatIfSimulation | null>(null);
|
||||
const [simulationCurrentlyEditing, setSimulationCurrentlyEditing] =
|
||||
useState<WhatIfSimulation | null>(null);
|
||||
|
||||
const loadSimulations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchAllSimulations();
|
||||
setSimulations(result);
|
||||
|
||||
// Fetch dashboard info for all unique dashboard IDs
|
||||
const dashboardIds = [...new Set(result.map(sim => sim.dashboardId))];
|
||||
if (dashboardIds.length > 0) {
|
||||
const dashboardInfos: Record<number, DashboardInfo> = {};
|
||||
const verboseNames: ColumnVerboseNames = {};
|
||||
|
||||
await Promise.all(
|
||||
dashboardIds.map(async id => {
|
||||
try {
|
||||
// Fetch dashboard info and datasets in parallel
|
||||
const [dashboardResponse, datasetsResponse] = await Promise.all([
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
}),
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${id}/datasets`,
|
||||
}),
|
||||
]);
|
||||
|
||||
dashboardInfos[id] = {
|
||||
id,
|
||||
dashboard_title: dashboardResponse.json.result.dashboard_title,
|
||||
slug: dashboardResponse.json.result.slug,
|
||||
};
|
||||
|
||||
// Extract column verbose names from all datasets
|
||||
const datasets = datasetsResponse.json.result || [];
|
||||
datasets.forEach(
|
||||
(dataset: {
|
||||
columns?: { column_name: string; verbose_name?: string }[];
|
||||
}) => {
|
||||
(dataset.columns || []).forEach(col => {
|
||||
if (col.verbose_name) {
|
||||
verboseNames[col.column_name] = col.verbose_name;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
dashboardInfos[id] = {
|
||||
id,
|
||||
dashboard_title: `Dashboard ${id}`,
|
||||
slug: null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
setDashboards(dashboardInfos);
|
||||
setColumnVerboseNames(verboseNames);
|
||||
}
|
||||
} catch (error) {
|
||||
addDangerToast(t('Failed to load simulations'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addDangerToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSimulations();
|
||||
}, [loadSimulations]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (simulation: WhatIfSimulation) => {
|
||||
try {
|
||||
await deleteSimulation(simulation.id);
|
||||
setSimulationCurrentlyDeleting(null);
|
||||
addSuccessToast(t('Deleted: %s', simulation.name));
|
||||
loadSimulations();
|
||||
} catch (error) {
|
||||
addDangerToast(t('Failed to delete simulation'));
|
||||
}
|
||||
},
|
||||
[addSuccessToast, addDangerToast, loadSimulations],
|
||||
);
|
||||
|
||||
const handleBulkDelete = useCallback(
|
||||
async (simulationsToDelete: WhatIfSimulation[]) => {
|
||||
try {
|
||||
await Promise.all(
|
||||
simulationsToDelete.map(sim => deleteSimulation(sim.id)),
|
||||
);
|
||||
addSuccessToast(
|
||||
t('Deleted %s simulation(s)', simulationsToDelete.length),
|
||||
);
|
||||
loadSimulations();
|
||||
} catch (error) {
|
||||
addDangerToast(t('Failed to delete simulations'));
|
||||
}
|
||||
},
|
||||
[addSuccessToast, addDangerToast, loadSimulations],
|
||||
);
|
||||
|
||||
const menuData: SubMenuProps = {
|
||||
name: t('What-if simulations'),
|
||||
};
|
||||
|
||||
const initialSort = [{ id: 'changedOn', desc: true }];
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessor: 'name',
|
||||
Header: t('Name'),
|
||||
size: 'lg',
|
||||
id: 'name',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { id, name, dashboardId },
|
||||
},
|
||||
}: {
|
||||
row: { original: WhatIfSimulation };
|
||||
}) => {
|
||||
const dashboard = dashboards[dashboardId];
|
||||
const dashboardUrl = dashboard?.slug
|
||||
? `/superset/dashboard/${dashboard.slug}/`
|
||||
: `/superset/dashboard/${dashboardId}/`;
|
||||
const url = `${dashboardUrl}?simulation=${id}`;
|
||||
return <Link to={url}>{name}</Link>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'description',
|
||||
Header: t('Description'),
|
||||
size: 'xl',
|
||||
id: 'description',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { description },
|
||||
},
|
||||
}: {
|
||||
row: { original: WhatIfSimulation };
|
||||
}) => description || '-',
|
||||
},
|
||||
{
|
||||
accessor: 'dashboardId',
|
||||
Header: t('Dashboard'),
|
||||
size: 'md',
|
||||
id: 'dashboardId',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { dashboardId },
|
||||
},
|
||||
}: {
|
||||
row: { original: WhatIfSimulation };
|
||||
}) => {
|
||||
const dashboard = dashboards[dashboardId];
|
||||
if (!dashboard) return `Dashboard ${dashboardId}`;
|
||||
const url = dashboard.slug
|
||||
? `/superset/dashboard/${dashboard.slug}/`
|
||||
: `/superset/dashboard/${dashboardId}/`;
|
||||
return <Link to={url}>{dashboard.dashboard_title}</Link>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'modifications',
|
||||
Header: t('Modifications'),
|
||||
size: 'xxl',
|
||||
id: 'modifications',
|
||||
disableSortBy: true,
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { modifications },
|
||||
},
|
||||
}: {
|
||||
row: { original: WhatIfSimulation };
|
||||
}) => {
|
||||
if (modifications.length === 0) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
<ModificationsContainer>
|
||||
<ModificationTagsRow>
|
||||
{modifications.map((mod, idx) => (
|
||||
<ModificationTag
|
||||
key={`${mod.column}-${idx}`}
|
||||
modification={mod}
|
||||
columnVerboseNames={columnVerboseNames}
|
||||
/>
|
||||
))}
|
||||
</ModificationTagsRow>
|
||||
</ModificationsContainer>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'changedOn',
|
||||
Header: t('Last modified'),
|
||||
size: 'md',
|
||||
id: 'changedOn',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { changedOn },
|
||||
},
|
||||
}: {
|
||||
row: { original: WhatIfSimulation };
|
||||
}) => (changedOn ? dayjs(changedOn).format('ll') : '-'),
|
||||
},
|
||||
{
|
||||
Cell: ({
|
||||
row: { original },
|
||||
}: {
|
||||
row: { original: WhatIfSimulation };
|
||||
}) => {
|
||||
const handleEdit = () => setSimulationCurrentlyEditing(original);
|
||||
const handleDelete = () => setSimulationCurrentlyDeleting(original);
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'edit-action',
|
||||
tooltip: t('Edit modifications'),
|
||||
placement: 'bottom',
|
||||
icon: 'EditOutlined',
|
||||
onClick: handleEdit,
|
||||
},
|
||||
{
|
||||
label: 'delete-action',
|
||||
tooltip: t('Delete simulation'),
|
||||
placement: 'bottom',
|
||||
icon: 'DeleteOutlined',
|
||||
onClick: handleDelete,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ListViewActionsBar actions={actions as ListViewActionProps[]} />
|
||||
);
|
||||
},
|
||||
Header: t('Actions'),
|
||||
id: 'actions',
|
||||
size: 'sm',
|
||||
disableSortBy: true,
|
||||
},
|
||||
],
|
||||
[dashboards, columnVerboseNames],
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
title: t('No simulations yet'),
|
||||
image: 'filter-results.svg',
|
||||
description: t(
|
||||
'Create your first What-If simulation from the What-If panel in a dashboard.',
|
||||
),
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<SubMenu {...menuData} />
|
||||
<PageContainer>
|
||||
<Skeleton active />
|
||||
</PageContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (simulations.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<SubMenu {...menuData} />
|
||||
<EmptyContainer>
|
||||
<Empty
|
||||
image="simple"
|
||||
description={
|
||||
<>
|
||||
<div
|
||||
css={css`
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
`}
|
||||
>
|
||||
{t('No simulations yet')}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
'Create your first What-If simulation from the What-If panel in a dashboard.',
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</EmptyContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubMenu {...menuData} />
|
||||
{simulationCurrentlyDeleting && (
|
||||
<DeleteModal
|
||||
description={t(
|
||||
'Are you sure you want to delete %s?',
|
||||
simulationCurrentlyDeleting.name,
|
||||
)}
|
||||
onConfirm={() => {
|
||||
if (simulationCurrentlyDeleting) {
|
||||
handleDelete(simulationCurrentlyDeleting);
|
||||
}
|
||||
}}
|
||||
onHide={() => setSimulationCurrentlyDeleting(null)}
|
||||
open
|
||||
title={t('Delete Simulation?')}
|
||||
/>
|
||||
)}
|
||||
{simulationCurrentlyEditing && (
|
||||
<EditSimulationModal
|
||||
simulation={simulationCurrentlyEditing}
|
||||
onHide={() => setSimulationCurrentlyEditing(null)}
|
||||
onSaved={loadSimulations}
|
||||
addSuccessToast={addSuccessToast}
|
||||
addDangerToast={addDangerToast}
|
||||
columnVerboseNames={columnVerboseNames}
|
||||
/>
|
||||
)}
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t(
|
||||
'Are you sure you want to delete the selected simulations?',
|
||||
)}
|
||||
onConfirm={handleBulkDelete}
|
||||
>
|
||||
{confirmDelete => {
|
||||
const bulkActions: ListViewProps['bulkActions'] = [
|
||||
{
|
||||
key: 'delete',
|
||||
name: t('Delete'),
|
||||
onSelect: confirmDelete,
|
||||
type: 'danger',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ListView<WhatIfSimulation>
|
||||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={false}
|
||||
columns={columns}
|
||||
count={simulations.length}
|
||||
data={simulations}
|
||||
emptyState={emptyState}
|
||||
fetchData={() => {}}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshData={loadSimulations}
|
||||
initialSort={initialSort}
|
||||
loading={loading}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ConfirmStatusChange>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withToasts(WhatIfSimulationList);
|
||||
@@ -138,6 +138,13 @@ const RowLevelSecurityList = lazy(
|
||||
),
|
||||
);
|
||||
|
||||
const WhatIfSimulationList = lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "WhatIfSimulationList" */ 'src/pages/WhatIfSimulationList'
|
||||
),
|
||||
);
|
||||
|
||||
const RolesList = lazy(
|
||||
() => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'),
|
||||
);
|
||||
@@ -289,6 +296,10 @@ export const routes: Routes = [
|
||||
path: '/rowlevelsecurity/list',
|
||||
Component: RowLevelSecurityList,
|
||||
},
|
||||
{
|
||||
path: '/whatif/simulations/',
|
||||
Component: WhatIfSimulationList,
|
||||
},
|
||||
{
|
||||
path: '/sqllab/',
|
||||
Component: SqlLab,
|
||||
|
||||
@@ -1029,6 +1029,16 @@ class ChartDataExtrasSchema(Schema):
|
||||
},
|
||||
allow_none=True,
|
||||
)
|
||||
what_if = fields.Dict(
|
||||
metadata={
|
||||
"description": (
|
||||
"What-if analysis configuration. Contains modifications to apply "
|
||||
"to column values for simulation purposes."
|
||||
),
|
||||
"example": {"modifications": [{"column": "revenue", "multiplier": 1.1}]},
|
||||
},
|
||||
allow_none=True,
|
||||
)
|
||||
|
||||
|
||||
class AnnotationLayerSchema(Schema):
|
||||
|
||||
@@ -1145,6 +1145,18 @@ QUERY_LOGGER = None
|
||||
# Set this API key to enable Mapbox visualizations
|
||||
MAPBOX_API_KEY = os.environ.get("MAPBOX_API_KEY", "")
|
||||
|
||||
# ---------------------------------------------------
|
||||
# What-If AI Interpretation Configuration
|
||||
# ---------------------------------------------------
|
||||
# API key for OpenRouter (required for AI interpretation of what-if analysis)
|
||||
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
|
||||
# Model to use for interpretation (default: x-ai/grok-4.1-fast)
|
||||
OPENROUTER_MODEL = "x-ai/grok-4.1-fast"
|
||||
# API base URL for OpenRouter
|
||||
OPENROUTER_API_BASE = "https://openrouter.ai/api/v1"
|
||||
# Request timeout in seconds
|
||||
OPENROUTER_TIMEOUT = 30
|
||||
|
||||
# Maximum number of rows returned for any analytical database query
|
||||
SQL_MAX_ROW = 100000
|
||||
|
||||
|
||||
@@ -1516,11 +1516,172 @@ class SqlaTable(
|
||||
def get_from_clause(
|
||||
self,
|
||||
template_processor: BaseTemplateProcessor | None = None,
|
||||
what_if: dict[str, Any] | None = None,
|
||||
) -> tuple[TableClause | Alias, str | None]:
|
||||
if not self.is_virtual:
|
||||
return self.get_sqla_table(), None
|
||||
tbl = self.get_sqla_table()
|
||||
if what_if:
|
||||
tbl = self._apply_what_if_transform(tbl, what_if)
|
||||
return tbl, None
|
||||
|
||||
return super().get_from_clause(template_processor)
|
||||
from_clause, cte = super().get_from_clause(template_processor, what_if=None)
|
||||
if what_if:
|
||||
from_clause = self._apply_what_if_transform(from_clause, what_if)
|
||||
return from_clause, cte
|
||||
|
||||
def _build_what_if_filter_condition(
|
||||
self,
|
||||
filters: list[dict[str, Any]],
|
||||
) -> ColumnElement | None:
|
||||
"""
|
||||
Build a SQLAlchemy condition from a list of what-if filters.
|
||||
|
||||
Supports operators: ==, !=, >, <, >=, <=, IN, NOT IN, TEMPORAL_RANGE
|
||||
|
||||
:param filters: List of filter dicts with 'col', 'op', and 'val' keys
|
||||
:returns: Combined SQLAlchemy condition (ANDed together), or None if no valid filters
|
||||
"""
|
||||
from superset.common.utils.time_range_utils import (
|
||||
get_since_until_from_time_range,
|
||||
)
|
||||
from superset.utils.core import FilterOperator
|
||||
|
||||
conditions: list[ColumnElement] = []
|
||||
# Build a map of column name -> column object for quick lookup
|
||||
columns_by_name = {col.column_name: col for col in self.columns}
|
||||
|
||||
for flt in filters:
|
||||
col_name = flt.get("col")
|
||||
op = flt.get("op")
|
||||
val = flt.get("val")
|
||||
|
||||
# Skip if column doesn't exist in datasource
|
||||
if col_name not in columns_by_name:
|
||||
continue
|
||||
|
||||
sqla_col = sa.column(col_name)
|
||||
|
||||
if op == FilterOperator.EQUALS:
|
||||
conditions.append(sqla_col == val)
|
||||
elif op == FilterOperator.NOT_EQUALS:
|
||||
conditions.append(sqla_col != val)
|
||||
elif op == FilterOperator.GREATER_THAN:
|
||||
conditions.append(sqla_col > val)
|
||||
elif op == FilterOperator.LESS_THAN:
|
||||
conditions.append(sqla_col < val)
|
||||
elif op == FilterOperator.GREATER_THAN_OR_EQUALS:
|
||||
conditions.append(sqla_col >= val)
|
||||
elif op == FilterOperator.LESS_THAN_OR_EQUALS:
|
||||
conditions.append(sqla_col <= val)
|
||||
elif op == FilterOperator.IN:
|
||||
if isinstance(val, list):
|
||||
conditions.append(sqla_col.in_(val))
|
||||
elif op == FilterOperator.NOT_IN:
|
||||
if isinstance(val, list):
|
||||
conditions.append(~sqla_col.in_(val))
|
||||
elif op == FilterOperator.TEMPORAL_RANGE:
|
||||
# Parse time range string like "2024-01-01 : 2024-03-31" or "Last week"
|
||||
if isinstance(val, str):
|
||||
since, until = get_since_until_from_time_range(time_range=val)
|
||||
time_conditions = []
|
||||
col_obj = columns_by_name[col_name]
|
||||
if since:
|
||||
# Convert datetime to database-specific SQL literal
|
||||
since_sql = self.dttm_sql_literal(since, col_obj)
|
||||
time_conditions.append(sqla_col >= sa.literal_column(since_sql))
|
||||
if until:
|
||||
until_sql = self.dttm_sql_literal(until, col_obj)
|
||||
time_conditions.append(sqla_col < sa.literal_column(until_sql))
|
||||
if time_conditions:
|
||||
conditions.append(and_(*time_conditions))
|
||||
|
||||
if not conditions:
|
||||
return None
|
||||
|
||||
return and_(*conditions)
|
||||
|
||||
def _apply_what_if_transform(
|
||||
self,
|
||||
source: TableClause | Alias,
|
||||
what_if: dict[str, Any],
|
||||
) -> Alias:
|
||||
"""
|
||||
Wrap the source table/subquery with a subquery that applies
|
||||
column transformations for what-if analysis.
|
||||
|
||||
:param source: Original table or subquery to transform
|
||||
:param what_if: Dict containing 'modifications' list with column/multiplier
|
||||
pairs and 'needed_columns' set with columns required by the query
|
||||
:returns: Aliased subquery with transformations applied
|
||||
"""
|
||||
modifications = what_if.get("modifications", [])
|
||||
if not modifications:
|
||||
return source # type: ignore
|
||||
|
||||
# Build a dict of column -> modification config (including filters)
|
||||
mod_map = {m["column"]: m for m in modifications}
|
||||
|
||||
# Get columns needed by the query + modified columns
|
||||
# None means we need all columns (e.g., for complex SQL metrics)
|
||||
needed_columns: set[str] | None = what_if.get("needed_columns")
|
||||
modified_column_names = set(mod_map.keys())
|
||||
|
||||
# Collect columns used in filters
|
||||
filter_columns: set[str] = set()
|
||||
for mod in modifications:
|
||||
for flt in mod.get("filters", []):
|
||||
if col_name := flt.get("col"):
|
||||
filter_columns.add(col_name)
|
||||
|
||||
# Determine which columns to select
|
||||
available_columns = {col.column_name for col in self.columns}
|
||||
if needed_columns is None:
|
||||
# Use all available columns
|
||||
columns_to_select = available_columns
|
||||
else:
|
||||
# Use only needed columns + modified columns + filter columns
|
||||
columns_to_select = needed_columns | modified_column_names | filter_columns
|
||||
|
||||
# Build select list with only needed columns
|
||||
select_columns = []
|
||||
|
||||
for col_name in columns_to_select:
|
||||
# Skip columns that don't exist in the datasource
|
||||
if col_name not in available_columns:
|
||||
continue
|
||||
|
||||
if col_name in mod_map:
|
||||
mod = mod_map[col_name]
|
||||
multiplier = mod["multiplier"]
|
||||
filters = mod.get("filters", [])
|
||||
col_ref = sa.column(col_name)
|
||||
|
||||
if filters:
|
||||
# Build conditional transformation with CASE WHEN
|
||||
condition = self._build_what_if_filter_condition(filters)
|
||||
if condition is not None:
|
||||
transformed = sa.case(
|
||||
(condition, col_ref * sa.literal(multiplier)),
|
||||
else_=col_ref,
|
||||
).label(col_name)
|
||||
else:
|
||||
# No valid filter conditions, apply unconditionally
|
||||
transformed = (col_ref * sa.literal(multiplier)).label(col_name)
|
||||
else:
|
||||
# No filters, apply transformation to all rows
|
||||
transformed = (col_ref * sa.literal(multiplier)).label(col_name)
|
||||
|
||||
select_columns.append(transformed)
|
||||
else:
|
||||
select_columns.append(sa.column(col_name))
|
||||
|
||||
if not select_columns:
|
||||
# Fallback: if no columns to select, return source unchanged
|
||||
return source # type: ignore
|
||||
|
||||
# Create subquery with transformations
|
||||
subq = sa.select(*select_columns).select_from(source)
|
||||
return subq.alias("__what_if")
|
||||
|
||||
def adhoc_metric_to_sqla(
|
||||
self,
|
||||
|
||||
106
superset/daos/what_if_simulation.py
Normal file
106
superset/daos/what_if_simulation.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""DAO for What-If Simulation persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.extensions import db
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.what_if.models import WhatIfSimulation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WhatIfSimulationDAO(BaseDAO[WhatIfSimulation]):
|
||||
"""Data access object for What-If Simulations."""
|
||||
|
||||
@classmethod
|
||||
def find_by_dashboard_and_user(
|
||||
cls,
|
||||
dashboard_id: int,
|
||||
user_id: Optional[int] = None,
|
||||
) -> list[WhatIfSimulation]:
|
||||
"""
|
||||
Find all simulations for a dashboard owned by a specific user.
|
||||
|
||||
:param dashboard_id: The dashboard ID
|
||||
:param user_id: The user ID (defaults to current user)
|
||||
:returns: List of simulations
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = get_user_id()
|
||||
|
||||
return (
|
||||
db.session.query(WhatIfSimulation)
|
||||
.filter(
|
||||
WhatIfSimulation.dashboard_id == dashboard_id,
|
||||
WhatIfSimulation.user_id == user_id,
|
||||
)
|
||||
.order_by(WhatIfSimulation.changed_on.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find_all_for_user(
|
||||
cls,
|
||||
user_id: Optional[int] = None,
|
||||
) -> list[WhatIfSimulation]:
|
||||
"""
|
||||
Find all simulations owned by a user across all dashboards.
|
||||
|
||||
:param user_id: The user ID (defaults to current user)
|
||||
:returns: List of simulations
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = get_user_id()
|
||||
|
||||
return (
|
||||
db.session.query(WhatIfSimulation)
|
||||
.filter(WhatIfSimulation.user_id == user_id)
|
||||
.order_by(WhatIfSimulation.changed_on.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_name_uniqueness(
|
||||
cls,
|
||||
name: str,
|
||||
dashboard_id: int,
|
||||
user_id: int,
|
||||
simulation_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Validate if simulation name is unique for this dashboard/user combo.
|
||||
|
||||
:param name: The simulation name
|
||||
:param dashboard_id: The dashboard ID
|
||||
:param user_id: The user ID
|
||||
:param simulation_id: Optional simulation ID (for updates)
|
||||
:returns: True if unique, False otherwise
|
||||
"""
|
||||
query = db.session.query(WhatIfSimulation).filter(
|
||||
WhatIfSimulation.name == name,
|
||||
WhatIfSimulation.dashboard_id == dashboard_id,
|
||||
WhatIfSimulation.user_id == user_id,
|
||||
)
|
||||
if simulation_id:
|
||||
query = query.filter(WhatIfSimulation.id != simulation_id)
|
||||
return not db.session.query(query.exists()).scalar()
|
||||
@@ -223,6 +223,8 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
from superset.views.user_registrations import UserRegistrationsView
|
||||
from superset.views.users.api import CurrentUserRestApi, UserRestApi
|
||||
from superset.views.users_list import UsersListView
|
||||
from superset.views.what_if import WhatIfSimulationView
|
||||
from superset.what_if.api import WhatIfRestApi
|
||||
|
||||
set_app_error_handlers(self.superset_app)
|
||||
self.register_request_handlers()
|
||||
@@ -266,6 +268,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
appbuilder.add_api(RLSRestApi)
|
||||
appbuilder.add_api(SavedQueryRestApi)
|
||||
appbuilder.add_api(TagRestApi)
|
||||
appbuilder.add_api(WhatIfRestApi)
|
||||
appbuilder.add_api(SqlLabRestApi)
|
||||
appbuilder.add_api(SqlLabPermalinkRestApi)
|
||||
appbuilder.add_api(LogRestApi)
|
||||
@@ -430,6 +433,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
appbuilder.add_view_no_menu(RoleRestAPI)
|
||||
appbuilder.add_view_no_menu(UserInfoView)
|
||||
|
||||
appbuilder.add_view(
|
||||
WhatIfSimulationView,
|
||||
"What-If Simulations",
|
||||
label=_("What-if simulations"),
|
||||
href="/whatif/simulations/",
|
||||
icon="fa-flask",
|
||||
category="Manage",
|
||||
category_label=_("Manage"),
|
||||
)
|
||||
|
||||
#
|
||||
# Add links
|
||||
#
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""add_what_if_simulations
|
||||
|
||||
Revision ID: b8f3a2c9d1e5
|
||||
Revises: a9c01ec10479
|
||||
Create Date: 2025-12-19 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset.migrations.shared.utils import (
|
||||
create_fks_for_table,
|
||||
create_table,
|
||||
drop_table,
|
||||
)
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b8f3a2c9d1e5"
|
||||
down_revision = "a9c01ec10479"
|
||||
|
||||
|
||||
def upgrade():
|
||||
create_table(
|
||||
"what_if_simulations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("uuid", UUIDType(binary=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=256), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("dashboard_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"modifications_json",
|
||||
sa.Text().with_variant(mysql.MEDIUMTEXT(), "mysql"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"cascading_effects_enabled",
|
||||
sa.Boolean(),
|
||||
server_default=sa.false(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("changed_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("uuid"),
|
||||
)
|
||||
|
||||
# Create index for fast lookup by dashboard_id + user_id
|
||||
op.create_index(
|
||||
"ix_what_if_simulations_dashboard_user",
|
||||
"what_if_simulations",
|
||||
["dashboard_id", "user_id"],
|
||||
)
|
||||
|
||||
# Create foreign key constraints
|
||||
create_fks_for_table(
|
||||
"fk_what_if_simulations_dashboard_id_dashboards",
|
||||
"what_if_simulations",
|
||||
"dashboards",
|
||||
["dashboard_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
create_fks_for_table(
|
||||
"fk_what_if_simulations_user_id_ab_user",
|
||||
"what_if_simulations",
|
||||
"ab_user",
|
||||
["user_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
create_fks_for_table(
|
||||
"fk_what_if_simulations_created_by_fk_ab_user",
|
||||
"what_if_simulations",
|
||||
"ab_user",
|
||||
["created_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
create_fks_for_table(
|
||||
"fk_what_if_simulations_changed_by_fk_ab_user",
|
||||
"what_if_simulations",
|
||||
"ab_user",
|
||||
["changed_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index(
|
||||
"ix_what_if_simulations_dashboard_user",
|
||||
table_name="what_if_simulations",
|
||||
)
|
||||
drop_table("what_if_simulations")
|
||||
@@ -63,6 +63,9 @@ from sqlalchemy.sql.expression import Label, Select, TextAsFrom
|
||||
from sqlalchemy.sql.selectable import Alias, TableClause
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
import sqlglot
|
||||
from sqlglot import exp
|
||||
|
||||
from superset import db, is_feature_enabled
|
||||
from superset.advanced_data_type.types import AdvancedDataTypeResponse
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
@@ -2016,7 +2019,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
return self.db_engine_spec.get_text_clause(clause)
|
||||
|
||||
def get_from_clause(
|
||||
self, template_processor: Optional[BaseTemplateProcessor] = None
|
||||
self,
|
||||
template_processor: Optional[BaseTemplateProcessor] = None,
|
||||
what_if: Optional[dict[str, Any]] = None,
|
||||
) -> tuple[Union[TableClause, Alias], Optional[str]]:
|
||||
"""
|
||||
Return where to select the columns and metrics from. Either a physical table
|
||||
@@ -2060,6 +2065,161 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
|
||||
return from_clause, cte
|
||||
|
||||
def _extract_columns_from_sql(
|
||||
self, sql_expression: str, available_columns: set[str]
|
||||
) -> set[str]:
|
||||
"""
|
||||
Extract column references from a SQL expression using sqlglot.
|
||||
|
||||
:param sql_expression: The SQL expression to parse
|
||||
:param available_columns: Set of known column names in the dataset
|
||||
:returns: Set of column names found in the expression that exist in available_columns
|
||||
"""
|
||||
try:
|
||||
# Parse the expression as a SELECT statement to handle it properly
|
||||
parsed = sqlglot.parse_one(f"SELECT {sql_expression}")
|
||||
found_columns: set[str] = set()
|
||||
for column in parsed.find_all(exp.Column):
|
||||
col_name = column.name
|
||||
if col_name in available_columns:
|
||||
found_columns.add(col_name)
|
||||
return found_columns
|
||||
except Exception: # noqa: BLE001
|
||||
# If parsing fails, return all available columns as fallback
|
||||
return available_columns
|
||||
|
||||
def _collect_needed_columns( # noqa: C901
|
||||
self,
|
||||
columns: Optional[list[Column]] = None,
|
||||
groupby: Optional[list[Column]] = None,
|
||||
metrics: Optional[list[Metric]] = None,
|
||||
filter: Optional[list[utils.QueryObjectFilterClause]] = None,
|
||||
orderby: Optional[list[OrderBy]] = None,
|
||||
granularity: Optional[str] = None,
|
||||
extras: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[set[str]]:
|
||||
"""
|
||||
Collect all column names needed by the query for what-if transformation.
|
||||
This allows us to only select necessary columns instead of SELECT *.
|
||||
|
||||
:returns: Set of column names that are referenced by the query,
|
||||
or None if all columns should be included (e.g., for complex metrics)
|
||||
"""
|
||||
needed: set[str] = set()
|
||||
available_columns = {col.column_name for col in self.columns}
|
||||
|
||||
# Add granularity column (time column)
|
||||
if granularity:
|
||||
needed.add(granularity)
|
||||
|
||||
# Add columns from dimensions/columns list
|
||||
for col in columns or []:
|
||||
if isinstance(col, str):
|
||||
needed.add(col)
|
||||
elif isinstance(col, dict):
|
||||
if sql_expr := col.get("sqlExpression"):
|
||||
# Check if it's just a simple column reference
|
||||
if col.get("isColumnReference"):
|
||||
needed.add(sql_expr)
|
||||
else:
|
||||
# Parse SQL expression to extract column references
|
||||
needed.update(
|
||||
self._extract_columns_from_sql(sql_expr, available_columns)
|
||||
)
|
||||
elif col.get("column_name"):
|
||||
needed.add(col["column_name"])
|
||||
|
||||
# Add columns from groupby
|
||||
for col in groupby or []:
|
||||
if isinstance(col, str):
|
||||
needed.add(col)
|
||||
elif isinstance(col, dict):
|
||||
if sql_expr := col.get("sqlExpression"):
|
||||
# Check if it's just a simple column reference
|
||||
if col.get("isColumnReference"):
|
||||
needed.add(sql_expr)
|
||||
else:
|
||||
# Parse SQL expression to extract column references
|
||||
needed.update(
|
||||
self._extract_columns_from_sql(sql_expr, available_columns)
|
||||
)
|
||||
elif col.get("column_name"):
|
||||
needed.add(col["column_name"])
|
||||
|
||||
# Add columns from metrics
|
||||
for metric in metrics or []:
|
||||
if isinstance(metric, str):
|
||||
# Saved metric - we need to look it up to find the expression
|
||||
# For now, fallback to selecting all columns
|
||||
return None
|
||||
elif isinstance(metric, dict):
|
||||
expression_type = metric.get("expressionType")
|
||||
if expression_type == "SQL":
|
||||
# Parse SQL expression to extract column references
|
||||
if sql_expr := metric.get("sqlExpression"):
|
||||
needed.update(
|
||||
self._extract_columns_from_sql(sql_expr, available_columns)
|
||||
)
|
||||
else:
|
||||
# SIMPLE adhoc metric - check for column reference
|
||||
metric_column = metric.get("column")
|
||||
if isinstance(metric_column, dict):
|
||||
col_name = metric_column.get("column_name")
|
||||
if isinstance(col_name, str):
|
||||
needed.add(col_name)
|
||||
|
||||
# Add columns from filters
|
||||
for flt in filter or []:
|
||||
col = flt.get("col")
|
||||
if isinstance(col, str):
|
||||
needed.add(col)
|
||||
elif isinstance(col, dict):
|
||||
if sql_expr := col.get("sqlExpression"):
|
||||
# Check if it's just a simple column reference
|
||||
if col.get("isColumnReference"):
|
||||
needed.add(sql_expr)
|
||||
else:
|
||||
# Parse SQL expression to extract column references
|
||||
needed.update(
|
||||
self._extract_columns_from_sql(sql_expr, available_columns)
|
||||
)
|
||||
elif col.get("column_name"):
|
||||
needed.add(col["column_name"])
|
||||
|
||||
# Add columns from orderby
|
||||
for order_item in orderby or []:
|
||||
if isinstance(order_item, (list, tuple)) and len(order_item) >= 1:
|
||||
col = order_item[0]
|
||||
if isinstance(col, str):
|
||||
needed.add(col)
|
||||
elif isinstance(col, dict):
|
||||
if sql_expr := col.get("sqlExpression"):
|
||||
# Check if it's just a simple column reference
|
||||
if col.get("isColumnReference"):
|
||||
needed.add(sql_expr)
|
||||
else:
|
||||
# Parse SQL expression to extract column references
|
||||
needed.update(
|
||||
self._extract_columns_from_sql(
|
||||
sql_expr, available_columns
|
||||
)
|
||||
)
|
||||
elif col.get("column_name"):
|
||||
needed.add(col["column_name"])
|
||||
|
||||
# Add columns from extras.where and extras.having (raw SQL clauses)
|
||||
if extras:
|
||||
if where := extras.get("where"):
|
||||
needed.update(
|
||||
self._extract_columns_from_sql(where, available_columns)
|
||||
)
|
||||
if having := extras.get("having"):
|
||||
needed.update(
|
||||
self._extract_columns_from_sql(having, available_columns)
|
||||
)
|
||||
|
||||
return needed
|
||||
|
||||
def adhoc_metric_to_sqla(
|
||||
self,
|
||||
metric: AdhocMetric,
|
||||
@@ -2879,7 +3039,20 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
|
||||
# Process FROM clause early to populate removed_filters from virtual dataset
|
||||
# templates before we decide whether to add time filters
|
||||
tbl, cte = self.get_from_clause(template_processor)
|
||||
what_if = extras.get("what_if") if extras else None
|
||||
if what_if:
|
||||
# Collect columns needed by the query for efficient what-if transformation
|
||||
what_if = dict(what_if) # Copy to avoid mutating original
|
||||
what_if["needed_columns"] = self._collect_needed_columns(
|
||||
columns=columns,
|
||||
groupby=groupby,
|
||||
metrics=metrics,
|
||||
filter=filter,
|
||||
orderby=orderby,
|
||||
granularity=granularity,
|
||||
extras=extras,
|
||||
)
|
||||
tbl, cte = self.get_from_clause(template_processor, what_if=what_if)
|
||||
|
||||
if granularity:
|
||||
if granularity not in columns_by_name or not dttm_col:
|
||||
|
||||
39
superset/views/what_if.py
Normal file
39
superset/views/what_if.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""View for What-If simulations list page."""
|
||||
|
||||
from flask_appbuilder import permission_name
|
||||
from flask_appbuilder.api import expose
|
||||
from flask_appbuilder.security.decorators import has_access
|
||||
|
||||
from superset.superset_typing import FlaskResponse
|
||||
|
||||
from .base import BaseSupersetView
|
||||
|
||||
|
||||
class WhatIfSimulationView(BaseSupersetView):
|
||||
"""View for the What-If simulations list page."""
|
||||
|
||||
route_base = "/whatif"
|
||||
class_permission_name = "Dashboard"
|
||||
|
||||
@expose("/simulations/")
|
||||
@has_access
|
||||
@permission_name("read")
|
||||
def list(self) -> FlaskResponse:
|
||||
"""Render the What-If simulations list page."""
|
||||
return super().render_app_template()
|
||||
17
superset/what_if/__init__.py
Normal file
17
superset/what_if/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""What-If Analysis module for AI-powered interpretation of scenario analysis."""
|
||||
473
superset/what_if/api.py
Normal file
473
superset/what_if/api.py
Normal file
@@ -0,0 +1,473 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""What-If Analysis REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import request, Response
|
||||
from flask_appbuilder.api import expose, protect, safe
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
from superset.daos.what_if_simulation import WhatIfSimulationDAO
|
||||
from superset.extensions import event_logger
|
||||
from superset.utils import json
|
||||
from superset.views.base_api import BaseSupersetApi, statsd_metrics
|
||||
from superset.what_if.commands.interpret import WhatIfInterpretCommand
|
||||
from superset.what_if.commands.simulation_create import CreateWhatIfSimulationCommand
|
||||
from superset.what_if.commands.simulation_delete import DeleteWhatIfSimulationCommand
|
||||
from superset.what_if.commands.simulation_update import UpdateWhatIfSimulationCommand
|
||||
from superset.what_if.commands.suggest_related import WhatIfSuggestRelatedCommand
|
||||
from superset.what_if.exceptions import (
|
||||
OpenRouterAPIError,
|
||||
OpenRouterConfigError,
|
||||
WhatIfSimulationCreateFailedError,
|
||||
WhatIfSimulationDeleteFailedError,
|
||||
WhatIfSimulationForbiddenError,
|
||||
WhatIfSimulationInvalidError,
|
||||
WhatIfSimulationNotFoundError,
|
||||
WhatIfSimulationUpdateFailedError,
|
||||
)
|
||||
from superset.what_if.schemas import (
|
||||
WhatIfInterpretRequestSchema,
|
||||
WhatIfInterpretResponseSchema,
|
||||
WhatIfSimulationPostSchema,
|
||||
WhatIfSimulationPutSchema,
|
||||
WhatIfSuggestRelatedRequestSchema,
|
||||
WhatIfSuggestRelatedResponseSchema,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WhatIfRestApi(BaseSupersetApi):
|
||||
"""REST API for What-If Analysis features."""
|
||||
|
||||
resource_name = "what_if"
|
||||
allow_browser_login = True
|
||||
openapi_spec_tag = "What-If Analysis"
|
||||
|
||||
# Use Dashboard permissions since what-if is a dashboard feature
|
||||
class_permission_name = "Dashboard"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
|
||||
@expose("/interpret", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@event_logger.log_this
|
||||
@statsd_metrics
|
||||
def interpret(self) -> Response:
|
||||
"""Generate AI interpretation of what-if analysis results.
|
||||
---
|
||||
post:
|
||||
summary: Generate AI interpretation of what-if changes
|
||||
description: >-
|
||||
Sends what-if modification data to an LLM for business interpretation.
|
||||
Returns a summary and actionable insights.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WhatIfInterpretRequestSchema'
|
||||
responses:
|
||||
200:
|
||||
description: AI interpretation generated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/WhatIfInterpretResponseSchema'
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
502:
|
||||
description: Error communicating with AI service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
security:
|
||||
- jwt: []
|
||||
"""
|
||||
try:
|
||||
request_data = WhatIfInterpretRequestSchema().load(request.json)
|
||||
except ValidationError as ex:
|
||||
logger.warning("Invalid request data: %s", ex.messages)
|
||||
return self.response_400(message=str(ex.messages))
|
||||
|
||||
try:
|
||||
command = WhatIfInterpretCommand(request_data)
|
||||
result = command.run()
|
||||
return self.response(
|
||||
200, result=WhatIfInterpretResponseSchema().dump(result)
|
||||
)
|
||||
except OpenRouterConfigError as ex:
|
||||
logger.error("OpenRouter configuration error: %s", ex)
|
||||
return self.response(500, message="AI interpretation is not configured")
|
||||
except OpenRouterAPIError as ex:
|
||||
logger.error("OpenRouter API error: %s", ex)
|
||||
return self.response(502, message=str(ex))
|
||||
except ValueError as ex:
|
||||
logger.warning("Invalid request: %s", ex)
|
||||
return self.response_400(message=str(ex))
|
||||
|
||||
@expose("/suggest_related", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@event_logger.log_this
|
||||
@statsd_metrics
|
||||
def suggest_related(self) -> Response:
|
||||
"""Get AI suggestions for related column modifications.
|
||||
---
|
||||
post:
|
||||
summary: Get AI-suggested cascading column modifications
|
||||
description: >-
|
||||
Analyzes column relationships and suggests related columns
|
||||
that should be modified when a user modifies a specific column.
|
||||
Uses AI to infer causal, mathematical, and domain-specific relationships.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WhatIfSuggestRelatedRequestSchema'
|
||||
responses:
|
||||
200:
|
||||
description: Related column suggestions generated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/WhatIfSuggestRelatedResponseSchema'
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
502:
|
||||
description: Error communicating with AI service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
security:
|
||||
- jwt: []
|
||||
"""
|
||||
try:
|
||||
request_data = WhatIfSuggestRelatedRequestSchema().load(request.json)
|
||||
except ValidationError as ex:
|
||||
logger.warning("Invalid request data: %s", ex.messages)
|
||||
return self.response_400(message=str(ex.messages))
|
||||
|
||||
try:
|
||||
command = WhatIfSuggestRelatedCommand(request_data)
|
||||
result = command.run()
|
||||
return self.response(
|
||||
200, result=WhatIfSuggestRelatedResponseSchema().dump(result)
|
||||
)
|
||||
except OpenRouterConfigError as ex:
|
||||
logger.error("OpenRouter configuration error: %s", ex)
|
||||
return self.response(500, message="AI suggestions are not configured")
|
||||
except OpenRouterAPIError as ex:
|
||||
logger.error("OpenRouter API error: %s", ex)
|
||||
return self.response(502, message=str(ex))
|
||||
except ValueError as ex:
|
||||
logger.warning("Invalid request: %s", ex)
|
||||
return self.response_400(message=str(ex))
|
||||
|
||||
# =========================================================================
|
||||
# Simulation CRUD Endpoints
|
||||
# =========================================================================
|
||||
|
||||
@expose("/simulations", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@event_logger.log_this
|
||||
@statsd_metrics
|
||||
def list_all_simulations(self) -> Response:
|
||||
"""List all saved simulations for the current user across all dashboards.
|
||||
---
|
||||
get:
|
||||
summary: List all What-If simulations for current user
|
||||
responses:
|
||||
200:
|
||||
description: List of simulations
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: array
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
security:
|
||||
- jwt: []
|
||||
"""
|
||||
simulations = WhatIfSimulationDAO.find_all_for_user()
|
||||
result = [
|
||||
{
|
||||
"id": sim.id,
|
||||
"uuid": str(sim.uuid),
|
||||
"name": sim.name,
|
||||
"description": sim.description,
|
||||
"dashboard_id": sim.dashboard_id,
|
||||
"modifications": json.loads(sim.modifications_json),
|
||||
"cascading_effects_enabled": sim.cascading_effects_enabled,
|
||||
"created_on": sim.created_on.isoformat() if sim.created_on else None,
|
||||
"changed_on": sim.changed_on.isoformat() if sim.changed_on else None,
|
||||
}
|
||||
for sim in simulations
|
||||
]
|
||||
return self.response(200, result=result)
|
||||
|
||||
@expose("/simulations/dashboard/<int:dashboard_id>", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@event_logger.log_this
|
||||
@statsd_metrics
|
||||
def list_simulations(self, dashboard_id: int) -> Response:
|
||||
"""List all saved simulations for a dashboard (current user only).
|
||||
---
|
||||
get:
|
||||
summary: List What-If simulations for a dashboard
|
||||
parameters:
|
||||
- in: path
|
||||
name: dashboard_id
|
||||
schema:
|
||||
type: integer
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: List of simulations
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: array
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
security:
|
||||
- jwt: []
|
||||
"""
|
||||
simulations = WhatIfSimulationDAO.find_by_dashboard_and_user(dashboard_id)
|
||||
result = [
|
||||
{
|
||||
"id": sim.id,
|
||||
"uuid": str(sim.uuid),
|
||||
"name": sim.name,
|
||||
"description": sim.description,
|
||||
"modifications": json.loads(sim.modifications_json),
|
||||
"cascading_effects_enabled": sim.cascading_effects_enabled,
|
||||
"created_on": sim.created_on.isoformat() if sim.created_on else None,
|
||||
"changed_on": sim.changed_on.isoformat() if sim.changed_on else None,
|
||||
}
|
||||
for sim in simulations
|
||||
]
|
||||
return self.response(200, result=result)
|
||||
|
||||
@expose("/simulations", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@event_logger.log_this
|
||||
@statsd_metrics
|
||||
def create_simulation(self) -> Response:
|
||||
"""Create a new What-If simulation.
|
||||
---
|
||||
post:
|
||||
summary: Save a new What-If simulation
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WhatIfSimulationPostSchema'
|
||||
responses:
|
||||
201:
|
||||
description: Simulation created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
uuid:
|
||||
type: string
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
- jwt: []
|
||||
"""
|
||||
try:
|
||||
data = WhatIfSimulationPostSchema().load(request.json)
|
||||
except ValidationError as ex:
|
||||
logger.warning("Invalid request data: %s", ex.messages)
|
||||
return self.response_400(message=str(ex.messages))
|
||||
|
||||
# Serialize modifications to JSON
|
||||
data["modifications_json"] = json.dumps(data.pop("modifications"))
|
||||
|
||||
try:
|
||||
simulation = CreateWhatIfSimulationCommand(data).run()
|
||||
return self.response(
|
||||
201,
|
||||
id=simulation.id,
|
||||
uuid=str(simulation.uuid),
|
||||
)
|
||||
except WhatIfSimulationInvalidError as ex:
|
||||
return self.response_422(message=ex.normalized_messages())
|
||||
except WhatIfSimulationCreateFailedError as ex:
|
||||
logger.error("Error creating simulation: %s", ex)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/simulations/<int:pk>", methods=("PUT",))
|
||||
@protect()
|
||||
@safe
|
||||
@event_logger.log_this
|
||||
@statsd_metrics
|
||||
def update_simulation(self, pk: int) -> Response:
|
||||
"""Update a What-If simulation.
|
||||
---
|
||||
put:
|
||||
summary: Update a What-If simulation
|
||||
parameters:
|
||||
- in: path
|
||||
name: pk
|
||||
schema:
|
||||
type: integer
|
||||
required: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WhatIfSimulationPutSchema'
|
||||
responses:
|
||||
200:
|
||||
description: Simulation updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
- jwt: []
|
||||
"""
|
||||
try:
|
||||
data = WhatIfSimulationPutSchema().load(request.json)
|
||||
except ValidationError as ex:
|
||||
logger.warning("Invalid request data: %s", ex.messages)
|
||||
return self.response_400(message=str(ex.messages))
|
||||
|
||||
# Serialize modifications to JSON if present
|
||||
if "modifications" in data:
|
||||
data["modifications_json"] = json.dumps(data.pop("modifications"))
|
||||
|
||||
try:
|
||||
simulation = UpdateWhatIfSimulationCommand(pk, data).run()
|
||||
return self.response(200, id=simulation.id)
|
||||
except WhatIfSimulationNotFoundError:
|
||||
return self.response_404()
|
||||
except WhatIfSimulationForbiddenError:
|
||||
return self.response_403()
|
||||
except WhatIfSimulationInvalidError as ex:
|
||||
return self.response_422(message=ex.normalized_messages())
|
||||
except WhatIfSimulationUpdateFailedError as ex:
|
||||
logger.error("Error updating simulation: %s", ex)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/simulations/<int:pk>", methods=("DELETE",))
|
||||
@protect()
|
||||
@safe
|
||||
@event_logger.log_this
|
||||
@statsd_metrics
|
||||
def delete_simulation(self, pk: int) -> Response:
|
||||
"""Delete a What-If simulation.
|
||||
---
|
||||
delete:
|
||||
summary: Delete a What-If simulation
|
||||
parameters:
|
||||
- in: path
|
||||
name: pk
|
||||
schema:
|
||||
type: integer
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: Simulation deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
- jwt: []
|
||||
"""
|
||||
try:
|
||||
DeleteWhatIfSimulationCommand([pk]).run()
|
||||
return self.response(200, message="OK")
|
||||
except WhatIfSimulationNotFoundError:
|
||||
return self.response_404()
|
||||
except WhatIfSimulationForbiddenError:
|
||||
return self.response_403()
|
||||
except WhatIfSimulationDeleteFailedError as ex:
|
||||
logger.error("Error deleting simulation: %s", ex)
|
||||
return self.response_422(message=str(ex))
|
||||
17
superset/what_if/commands/__init__.py
Normal file
17
superset/what_if/commands/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""What-If Analysis commands."""
|
||||
212
superset/what_if/commands/interpret.py
Normal file
212
superset/what_if/commands/interpret.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""What-If Analysis interpretation command using OpenRouter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from flask import current_app
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.utils import json
|
||||
from superset.what_if.exceptions import OpenRouterAPIError, OpenRouterConfigError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WhatIfInterpretCommand(BaseCommand):
|
||||
"""Command to get AI interpretation of what-if analysis results."""
|
||||
|
||||
def __init__(self, data: dict[str, Any]) -> None:
|
||||
self._data = data
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
self.validate()
|
||||
return self._get_ai_interpretation()
|
||||
|
||||
def validate(self) -> None:
|
||||
api_key = current_app.config.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
raise OpenRouterConfigError("OPENROUTER_API_KEY not configured")
|
||||
|
||||
if not self._data.get("modifications"):
|
||||
raise ValueError("At least one modification is required")
|
||||
|
||||
if not self._data.get("charts"):
|
||||
raise ValueError("At least one chart comparison is required")
|
||||
|
||||
def _format_filter(self, flt: dict[str, Any]) -> str:
|
||||
"""Format a single filter for display in the prompt."""
|
||||
col = flt.get("col", "")
|
||||
op = flt.get("op", "")
|
||||
val = flt.get("val", "")
|
||||
|
||||
# Format the value based on type
|
||||
if isinstance(val, list):
|
||||
val_str = ", ".join(str(v) for v in val)
|
||||
return f"{col} {op} [{val_str}]"
|
||||
if isinstance(val, str) and op == "TEMPORAL_RANGE":
|
||||
return f"{col} in time range '{val}'"
|
||||
return f"{col} {op} {val}"
|
||||
|
||||
def _build_prompt(self) -> str:
|
||||
modifications = self._data["modifications"]
|
||||
charts = self._data["charts"]
|
||||
dashboard_name = self._data.get("dashboard_name") or "Dashboard"
|
||||
|
||||
# Build modification description
|
||||
mod_descriptions = []
|
||||
for mod in modifications:
|
||||
pct_change = (mod["multiplier"] - 1) * 100
|
||||
sign = "+" if pct_change >= 0 else ""
|
||||
base_desc = f"- {mod['column']}: {sign}{pct_change:.1f}%"
|
||||
|
||||
# Add filter conditions if present
|
||||
filters = mod.get("filters") or []
|
||||
if filters:
|
||||
filter_strs = [self._format_filter(f) for f in filters]
|
||||
filter_desc = " AND ".join(filter_strs)
|
||||
base_desc += f" (only where {filter_desc})"
|
||||
|
||||
mod_descriptions.append(base_desc)
|
||||
|
||||
modifications_text = "\n".join(mod_descriptions)
|
||||
|
||||
# Build chart impact summary
|
||||
chart_summaries = []
|
||||
for chart in charts:
|
||||
metrics_text = []
|
||||
for metric in chart["metrics"]:
|
||||
sign = "+" if metric["percentage_change"] >= 0 else ""
|
||||
metrics_text.append(
|
||||
f" - {metric['metric_name']}: "
|
||||
f"{metric['original_value']:,.2f} -> {metric['modified_value']:,.2f} "
|
||||
f"({sign}{metric['percentage_change']:.1f}%)"
|
||||
)
|
||||
chart_summaries.append(
|
||||
f"**{chart['chart_name']}** ({chart['chart_type']}):\n"
|
||||
+ "\n".join(metrics_text)
|
||||
)
|
||||
|
||||
charts_text = "\n\n".join(chart_summaries)
|
||||
|
||||
return f"""You are a business intelligence analyst. A user is performing a what-if analysis on their "{dashboard_name}" dashboard.
|
||||
|
||||
## Scenario
|
||||
The user modified the following column(s):
|
||||
{modifications_text}
|
||||
|
||||
## Impact on Charts
|
||||
{charts_text}
|
||||
|
||||
## Your Task
|
||||
Analyze this what-if scenario and provide:
|
||||
|
||||
1. **Summary**: A 1-2 sentence executive summary of the overall impact.
|
||||
|
||||
2. **Key Observations**: 2-3 specific observations about how the changes affected different metrics.
|
||||
|
||||
3. **Business Implications**: What does this mean for the business? Consider:
|
||||
- Revenue/cost implications
|
||||
- Operational efficiency
|
||||
- Risk factors
|
||||
|
||||
4. **Recommendations**: 1-2 actionable recommendations based on this analysis.
|
||||
|
||||
Please be concise, specific, and focus on business value. Use the actual numbers from the data.
|
||||
|
||||
Respond in JSON format:
|
||||
{{
|
||||
"summary": "...",
|
||||
"insights": [
|
||||
{{"title": "...", "description": "...", "type": "observation"}},
|
||||
{{"title": "...", "description": "...", "type": "implication"}},
|
||||
{{"title": "...", "description": "...", "type": "recommendation"}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
def _get_ai_interpretation(self) -> dict[str, Any]:
|
||||
api_key = current_app.config.get("OPENROUTER_API_KEY")
|
||||
model = current_app.config.get("OPENROUTER_MODEL", "x-ai/grok-4.1-fast")
|
||||
api_base = current_app.config.get(
|
||||
"OPENROUTER_API_BASE", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
timeout = current_app.config.get("OPENROUTER_TIMEOUT", 30)
|
||||
|
||||
prompt = self._build_prompt()
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": current_app.config.get("WEBDRIVER_BASEURL", ""),
|
||||
"X-Title": "Apache Superset What-If Analysis",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a business intelligence analyst. "
|
||||
"Respond only with valid JSON."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 1000,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
response = client.post(
|
||||
f"{api_base}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
|
||||
# Parse the JSON response
|
||||
parsed = json.loads(content)
|
||||
return {
|
||||
"summary": parsed.get("summary", ""),
|
||||
"insights": parsed.get("insights", []),
|
||||
"raw_response": content if current_app.debug else None,
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as ex:
|
||||
logger.error("OpenRouter API error: %s", ex.response.status_code)
|
||||
raise OpenRouterAPIError(
|
||||
f"OpenRouter API error: {ex.response.status_code}"
|
||||
) from ex
|
||||
except json.JSONDecodeError as ex:
|
||||
logger.error("Failed to parse AI response: %s", ex)
|
||||
raise OpenRouterAPIError("Failed to parse AI response") from ex
|
||||
except httpx.TimeoutException as ex:
|
||||
logger.error("OpenRouter API timeout")
|
||||
raise OpenRouterAPIError("AI service timed out") from ex
|
||||
except Exception as ex:
|
||||
logger.exception("Unexpected error calling OpenRouter")
|
||||
raise OpenRouterAPIError(f"Unexpected error: {ex!s}") from ex
|
||||
68
superset/what_if/commands/simulation_create.py
Normal file
68
superset/what_if/commands/simulation_create.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Create What-If Simulation command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.daos.what_if_simulation import WhatIfSimulationDAO
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.what_if.exceptions import (
|
||||
WhatIfSimulationCreateFailedError,
|
||||
WhatIfSimulationInvalidError,
|
||||
WhatIfSimulationNameUniquenessError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateWhatIfSimulationCommand(BaseCommand):
|
||||
"""Command to create a new What-If simulation."""
|
||||
|
||||
def __init__(self, data: dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=WhatIfSimulationCreateFailedError))
|
||||
def run(self) -> Model:
|
||||
self.validate()
|
||||
user_id = get_user_id()
|
||||
self._properties["user_id"] = user_id
|
||||
return WhatIfSimulationDAO.create(attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
exceptions: list[ValidationError] = []
|
||||
|
||||
name = self._properties.get("name", "")
|
||||
dashboard_id = self._properties.get("dashboard_id")
|
||||
user_id = get_user_id()
|
||||
|
||||
# Validate name uniqueness for this dashboard/user
|
||||
if not WhatIfSimulationDAO.validate_name_uniqueness(
|
||||
name, dashboard_id, user_id
|
||||
):
|
||||
exceptions.append(WhatIfSimulationNameUniquenessError())
|
||||
|
||||
if exceptions:
|
||||
raise WhatIfSimulationInvalidError(exceptions=exceptions)
|
||||
60
superset/what_if/commands/simulation_delete.py
Normal file
60
superset/what_if/commands/simulation_delete.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Delete What-If Simulation command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.daos.what_if_simulation import WhatIfSimulationDAO
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.what_if.exceptions import (
|
||||
WhatIfSimulationDeleteFailedError,
|
||||
WhatIfSimulationForbiddenError,
|
||||
WhatIfSimulationNotFoundError,
|
||||
)
|
||||
from superset.what_if.models import WhatIfSimulation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeleteWhatIfSimulationCommand(BaseCommand):
|
||||
"""Command to delete What-If simulation(s)."""
|
||||
|
||||
def __init__(self, simulation_ids: list[int]):
|
||||
self._simulation_ids = simulation_ids
|
||||
self._models: list[WhatIfSimulation] = []
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=WhatIfSimulationDeleteFailedError))
|
||||
def run(self) -> None:
|
||||
self.validate()
|
||||
WhatIfSimulationDAO.delete(self._models)
|
||||
|
||||
def validate(self) -> None:
|
||||
user_id = get_user_id()
|
||||
self._models = WhatIfSimulationDAO.find_by_ids(self._simulation_ids)
|
||||
|
||||
if len(self._models) != len(self._simulation_ids):
|
||||
raise WhatIfSimulationNotFoundError()
|
||||
|
||||
# Check ownership of all simulations
|
||||
for model in self._models:
|
||||
if model.user_id != user_id:
|
||||
raise WhatIfSimulationForbiddenError()
|
||||
82
superset/what_if/commands/simulation_update.py
Normal file
82
superset/what_if/commands/simulation_update.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Update What-If Simulation command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.daos.what_if_simulation import WhatIfSimulationDAO
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.what_if.exceptions import (
|
||||
WhatIfSimulationForbiddenError,
|
||||
WhatIfSimulationInvalidError,
|
||||
WhatIfSimulationNameUniquenessError,
|
||||
WhatIfSimulationNotFoundError,
|
||||
WhatIfSimulationUpdateFailedError,
|
||||
)
|
||||
from superset.what_if.models import WhatIfSimulation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateWhatIfSimulationCommand(BaseCommand):
|
||||
"""Command to update a What-If simulation."""
|
||||
|
||||
def __init__(self, simulation_id: int, data: dict[str, Any]):
|
||||
self._simulation_id = simulation_id
|
||||
self._properties = data.copy()
|
||||
self._model: Optional[WhatIfSimulation] = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=WhatIfSimulationUpdateFailedError))
|
||||
def run(self) -> Model:
|
||||
self.validate()
|
||||
return WhatIfSimulationDAO.update(self._model, attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
exceptions: list[ValidationError] = []
|
||||
|
||||
# Fetch model
|
||||
self._model = WhatIfSimulationDAO.find_by_id(self._simulation_id)
|
||||
if not self._model:
|
||||
raise WhatIfSimulationNotFoundError()
|
||||
|
||||
# Check ownership
|
||||
user_id = get_user_id()
|
||||
if self._model.user_id != user_id:
|
||||
raise WhatIfSimulationForbiddenError()
|
||||
|
||||
# Validate name uniqueness if name is being updated
|
||||
name = self._properties.get("name")
|
||||
if name and name != self._model.name:
|
||||
if not WhatIfSimulationDAO.validate_name_uniqueness(
|
||||
name,
|
||||
self._model.dashboard_id,
|
||||
user_id,
|
||||
self._simulation_id,
|
||||
):
|
||||
exceptions.append(WhatIfSimulationNameUniquenessError())
|
||||
|
||||
if exceptions:
|
||||
raise WhatIfSimulationInvalidError(exceptions=exceptions)
|
||||
220
superset/what_if/commands/suggest_related.py
Normal file
220
superset/what_if/commands/suggest_related.py
Normal file
@@ -0,0 +1,220 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""What-If Analysis suggest related columns command using OpenRouter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from flask import current_app
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.utils import json
|
||||
from superset.what_if.exceptions import OpenRouterAPIError, OpenRouterConfigError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WhatIfSuggestRelatedCommand(BaseCommand):
|
||||
"""Command to get AI suggestions for related column modifications."""
|
||||
|
||||
def __init__(self, data: dict[str, Any]) -> None:
|
||||
self._data = data
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
self.validate()
|
||||
return self._get_ai_suggestions()
|
||||
|
||||
def validate(self) -> None:
|
||||
api_key = current_app.config.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
raise OpenRouterConfigError("OPENROUTER_API_KEY not configured")
|
||||
|
||||
if not self._data.get("selected_column"):
|
||||
raise ValueError("selected_column is required")
|
||||
|
||||
if not self._data.get("available_columns"):
|
||||
raise ValueError("available_columns list is required")
|
||||
|
||||
if self._data.get("user_multiplier") is None:
|
||||
raise ValueError("user_multiplier is required")
|
||||
|
||||
def _build_prompt(self) -> str:
|
||||
selected_column = self._data["selected_column"]
|
||||
user_multiplier = self._data["user_multiplier"]
|
||||
available_columns = self._data["available_columns"]
|
||||
dashboard_name = self._data.get("dashboard_name") or "Dashboard"
|
||||
|
||||
pct_change = (user_multiplier - 1) * 100
|
||||
sign = "+" if pct_change >= 0 else ""
|
||||
|
||||
# Build column list with descriptions
|
||||
columns_text = []
|
||||
for col in available_columns:
|
||||
# Skip the selected column - we don't want to suggest modifying it again
|
||||
if col["column_name"] == selected_column:
|
||||
continue
|
||||
|
||||
col_desc = f"- **{col['column_name']}**"
|
||||
if col.get("verbose_name"):
|
||||
col_desc += f" ({col['verbose_name']})"
|
||||
if col.get("description"):
|
||||
col_desc += f": {col['description']}"
|
||||
columns_text.append(col_desc)
|
||||
|
||||
if not columns_text:
|
||||
columns_text = ["No other columns available"]
|
||||
|
||||
return f"""You are a business intelligence analyst helping with what-if scenario analysis.
|
||||
|
||||
## Context
|
||||
A user is working on a "{dashboard_name}" dashboard and wants to simulate the cascading effects of changing a metric.
|
||||
|
||||
## User's Modification
|
||||
The user is modifying **{selected_column}** by {sign}{pct_change:.1f}%
|
||||
|
||||
## Other Available Columns
|
||||
These are the other numeric columns available in the dashboard:
|
||||
{chr(10).join(columns_text)}
|
||||
|
||||
## Your Task
|
||||
Analyze the relationships between these columns and suggest which OTHER columns should also be modified as a cascading effect of the user's change to {selected_column}.
|
||||
|
||||
Consider:
|
||||
1. **Causal relationships**: If column A affects column B in real business scenarios
|
||||
2. **Mathematical relationships**: Derived metrics, ratios, calculated fields
|
||||
3. **Domain knowledge**: Industry-standard relationships (e.g., increasing customers often increases orders and revenue)
|
||||
|
||||
For each suggested column, provide:
|
||||
- The appropriate multiplier (proportional, dampened, amplified, or inverse based on the relationship)
|
||||
- A brief reasoning explaining the relationship (1 sentence)
|
||||
- Your confidence level (high/medium/low)
|
||||
|
||||
Guidelines:
|
||||
- Only suggest columns that have a clear logical relationship to {selected_column}
|
||||
- Be conservative - don't suggest modifications without good reasoning
|
||||
- The multiplier should be realistic (e.g., if {selected_column} increases 10%, a related column might increase 5-15%, not 100%)
|
||||
- If no clear relationships exist, return an empty suggestions array
|
||||
|
||||
Respond in JSON format:
|
||||
{{
|
||||
"suggested_modifications": [
|
||||
{{
|
||||
"column": "column_name",
|
||||
"multiplier": 1.08,
|
||||
"reasoning": "Brief explanation of the relationship",
|
||||
"confidence": "high"
|
||||
}}
|
||||
],
|
||||
"explanation": "Overall summary of the analysis (1-2 sentences)"
|
||||
}}"""
|
||||
|
||||
def _get_ai_suggestions(self) -> dict[str, Any]:
|
||||
api_key = current_app.config.get("OPENROUTER_API_KEY")
|
||||
model = current_app.config.get("OPENROUTER_MODEL", "x-ai/grok-4.1-fast")
|
||||
api_base = current_app.config.get(
|
||||
"OPENROUTER_API_BASE", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
timeout = current_app.config.get("OPENROUTER_TIMEOUT", 30)
|
||||
|
||||
prompt = self._build_prompt()
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": current_app.config.get("WEBDRIVER_BASEURL", ""),
|
||||
"X-Title": "Apache Superset What-If Analysis",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a business intelligence analyst specializing in "
|
||||
"data relationships and cascading effects analysis. "
|
||||
"Respond only with valid JSON."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 1000,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
response = client.post(
|
||||
f"{api_base}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
|
||||
# Parse the JSON response
|
||||
parsed = json.loads(content)
|
||||
|
||||
# Validate and normalize the response
|
||||
suggestions = parsed.get("suggested_modifications", [])
|
||||
validated_suggestions = []
|
||||
|
||||
for suggestion in suggestions:
|
||||
# Ensure required fields exist
|
||||
if all(
|
||||
k in suggestion
|
||||
for k in ["column", "multiplier", "reasoning", "confidence"]
|
||||
):
|
||||
# Normalize confidence to lowercase
|
||||
confidence = suggestion["confidence"].lower()
|
||||
if confidence not in ("high", "medium", "low"):
|
||||
confidence = "medium"
|
||||
|
||||
validated_suggestions.append(
|
||||
{
|
||||
"column": suggestion["column"],
|
||||
"multiplier": float(suggestion["multiplier"]),
|
||||
"reasoning": suggestion["reasoning"],
|
||||
"confidence": confidence,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"suggested_modifications": validated_suggestions,
|
||||
"explanation": parsed.get("explanation"),
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as ex:
|
||||
logger.error("OpenRouter API error: %s", ex.response.status_code)
|
||||
raise OpenRouterAPIError(
|
||||
f"OpenRouter API error: {ex.response.status_code}"
|
||||
) from ex
|
||||
except json.JSONDecodeError as ex:
|
||||
logger.error("Failed to parse AI response: %s", ex)
|
||||
raise OpenRouterAPIError("Failed to parse AI response") from ex
|
||||
except httpx.TimeoutException as ex:
|
||||
logger.error("OpenRouter API timeout")
|
||||
raise OpenRouterAPIError("AI service timed out") from ex
|
||||
except Exception as ex:
|
||||
logger.exception("Unexpected error calling OpenRouter")
|
||||
raise OpenRouterAPIError(f"Unexpected error: {ex!s}") from ex
|
||||
99
superset/what_if/exceptions.py
Normal file
99
superset/what_if/exceptions.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""What-If Analysis exceptions."""
|
||||
|
||||
from flask_babel import lazy_gettext as _
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.exceptions import (
|
||||
CommandException,
|
||||
CommandInvalidError,
|
||||
CreateFailedError,
|
||||
DeleteFailedError,
|
||||
ForbiddenError,
|
||||
)
|
||||
from superset.exceptions import SupersetException
|
||||
|
||||
|
||||
class WhatIfException(SupersetException):
|
||||
"""Base exception for What-If Analysis errors."""
|
||||
|
||||
|
||||
class OpenRouterConfigError(WhatIfException):
|
||||
"""Raised when OpenRouter API is not configured."""
|
||||
|
||||
status = 500
|
||||
message = "OpenRouter API is not configured"
|
||||
|
||||
|
||||
class OpenRouterAPIError(WhatIfException):
|
||||
"""Raised when there is an error communicating with OpenRouter API."""
|
||||
|
||||
status = 502
|
||||
message = "Error communicating with OpenRouter API"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Simulation persistence exceptions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class WhatIfSimulationNameUniquenessError(ValidationError):
|
||||
"""Validation error for simulation name already exists."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
[_("Name must be unique for this dashboard")],
|
||||
field_name="name",
|
||||
)
|
||||
|
||||
|
||||
class WhatIfSimulationNotFoundError(CommandException):
|
||||
"""Raised when a simulation is not found."""
|
||||
|
||||
status = 404
|
||||
message = _("What-If simulation not found.")
|
||||
|
||||
|
||||
class WhatIfSimulationInvalidError(CommandInvalidError):
|
||||
"""Raised when simulation parameters are invalid."""
|
||||
|
||||
message = _("What-If simulation parameters are invalid.")
|
||||
|
||||
|
||||
class WhatIfSimulationCreateFailedError(CreateFailedError):
|
||||
"""Raised when simulation creation fails."""
|
||||
|
||||
message = _("What-If simulation could not be created.")
|
||||
|
||||
|
||||
class WhatIfSimulationUpdateFailedError(CreateFailedError):
|
||||
"""Raised when simulation update fails."""
|
||||
|
||||
message = _("What-If simulation could not be updated.")
|
||||
|
||||
|
||||
class WhatIfSimulationDeleteFailedError(DeleteFailedError):
|
||||
"""Raised when simulation deletion fails."""
|
||||
|
||||
message = _("What-If simulation could not be deleted.")
|
||||
|
||||
|
||||
class WhatIfSimulationForbiddenError(ForbiddenError):
|
||||
"""Raised when user doesn't have permission to access a simulation."""
|
||||
|
||||
message = _("You do not have permission to access this simulation.")
|
||||
85
superset/what_if/models.py
Normal file
85
superset/what_if/models.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""What-If Simulation persistence models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset import security_manager
|
||||
from superset.models.helpers import AuditMixinNullable
|
||||
|
||||
|
||||
class WhatIfSimulation(Model, AuditMixinNullable):
|
||||
"""Saved What-If simulation configuration."""
|
||||
|
||||
__tablename__ = "what_if_simulations"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
uuid = Column(
|
||||
UUIDType(binary=True), default=uuid.uuid4, unique=True, nullable=False
|
||||
)
|
||||
name = Column(String(256), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
dashboard_id = Column(
|
||||
Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id = Column(Integer, ForeignKey("ab_user.id"), nullable=False)
|
||||
|
||||
# JSON column storing modifications array
|
||||
# Structure: [{"column": "...", "multiplier": 1.1, "filters": [...]}]
|
||||
modifications_json = Column(Text, nullable=False)
|
||||
|
||||
# Whether cascading effects were enabled when saved
|
||||
cascading_effects_enabled = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Relationships
|
||||
dashboard = relationship(
|
||||
"Dashboard",
|
||||
foreign_keys=[dashboard_id],
|
||||
backref="what_if_simulations",
|
||||
)
|
||||
user = relationship(
|
||||
security_manager.user_model,
|
||||
foreign_keys=[user_id],
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_what_if_simulations_dashboard_user", dashboard_id, user_id),
|
||||
)
|
||||
|
||||
@property
|
||||
def modifications(self) -> list[dict[str, Any]]:
|
||||
"""Parse and return modifications from JSON."""
|
||||
if self.modifications_json:
|
||||
return json.loads(self.modifications_json)
|
||||
return []
|
||||
|
||||
@modifications.setter
|
||||
def modifications(self, value: list[dict[str, Any]]) -> None:
|
||||
"""Serialize modifications to JSON."""
|
||||
self.modifications_json = json.dumps(value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"WhatIfSimulation<{self.name}>"
|
||||
329
superset/what_if/schemas.py
Normal file
329
superset/what_if/schemas.py
Normal file
@@ -0,0 +1,329 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""What-If Analysis schemas for request/response validation."""
|
||||
|
||||
from marshmallow import fields, Schema
|
||||
|
||||
|
||||
class ChartMetricComparisonSchema(Schema):
|
||||
"""Schema for a single metric comparison within a chart."""
|
||||
|
||||
metric_name = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Name of the metric being compared"},
|
||||
)
|
||||
original_value = fields.Float(
|
||||
required=True,
|
||||
metadata={"description": "Original metric value before modification"},
|
||||
)
|
||||
modified_value = fields.Float(
|
||||
required=True,
|
||||
metadata={"description": "Modified metric value after what-if applied"},
|
||||
)
|
||||
percentage_change = fields.Float(
|
||||
required=True,
|
||||
metadata={"description": "Percentage change from original to modified"},
|
||||
)
|
||||
|
||||
|
||||
class ChartComparisonSchema(Schema):
|
||||
"""Schema for chart-level comparison data."""
|
||||
|
||||
chart_id = fields.Integer(
|
||||
required=True,
|
||||
metadata={"description": "Unique identifier for the chart"},
|
||||
)
|
||||
chart_name = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Display name of the chart"},
|
||||
)
|
||||
chart_type = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Visualization type (e.g., bar, line, pie)"},
|
||||
)
|
||||
metrics = fields.List(
|
||||
fields.Nested(ChartMetricComparisonSchema),
|
||||
required=True,
|
||||
metadata={"description": "List of metric comparisons for this chart"},
|
||||
)
|
||||
|
||||
|
||||
class WhatIfFilterSchema(Schema):
|
||||
"""Schema for a what-if filter condition."""
|
||||
|
||||
col = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Column name to filter on"},
|
||||
)
|
||||
op = fields.String(
|
||||
required=True,
|
||||
metadata={
|
||||
"description": "Filter operator: ==, !=, >, <, >=, <=, IN, NOT IN, TEMPORAL_RANGE"
|
||||
},
|
||||
)
|
||||
val = fields.Raw(
|
||||
required=True,
|
||||
metadata={
|
||||
"description": "Filter value (string, number, or array for IN/NOT IN operators)"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ModificationSchema(Schema):
|
||||
"""Schema for a single what-if modification."""
|
||||
|
||||
column = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Column name being modified"},
|
||||
)
|
||||
multiplier = fields.Float(
|
||||
required=True,
|
||||
metadata={
|
||||
"description": "Multiplier applied to the column (e.g., 1.1 for +10%)"
|
||||
},
|
||||
)
|
||||
filters = fields.List(
|
||||
fields.Nested(WhatIfFilterSchema),
|
||||
required=False,
|
||||
load_default=None,
|
||||
metadata={
|
||||
"description": "Optional filters to apply modification conditionally"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class WhatIfInterpretRequestSchema(Schema):
|
||||
"""Schema for what-if interpretation request."""
|
||||
|
||||
modifications = fields.List(
|
||||
fields.Nested(ModificationSchema),
|
||||
required=True,
|
||||
metadata={"description": "List of column modifications applied"},
|
||||
)
|
||||
charts = fields.List(
|
||||
fields.Nested(ChartComparisonSchema),
|
||||
required=True,
|
||||
metadata={"description": "List of charts with comparison data"},
|
||||
)
|
||||
dashboard_name = fields.String(
|
||||
required=False,
|
||||
load_default=None,
|
||||
metadata={"description": "Name of the dashboard for context"},
|
||||
)
|
||||
|
||||
|
||||
class InsightSchema(Schema):
|
||||
"""Schema for a single AI-generated insight."""
|
||||
|
||||
title = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Short title summarizing the insight"},
|
||||
)
|
||||
description = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Detailed description of the insight"},
|
||||
)
|
||||
type = fields.String(
|
||||
required=True,
|
||||
metadata={
|
||||
"description": "Type of insight: observation, implication, or recommendation"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class WhatIfInterpretResponseSchema(Schema):
|
||||
"""Schema for what-if interpretation response."""
|
||||
|
||||
summary = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Executive summary of the what-if analysis"},
|
||||
)
|
||||
insights = fields.List(
|
||||
fields.Nested(InsightSchema),
|
||||
required=True,
|
||||
metadata={"description": "List of AI-generated insights"},
|
||||
)
|
||||
raw_response = fields.String(
|
||||
required=False,
|
||||
metadata={"description": "Raw AI response (only in debug mode)"},
|
||||
)
|
||||
|
||||
|
||||
# Schemas for suggest_related endpoint
|
||||
|
||||
|
||||
class AvailableColumnSchema(Schema):
|
||||
"""Schema for an available column with metadata."""
|
||||
|
||||
column_name = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Name of the column"},
|
||||
)
|
||||
description = fields.String(
|
||||
required=False,
|
||||
load_default=None,
|
||||
metadata={"description": "Column description/documentation"},
|
||||
)
|
||||
verbose_name = fields.String(
|
||||
required=False,
|
||||
load_default=None,
|
||||
metadata={"description": "Human-readable column name"},
|
||||
)
|
||||
datasource_id = fields.Integer(
|
||||
required=True,
|
||||
metadata={"description": "ID of the datasource containing this column"},
|
||||
)
|
||||
|
||||
|
||||
class WhatIfSuggestRelatedRequestSchema(Schema):
|
||||
"""Schema for suggest_related request."""
|
||||
|
||||
selected_column = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "The column the user selected to modify"},
|
||||
)
|
||||
user_multiplier = fields.Float(
|
||||
required=True,
|
||||
metadata={
|
||||
"description": "The multiplier the user applied (e.g., 1.1 for +10%)"
|
||||
},
|
||||
)
|
||||
available_columns = fields.List(
|
||||
fields.Nested(AvailableColumnSchema),
|
||||
required=True,
|
||||
metadata={"description": "All numeric columns available in the dashboard"},
|
||||
)
|
||||
dashboard_name = fields.String(
|
||||
required=False,
|
||||
load_default=None,
|
||||
metadata={"description": "Name of the dashboard for context"},
|
||||
)
|
||||
|
||||
|
||||
class SuggestedModificationSchema(Schema):
|
||||
"""Schema for a single AI-suggested modification."""
|
||||
|
||||
column = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Column name to modify"},
|
||||
)
|
||||
multiplier = fields.Float(
|
||||
required=True,
|
||||
metadata={"description": "Suggested multiplier for this column"},
|
||||
)
|
||||
reasoning = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Brief explanation of why this column is related"},
|
||||
)
|
||||
confidence = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Confidence level: high, medium, or low"},
|
||||
)
|
||||
|
||||
|
||||
class WhatIfSuggestRelatedResponseSchema(Schema):
|
||||
"""Schema for suggest_related response."""
|
||||
|
||||
suggested_modifications = fields.List(
|
||||
fields.Nested(SuggestedModificationSchema),
|
||||
required=True,
|
||||
metadata={"description": "List of AI-suggested column modifications"},
|
||||
)
|
||||
explanation = fields.String(
|
||||
required=False,
|
||||
load_default=None,
|
||||
metadata={"description": "Overall explanation of the relationship analysis"},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Simulation CRUD Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class WhatIfSimulationPostSchema(Schema):
|
||||
"""Schema for creating a What-If simulation."""
|
||||
|
||||
name = fields.String(
|
||||
required=True,
|
||||
metadata={"description": "Name of the saved simulation"},
|
||||
)
|
||||
description = fields.String(
|
||||
required=False,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
metadata={"description": "Optional description"},
|
||||
)
|
||||
dashboard_id = fields.Integer(
|
||||
required=True,
|
||||
metadata={"description": "ID of the dashboard this simulation belongs to"},
|
||||
)
|
||||
modifications = fields.List(
|
||||
fields.Nested(ModificationSchema),
|
||||
required=True,
|
||||
metadata={"description": "List of column modifications"},
|
||||
)
|
||||
cascading_effects_enabled = fields.Boolean(
|
||||
required=False,
|
||||
load_default=False,
|
||||
metadata={"description": "Whether AI cascading effects were enabled"},
|
||||
)
|
||||
|
||||
|
||||
class WhatIfSimulationPutSchema(Schema):
|
||||
"""Schema for updating a What-If simulation."""
|
||||
|
||||
name = fields.String(
|
||||
required=False,
|
||||
metadata={"description": "Name of the saved simulation"},
|
||||
)
|
||||
description = fields.String(
|
||||
required=False,
|
||||
allow_none=True,
|
||||
metadata={"description": "Optional description"},
|
||||
)
|
||||
modifications = fields.List(
|
||||
fields.Nested(ModificationSchema),
|
||||
required=False,
|
||||
metadata={"description": "List of column modifications"},
|
||||
)
|
||||
cascading_effects_enabled = fields.Boolean(
|
||||
required=False,
|
||||
metadata={"description": "Whether AI cascading effects were enabled"},
|
||||
)
|
||||
|
||||
|
||||
class WhatIfSimulationResponseSchema(Schema):
|
||||
"""Schema for simulation response."""
|
||||
|
||||
id = fields.Integer(metadata={"description": "Simulation ID"})
|
||||
uuid = fields.String(metadata={"description": "Simulation UUID"})
|
||||
name = fields.String(metadata={"description": "Simulation name"})
|
||||
description = fields.String(
|
||||
allow_none=True,
|
||||
metadata={"description": "Simulation description"},
|
||||
)
|
||||
dashboard_id = fields.Integer(metadata={"description": "Dashboard ID"})
|
||||
modifications = fields.List(
|
||||
fields.Nested(ModificationSchema),
|
||||
metadata={"description": "Saved modifications"},
|
||||
)
|
||||
cascading_effects_enabled = fields.Boolean(
|
||||
metadata={"description": "Whether cascading effects were enabled"},
|
||||
)
|
||||
created_on = fields.DateTime(metadata={"description": "Creation timestamp"})
|
||||
changed_on = fields.DateTime(metadata={"description": "Last modified timestamp"})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user