mirror of
https://github.com/apache/superset.git
synced 2026-07-01 12:25:32 +00:00
Compare commits
31 Commits
chore/ci/s
...
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,
|
ExclamationCircleOutlined,
|
||||||
ExclamationCircleFilled,
|
ExclamationCircleFilled,
|
||||||
ExpandOutlined,
|
ExpandOutlined,
|
||||||
|
ExperimentOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
EyeInvisibleOutlined,
|
EyeInvisibleOutlined,
|
||||||
FallOutlined,
|
FallOutlined,
|
||||||
@@ -76,6 +77,7 @@ import {
|
|||||||
FileOutlined,
|
FileOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
FireOutlined,
|
FireOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
FormOutlined,
|
FormOutlined,
|
||||||
FullscreenExitOutlined,
|
FullscreenExitOutlined,
|
||||||
FullscreenOutlined,
|
FullscreenOutlined,
|
||||||
@@ -204,6 +206,7 @@ const AntdIcons = {
|
|||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
ExclamationCircleFilled,
|
ExclamationCircleFilled,
|
||||||
ExpandOutlined,
|
ExpandOutlined,
|
||||||
|
ExperimentOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
EyeInvisibleOutlined,
|
EyeInvisibleOutlined,
|
||||||
FacebookOutlined,
|
FacebookOutlined,
|
||||||
@@ -215,6 +218,7 @@ const AntdIcons = {
|
|||||||
FileOutlined,
|
FileOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
FireOutlined,
|
FireOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
FormOutlined,
|
FormOutlined,
|
||||||
FullscreenExitOutlined,
|
FullscreenExitOutlined,
|
||||||
FullscreenOutlined,
|
FullscreenOutlined,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ type ExtractedExtra = ExtraFilterQueryField & {
|
|||||||
export default function extractExtras(formData: QueryFormData): ExtractedExtra {
|
export default function extractExtras(formData: QueryFormData): ExtractedExtra {
|
||||||
const applied_time_extras: AppliedTimeExtras = {};
|
const applied_time_extras: AppliedTimeExtras = {};
|
||||||
const filters: QueryObjectFilterClause[] = [];
|
const filters: QueryObjectFilterClause[] = [];
|
||||||
const extras: QueryObjectExtras = {};
|
// Preserve existing extras from formData (e.g., what_if modifications)
|
||||||
|
const extras: QueryObjectExtras = { ...formData.extras };
|
||||||
const extract: ExtractedExtra = {
|
const extract: ExtractedExtra = {
|
||||||
filters,
|
filters,
|
||||||
extras,
|
extras,
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ export type QueryObjectExtras = Partial<{
|
|||||||
|
|
||||||
/** If true, WHERE/HAVING clauses need transpilation to target dialect */
|
/** If true, WHERE/HAVING clauses need transpilation to target dialect */
|
||||||
transpile_to_dialect?: boolean;
|
transpile_to_dialect?: boolean;
|
||||||
|
|
||||||
|
/** What-if analysis: column value modifications */
|
||||||
|
what_if?: {
|
||||||
|
modifications: Array<{
|
||||||
|
column: string;
|
||||||
|
multiplier: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ResidualQueryObjectData = {
|
export type ResidualQueryObjectData = {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const buildTimeRangeString = (since: string, until: string): string =>
|
|||||||
const formatDateEndpoint = (dttm: string, isStart?: boolean): string =>
|
const formatDateEndpoint = (dttm: string, isStart?: boolean): string =>
|
||||||
dttm.replace('T00:00:00', '') || (isStart ? '-∞' : '∞');
|
dttm.replace('T00:00:00', '') || (isStart ? '-∞' : '∞');
|
||||||
|
|
||||||
export const formatTimeRange = (
|
export const formatTimeRangeLabel = (
|
||||||
timeRange: string,
|
timeRange: string,
|
||||||
columnPlaceholder = 'col',
|
columnPlaceholder = 'col',
|
||||||
) => {
|
) => {
|
||||||
@@ -86,7 +86,7 @@ export const fetchTimeRange = async (
|
|||||||
response?.json?.result[0]?.until || '',
|
response?.json?.result[0]?.until || '',
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
value: formatTimeRange(timeRangeString, columnPlaceholder),
|
value: formatTimeRangeLabel(timeRangeString, columnPlaceholder),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const timeRanges = response?.json?.result.map((result: any) =>
|
const timeRanges = response?.json?.result.map((result: any) =>
|
||||||
|
|||||||
@@ -26,5 +26,9 @@ export {
|
|||||||
getTimeOffset,
|
getTimeOffset,
|
||||||
computeCustomDateTime,
|
computeCustomDateTime,
|
||||||
} from './getTimeOffset';
|
} from './getTimeOffset';
|
||||||
export { SEPARATOR, fetchTimeRange } from './fetchTimeRange';
|
export {
|
||||||
|
SEPARATOR,
|
||||||
|
fetchTimeRange,
|
||||||
|
formatTimeRangeLabel,
|
||||||
|
} from './fetchTimeRange';
|
||||||
export { customTimeRangeDecode } from './customTimeRangeDecode';
|
export { customTimeRangeDecode } from './customTimeRangeDecode';
|
||||||
|
|||||||
@@ -167,7 +167,21 @@ const MessageSpan = styled.span`
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin: ${({ theme }) => theme.sizeUnit * 4}px auto;
|
margin: ${({ theme }) => theme.sizeUnit * 4}px auto;
|
||||||
width: fit-content;
|
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, {}> {
|
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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
height,
|
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 (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onError={this.handleRenderContainerFailure}
|
onError={this.handleRenderContainerFailure}
|
||||||
@@ -385,9 +439,8 @@ class Chart extends PureComponent<ChartProps, {}> {
|
|||||||
height={height}
|
height={height}
|
||||||
width={width}
|
width={width}
|
||||||
>
|
>
|
||||||
{isLoading
|
{this.renderChartContainer()}
|
||||||
? this.renderSpinner(databaseName)
|
{isLoading && this.renderLoadingOverlay(databaseName)}
|
||||||
: this.renderChartContainer()}
|
|
||||||
</Styles>
|
</Styles>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -279,8 +279,14 @@ class ChartRenderer extends Component {
|
|||||||
render() {
|
render() {
|
||||||
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
|
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
|
||||||
|
|
||||||
// Skip chart rendering
|
// Skip chart rendering for errors
|
||||||
if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
|
if (chartAlert) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData =
|
||||||
|
this.mutableQueriesResponse && this.mutableQueriesResponse.length > 0;
|
||||||
|
if (!hasData && (chartStatus === 'loading' || chartStatus === null)) {
|
||||||
return 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 (
|
export const getDatasourceSamples = async (
|
||||||
datasourceType,
|
datasourceType,
|
||||||
datasourceId,
|
datasourceId,
|
||||||
|
|||||||
@@ -177,6 +177,33 @@ export default function chartReducer(
|
|||||||
annotationQuery,
|
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 */
|
/* eslint-disable no-param-reassign */
|
||||||
|
|||||||
@@ -774,6 +774,22 @@ export function clearAllChartStates() {
|
|||||||
return { type: CLEAR_ALL_CHART_STATES };
|
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 ---------------------------------------------------------------
|
// Undo history ---------------------------------------------------------------
|
||||||
export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
|
export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
|
||||||
export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {
|
export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ export const hydrateDashboard =
|
|||||||
dashboardState?.datasetsStatus || ResourceStatus.Loading,
|
dashboardState?.datasetsStatus || ResourceStatus.Loading,
|
||||||
chartStates: chartStates || dashboardState?.chartStates || {},
|
chartStates: chartStates || dashboardState?.chartStates || {},
|
||||||
chartCustomizationItems,
|
chartCustomizationItems,
|
||||||
|
whatIfModifications: [],
|
||||||
},
|
},
|
||||||
dashboardLayout,
|
dashboardLayout,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const TABS_KEYS = {
|
|||||||
|
|
||||||
const BuilderComponentPane = ({ topOffset = 0 }) => (
|
const BuilderComponentPane = ({ topOffset = 0 }) => (
|
||||||
<div
|
<div
|
||||||
|
className="dashboard-builder-sidepane"
|
||||||
data-test="dashboard-builder-sidepane"
|
data-test="dashboard-builder-sidepane"
|
||||||
css={css`
|
css={css`
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
/* eslint-env browser */
|
/* eslint-env browser */
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { addAlpha, JsonObject, t, useElementOnScreen } from '@superset-ui/core';
|
||||||
import { css, styled, useTheme } from '@apache-superset/core/ui';
|
import { css, styled, useTheme } from '@apache-superset/core/ui';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
setDirectPathToChild,
|
setDirectPathToChild,
|
||||||
setEditMode,
|
setEditMode,
|
||||||
|
toggleWhatIfPanel,
|
||||||
} from 'src/dashboard/actions/dashboardState';
|
} from 'src/dashboard/actions/dashboardState';
|
||||||
import {
|
import {
|
||||||
deleteTopLevelTabs,
|
deleteTopLevelTabs,
|
||||||
@@ -55,6 +57,8 @@ import {
|
|||||||
DashboardStandaloneMode,
|
DashboardStandaloneMode,
|
||||||
} from 'src/dashboard/util/constants';
|
} from 'src/dashboard/util/constants';
|
||||||
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
|
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 { useUiConfig } from 'src/components/UiConfigContext';
|
||||||
import ResizableSidebar from 'src/components/ResizableSidebar';
|
import ResizableSidebar from 'src/components/ResizableSidebar';
|
||||||
import {
|
import {
|
||||||
@@ -272,12 +276,16 @@ const DashboardContentWrapper = styled.div`
|
|||||||
const StyledDashboardContent = styled.div<{
|
const StyledDashboardContent = styled.div<{
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
marginLeft: number;
|
marginLeft: number;
|
||||||
|
marginRight: number;
|
||||||
|
hasWhatIfPanel: boolean;
|
||||||
}>`
|
}>`
|
||||||
${({ theme, editMode, marginLeft }) => css`
|
${({ theme, editMode, marginLeft, marginRight, hasWhatIfPanel }) => css`
|
||||||
background-color: ${theme.colorBgLayout};
|
background-color: ${theme.colorBgLayout};
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: row;
|
grid-template-columns: 1fr ${hasWhatIfPanel ? 'auto' : ''} ${editMode
|
||||||
flex-wrap: nowrap;
|
? 'auto'
|
||||||
|
: ''};
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
height: auto;
|
height: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
@@ -287,27 +295,23 @@ const StyledDashboardContent = styled.div<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid-container {
|
.grid-container {
|
||||||
/* without this, the grid will not get smaller upon toggling the builder panel on */
|
grid-column: 1;
|
||||||
width: 0;
|
grid-row: 2;
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: ${theme.sizeUnit * 4}px;
|
margin: ${theme.sizeUnit * 4}px;
|
||||||
margin-left: ${marginLeft}px;
|
margin-left: ${marginLeft}px;
|
||||||
|
margin-right: ${marginRight}px;
|
||||||
${editMode &&
|
min-width: 0; /* Prevent grid blowout */
|
||||||
`
|
|
||||||
max-width: calc(100% - ${
|
|
||||||
BUILDER_SIDEPANEL_WIDTH + theme.sizeUnit * 16
|
|
||||||
}px);
|
|
||||||
`}
|
|
||||||
|
|
||||||
/* this is the ParentSize wrapper */
|
/* this is the ParentSize wrapper */
|
||||||
& > div:first-child {
|
& > div:first-child {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-builder-sidepane {
|
.dashboard-builder-sidepane {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / -1; /* Span all rows */
|
||||||
width: ${BUILDER_SIDEPANEL_WIDTH}px;
|
width: ${BUILDER_SIDEPANEL_WIDTH}px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -385,6 +389,35 @@ const DashboardBuilder = () => {
|
|||||||
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
|
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
|
||||||
({ dashboardInfo }) => dashboardInfo.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(
|
const handleChangeTab = useCallback(
|
||||||
({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
|
({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
|
||||||
@@ -563,6 +596,8 @@ const DashboardBuilder = () => {
|
|||||||
? theme.sizeUnit * 4
|
? theme.sizeUnit * 4
|
||||||
: theme.sizeUnit * 8;
|
: theme.sizeUnit * 8;
|
||||||
|
|
||||||
|
const dashboardContentMarginRight = theme.sizeUnit * 4;
|
||||||
|
|
||||||
const renderChild = useCallback(
|
const renderChild = useCallback(
|
||||||
adjustedWidth => {
|
adjustedWidth => {
|
||||||
const filterBarWidth = dashboardFiltersOpen
|
const filterBarWidth = dashboardFiltersOpen
|
||||||
@@ -674,7 +709,10 @@ const DashboardBuilder = () => {
|
|||||||
className="dashboard-content"
|
className="dashboard-content"
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
marginLeft={dashboardContentMarginLeft}
|
marginLeft={dashboardContentMarginLeft}
|
||||||
|
marginRight={dashboardContentMarginRight}
|
||||||
|
hasWhatIfPanel={!editMode && whatIfPanelOpen}
|
||||||
>
|
>
|
||||||
|
{!editMode && <WhatIfBanner topOffset={barTopOffset} />}
|
||||||
{showDashboard ? (
|
{showDashboard ? (
|
||||||
missingInitialFilters.length > 0 ? (
|
missingInitialFilters.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
@@ -684,6 +722,8 @@ const DashboardBuilder = () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
& div {
|
& div {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
@@ -705,6 +745,14 @@ const DashboardBuilder = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Loading />
|
<Loading />
|
||||||
)}
|
)}
|
||||||
|
{!editMode && (
|
||||||
|
<WhatIfPanel
|
||||||
|
visible={whatIfPanelOpen}
|
||||||
|
onClose={handleCloseWhatIfPanel}
|
||||||
|
topOffset={barTopOffset}
|
||||||
|
initialSimulationId={initialSimulationId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{editMode && <BuilderComponentPane topOffset={barTopOffset} />}
|
{editMode && <BuilderComponentPane topOffset={barTopOffset} />}
|
||||||
</StyledDashboardContent>
|
</StyledDashboardContent>
|
||||||
</DashboardContentWrapper>
|
</DashboardContentWrapper>
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import {
|
|||||||
setMaxUndoHistoryExceeded,
|
setMaxUndoHistoryExceeded,
|
||||||
setRefreshFrequency,
|
setRefreshFrequency,
|
||||||
setUnsavedChanges,
|
setUnsavedChanges,
|
||||||
|
toggleWhatIfPanel,
|
||||||
} from '../../actions/dashboardState';
|
} from '../../actions/dashboardState';
|
||||||
import { logEvent } from '../../../logger/actions';
|
import { logEvent } from '../../../logger/actions';
|
||||||
import { dashboardInfoChanged } from '../../actions/dashboardInfo';
|
import { dashboardInfoChanged } from '../../actions/dashboardInfo';
|
||||||
@@ -106,6 +107,26 @@ const editButtonStyle = theme => css`
|
|||||||
color: ${theme.colorPrimary};
|
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`
|
const actionButtonsStyle = theme => css`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -195,6 +216,7 @@ const Header = () => {
|
|||||||
maxUndoHistoryExceeded,
|
maxUndoHistoryExceeded,
|
||||||
editMode,
|
editMode,
|
||||||
lastModifiedTime,
|
lastModifiedTime,
|
||||||
|
whatIfPanelOpen,
|
||||||
} = useSelector(
|
} = useSelector(
|
||||||
state => ({
|
state => ({
|
||||||
expandedSlices: state.dashboardState.expandedSlices,
|
expandedSlices: state.dashboardState.expandedSlices,
|
||||||
@@ -210,6 +232,7 @@ const Header = () => {
|
|||||||
maxUndoHistoryExceeded: !!state.dashboardState.maxUndoHistoryExceeded,
|
maxUndoHistoryExceeded: !!state.dashboardState.maxUndoHistoryExceeded,
|
||||||
editMode: !!state.dashboardState.editMode,
|
editMode: !!state.dashboardState.editMode,
|
||||||
lastModifiedTime: state.lastModifiedTime,
|
lastModifiedTime: state.lastModifiedTime,
|
||||||
|
whatIfPanelOpen: !!state.dashboardState.whatIfPanelOpen,
|
||||||
}),
|
}),
|
||||||
shallowEqual,
|
shallowEqual,
|
||||||
);
|
);
|
||||||
@@ -715,6 +738,17 @@ const Header = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div css={actionButtonsStyle}>
|
<div css={actionButtonsStyle}>
|
||||||
{NavExtension && <NavExtension />}
|
{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 && (
|
{userCanEdit && (
|
||||||
<Button
|
<Button
|
||||||
buttonStyle="secondary"
|
buttonStyle="secondary"
|
||||||
@@ -749,6 +783,7 @@ const Header = () => {
|
|||||||
undoLength,
|
undoLength,
|
||||||
userCanEdit,
|
userCanEdit,
|
||||||
userCanSaveAs,
|
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`
|
const SliceContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -367,6 +360,9 @@ const Chart = props => {
|
|||||||
state.dashboardInfo?.metadata?.shared_label_colors,
|
state.dashboardInfo?.metadata?.shared_label_colors,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const whatIfModifications = useSelector(
|
||||||
|
state => state.dashboardState.whatIfModifications || EMPTY_ARRAY,
|
||||||
|
);
|
||||||
|
|
||||||
const formData = useMemo(
|
const formData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -386,6 +382,7 @@ const Chart = props => {
|
|||||||
labelsColorMap,
|
labelsColorMap,
|
||||||
sharedLabelsColors,
|
sharedLabelsColors,
|
||||||
ownColorScheme,
|
ownColorScheme,
|
||||||
|
whatIfModifications,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
chart.id,
|
chart.id,
|
||||||
@@ -403,6 +400,7 @@ const Chart = props => {
|
|||||||
labelsColorMap,
|
labelsColorMap,
|
||||||
sharedLabelsColors,
|
sharedLabelsColors,
|
||||||
ownColorScheme,
|
ownColorScheme,
|
||||||
|
whatIfModifications,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -410,9 +408,13 @@ const Chart = props => {
|
|||||||
|
|
||||||
const ownState = useMemo(() => {
|
const ownState = useMemo(() => {
|
||||||
const baseOwnState = dataMask[props.id]?.ownState || EMPTY_OBJECT;
|
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(
|
return createOwnStateWithChartState(
|
||||||
baseOwnState,
|
baseOwnState,
|
||||||
chartState,
|
chartStateForConversion,
|
||||||
slice.viz_type,
|
slice.viz_type,
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
@@ -420,6 +422,7 @@ const Chart = props => {
|
|||||||
props.id,
|
props.id,
|
||||||
slice.viz_type,
|
slice.viz_type,
|
||||||
chartState?.state,
|
chartState?.state,
|
||||||
|
formData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const onExploreChart = useCallback(
|
const onExploreChart = useCallback(
|
||||||
@@ -596,7 +599,6 @@ const Chart = props => {
|
|||||||
return <MissingChart height={getChartHeight()} />;
|
return <MissingChart height={getChartHeight()} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLoading = chartStatus === 'loading';
|
|
||||||
const cachedDttm =
|
const cachedDttm =
|
||||||
// eslint-disable-next-line camelcase
|
// eslint-disable-next-line camelcase
|
||||||
queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
|
queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
|
||||||
@@ -668,15 +670,6 @@ const Chart = props => {
|
|||||||
className={cx('dashboard-chart')}
|
className={cx('dashboard-chart')}
|
||||||
aria-label={slice.description}
|
aria-label={slice.description}
|
||||||
>
|
>
|
||||||
{isLoading && (
|
|
||||||
<ChartOverlay
|
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
height: getChartHeight(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
width={width}
|
width={width}
|
||||||
height={getChartHeight()}
|
height={getChartHeight()}
|
||||||
@@ -693,17 +686,7 @@ const Chart = props => {
|
|||||||
formData={formData}
|
formData={formData}
|
||||||
labelsColor={labelsColor}
|
labelsColor={labelsColor}
|
||||||
labelsColorMap={labelsColorMap}
|
labelsColorMap={labelsColorMap}
|
||||||
ownState={createOwnStateWithChartState(
|
ownState={ownState}
|
||||||
dataMask[props.id]?.ownState || EMPTY_OBJECT,
|
|
||||||
{
|
|
||||||
state: getChartStateWithFallback(
|
|
||||||
chartState,
|
|
||||||
formData,
|
|
||||||
slice.viz_type,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
slice.viz_type,
|
|
||||||
)}
|
|
||||||
filterState={dataMask[props.id]?.filterState}
|
filterState={dataMask[props.id]?.filterState}
|
||||||
queriesResponse={chart.queriesResponse}
|
queriesResponse={chart.queriesResponse}
|
||||||
timeout={timeout}
|
timeout={timeout}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
|
|||||||
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
|
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
|
||||||
import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath';
|
import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath';
|
||||||
import useFilterFocusHighlightStyles from 'src/dashboard/util/useFilterFocusHighlightStyles';
|
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 { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
|
||||||
import {
|
import {
|
||||||
GRID_BASE_UNIT,
|
GRID_BASE_UNIT,
|
||||||
@@ -107,6 +108,7 @@ const ChartHolder = ({
|
|||||||
const isFullSize = fullSizeChartId === chartId;
|
const isFullSize = fullSizeChartId === chartId;
|
||||||
|
|
||||||
const focusHighlightStyles = useFilterFocusHighlightStyles(chartId);
|
const focusHighlightStyles = useFilterFocusHighlightStyles(chartId);
|
||||||
|
const whatIfHighlightStyles = useWhatIfHighlightStyles(chartId);
|
||||||
const directPathToChild = useSelector(
|
const directPathToChild = useSelector(
|
||||||
(state: RootState) => state.dashboardState.directPathToChild,
|
(state: RootState) => state.dashboardState.directPathToChild,
|
||||||
);
|
);
|
||||||
@@ -260,7 +262,7 @@ const ChartHolder = ({
|
|||||||
ref={dragSourceRef}
|
ref={dragSourceRef}
|
||||||
data-test="dashboard-component-chart-holder"
|
data-test="dashboard-component-chart-holder"
|
||||||
style={focusHighlightStyles}
|
style={focusHighlightStyles}
|
||||||
css={isFullSize ? fullSizeStyle : undefined}
|
css={[isFullSize && fullSizeStyle, whatIfHighlightStyles]}
|
||||||
className={cx(
|
className={cx(
|
||||||
'dashboard-component',
|
'dashboard-component',
|
||||||
'dashboard-component-chart-holder',
|
'dashboard-component-chart-holder',
|
||||||
@@ -325,6 +327,7 @@ const ChartHolder = ({
|
|||||||
onResizeStop,
|
onResizeStop,
|
||||||
editMode,
|
editMode,
|
||||||
focusHighlightStyles,
|
focusHighlightStyles,
|
||||||
|
whatIfHighlightStyles,
|
||||||
isFullSize,
|
isFullSize,
|
||||||
fullSizeStyle,
|
fullSizeStyle,
|
||||||
chartId,
|
chartId,
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ import {
|
|||||||
REMOVE_CHART_STATE,
|
REMOVE_CHART_STATE,
|
||||||
RESTORE_CHART_STATES,
|
RESTORE_CHART_STATES,
|
||||||
CLEAR_ALL_CHART_STATES,
|
CLEAR_ALL_CHART_STATES,
|
||||||
|
SET_WHAT_IF_MODIFICATIONS,
|
||||||
|
CLEAR_WHAT_IF_MODIFICATIONS,
|
||||||
|
TOGGLE_WHAT_IF_PANEL,
|
||||||
} from '../actions/dashboardState';
|
} from '../actions/dashboardState';
|
||||||
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
||||||
|
|
||||||
@@ -333,6 +336,24 @@ export default function dashboardStateReducer(state = {}, action) {
|
|||||||
chartStates: {},
|
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) {
|
if (action.type in actionHandlers) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from './components/nativeFilters/ChartCustomization/types';
|
} from './components/nativeFilters/ChartCustomization/types';
|
||||||
import { GroupByCustomizationsState } from './reducers/groupByCustomizations';
|
import { GroupByCustomizationsState } from './reducers/groupByCustomizations';
|
||||||
import { ChartState } from '../explore/types';
|
import { ChartState } from '../explore/types';
|
||||||
|
import { SliceEntitiesState } from './actions/sliceEntities';
|
||||||
|
|
||||||
export type { Dashboard } from 'src/types/Dashboard';
|
export type { Dashboard } from 'src/types/Dashboard';
|
||||||
|
|
||||||
@@ -129,6 +130,8 @@ export type DashboardState = {
|
|||||||
data: JsonObject;
|
data: JsonObject;
|
||||||
};
|
};
|
||||||
chartStates?: Record<string, any>;
|
chartStates?: Record<string, any>;
|
||||||
|
whatIfModifications: WhatIfModification[];
|
||||||
|
whatIfPanelOpen?: boolean;
|
||||||
};
|
};
|
||||||
export type DashboardInfo = {
|
export type DashboardInfo = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -184,7 +187,7 @@ export type DatasourcesState = {
|
|||||||
/** Root state of redux */
|
/** Root state of redux */
|
||||||
export type RootState = {
|
export type RootState = {
|
||||||
datasources: DatasourcesState;
|
datasources: DatasourcesState;
|
||||||
sliceEntities: JsonObject;
|
sliceEntities: SliceEntitiesState;
|
||||||
charts: ChartsState;
|
charts: ChartsState;
|
||||||
dashboardLayout: DashboardLayoutState;
|
dashboardLayout: DashboardLayoutState;
|
||||||
dashboardFilters: {};
|
dashboardFilters: {};
|
||||||
@@ -280,6 +283,40 @@ export type Slice = {
|
|||||||
created_by: { id: number };
|
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 {
|
export enum MenuKeys {
|
||||||
DownloadAsImage = 'download_as_image',
|
DownloadAsImage = 'download_as_image',
|
||||||
ExploreChart = 'explore_chart',
|
ExploreChart = 'explore_chart',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
ChartConfiguration,
|
ChartConfiguration,
|
||||||
ChartQueryPayload,
|
ChartQueryPayload,
|
||||||
ActiveFilters,
|
ActiveFilters,
|
||||||
|
WhatIfModification,
|
||||||
|
Slice,
|
||||||
} from 'src/dashboard/types';
|
} from 'src/dashboard/types';
|
||||||
import { ChartCustomizationItem } from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
|
import { ChartCustomizationItem } from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
|
||||||
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
|
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
|
||||||
@@ -42,6 +44,10 @@ import {
|
|||||||
} from './chartTypeLimitations';
|
} from './chartTypeLimitations';
|
||||||
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
|
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
|
||||||
import { getAllActiveFilters } from '../activeAllDashboardFilters';
|
import { getAllActiveFilters } from '../activeAllDashboardFilters';
|
||||||
|
import {
|
||||||
|
collectSqlExpressionsFromSlice,
|
||||||
|
findColumnsInSqlExpressions,
|
||||||
|
} from '../whatIf';
|
||||||
|
|
||||||
interface CachedFormData {
|
interface CachedFormData {
|
||||||
extra_form_data?: JsonObject;
|
extra_form_data?: JsonObject;
|
||||||
@@ -76,6 +82,7 @@ const cachedFormdataByChart: Record<
|
|||||||
CachedFormData & {
|
CachedFormData & {
|
||||||
dataMask: DataMask;
|
dataMask: DataMask;
|
||||||
extraControls: Record<string, string | boolean | null>;
|
extraControls: Record<string, string | boolean | null>;
|
||||||
|
whatIfModifications?: WhatIfModification[];
|
||||||
}
|
}
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
@@ -97,6 +104,7 @@ export interface GetFormDataWithExtraFiltersArguments {
|
|||||||
allSliceIds: number[];
|
allSliceIds: number[];
|
||||||
chartCustomization?: JsonObject;
|
chartCustomization?: JsonObject;
|
||||||
activeFilters?: ActiveFilters;
|
activeFilters?: ActiveFilters;
|
||||||
|
whatIfModifications?: WhatIfModification[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const createFilterDataMapping = (
|
const createFilterDataMapping = (
|
||||||
@@ -129,19 +137,10 @@ function extractColumnNames(columns: unknown[]): string[] {
|
|||||||
return columnNames;
|
return columnNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildExistingColumnsSet(chart: ChartQueryPayload): Set<string> {
|
function extractColumnsFromMetrics(
|
||||||
const existingColumns = new Set<string>();
|
metrics: any[],
|
||||||
const chartType = chart.form_data?.viz_type;
|
existingColumns: Set<string>,
|
||||||
|
): void {
|
||||||
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 || [];
|
|
||||||
metrics.forEach((metric: any) => {
|
metrics.forEach((metric: any) => {
|
||||||
if (typeof metric === 'string') {
|
if (typeof metric === 'string') {
|
||||||
existingColumns.add(metric);
|
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;
|
const seriesColumn = chart.form_data?.series;
|
||||||
if (seriesColumn) existingColumns.add(seriesColumn);
|
if (seriesColumn) existingColumns.add(seriesColumn);
|
||||||
@@ -450,6 +491,7 @@ export default function getFormDataWithExtraFilters({
|
|||||||
allSliceIds,
|
allSliceIds,
|
||||||
chartCustomization,
|
chartCustomization,
|
||||||
activeFilters: passedActiveFilters,
|
activeFilters: passedActiveFilters,
|
||||||
|
whatIfModifications,
|
||||||
}: GetFormDataWithExtraFiltersArguments) {
|
}: GetFormDataWithExtraFiltersArguments) {
|
||||||
const cachedFormData = cachedFormdataByChart[sliceId];
|
const cachedFormData = cachedFormdataByChart[sliceId];
|
||||||
const dataMaskEqual = areObjectsEqual(cachedFormData?.dataMask, dataMask, {
|
const dataMaskEqual = areObjectsEqual(cachedFormData?.dataMask, dataMask, {
|
||||||
@@ -476,7 +518,8 @@ export default function getFormDataWithExtraFilters({
|
|||||||
}) &&
|
}) &&
|
||||||
areObjectsEqual(cachedFormData?.chart_customization, chartCustomization, {
|
areObjectsEqual(cachedFormData?.chart_customization, chartCustomization, {
|
||||||
ignoreUndefined: true,
|
ignoreUndefined: true,
|
||||||
})
|
}) &&
|
||||||
|
isEqual(cachedFormData?.whatIfModifications, whatIfModifications)
|
||||||
) {
|
) {
|
||||||
return cachedFormData;
|
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;
|
let layerFilterScope: { [filterId: string]: number[] } | undefined;
|
||||||
|
|
||||||
const isDeckMultiChart = chart.form_data?.viz_type === 'deck_multi';
|
const isDeckMultiChart = chart.form_data?.viz_type === 'deck_multi';
|
||||||
@@ -571,6 +643,13 @@ export default function getFormDataWithExtraFilters({
|
|||||||
...groupByFormData,
|
...groupByFormData,
|
||||||
...(chartCustomization && { chart_customization: chartCustomization }),
|
...(chartCustomization && { chart_customization: chartCustomization }),
|
||||||
...(layerFilterScope && { layer_filter_scope: layerFilterScope }),
|
...(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;
|
cachedFiltersByChart[sliceId] = filters;
|
||||||
@@ -579,6 +658,7 @@ export default function getFormDataWithExtraFilters({
|
|||||||
dataMask,
|
dataMask,
|
||||||
extraControls,
|
extraControls,
|
||||||
...(chartCustomization && { chart_customization: chartCustomization }),
|
...(chartCustomization && { chart_customization: chartCustomization }),
|
||||||
|
whatIfModifications,
|
||||||
};
|
};
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
|
|||||||
@@ -24,6 +24,24 @@ export const arrayDiff = (a: string[], b: string[]) => [
|
|||||||
...b.filter(x => !a.includes(x)),
|
...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 = (
|
export const getAffectedOwnDataCharts = (
|
||||||
ownDataCharts: JsonObject,
|
ownDataCharts: JsonObject,
|
||||||
appliedOwnDataCharts: JsonObject,
|
appliedOwnDataCharts: JsonObject,
|
||||||
@@ -35,9 +53,10 @@ export const getAffectedOwnDataCharts = (
|
|||||||
);
|
);
|
||||||
const checkForUpdateIds = new Set<string>([...chartIds, ...appliedChartIds]);
|
const checkForUpdateIds = new Set<string>([...chartIds, ...appliedChartIds]);
|
||||||
checkForUpdateIds.forEach(chartId => {
|
checkForUpdateIds.forEach(chartId => {
|
||||||
if (
|
// Compare ownState excluding fields that don't require re-querying
|
||||||
!areObjectsEqual(ownDataCharts[chartId], appliedOwnDataCharts[chartId])
|
const currentState = getComparableOwnState(ownDataCharts[chartId]);
|
||||||
) {
|
const appliedState = getComparableOwnState(appliedOwnDataCharts[chartId]);
|
||||||
|
if (!areObjectsEqual(currentState, appliedState)) {
|
||||||
affectedIds.push(chartId);
|
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(
|
const RolesList = lazy(
|
||||||
() => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'),
|
() => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'),
|
||||||
);
|
);
|
||||||
@@ -289,6 +296,10 @@ export const routes: Routes = [
|
|||||||
path: '/rowlevelsecurity/list',
|
path: '/rowlevelsecurity/list',
|
||||||
Component: RowLevelSecurityList,
|
Component: RowLevelSecurityList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/whatif/simulations/',
|
||||||
|
Component: WhatIfSimulationList,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/sqllab/',
|
path: '/sqllab/',
|
||||||
Component: SqlLab,
|
Component: SqlLab,
|
||||||
|
|||||||
@@ -1029,6 +1029,16 @@ class ChartDataExtrasSchema(Schema):
|
|||||||
},
|
},
|
||||||
allow_none=True,
|
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):
|
class AnnotationLayerSchema(Schema):
|
||||||
|
|||||||
@@ -1145,6 +1145,18 @@ QUERY_LOGGER = None
|
|||||||
# Set this API key to enable Mapbox visualizations
|
# Set this API key to enable Mapbox visualizations
|
||||||
MAPBOX_API_KEY = os.environ.get("MAPBOX_API_KEY", "")
|
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
|
# Maximum number of rows returned for any analytical database query
|
||||||
SQL_MAX_ROW = 100000
|
SQL_MAX_ROW = 100000
|
||||||
|
|
||||||
|
|||||||
@@ -1516,11 +1516,172 @@ class SqlaTable(
|
|||||||
def get_from_clause(
|
def get_from_clause(
|
||||||
self,
|
self,
|
||||||
template_processor: BaseTemplateProcessor | None = None,
|
template_processor: BaseTemplateProcessor | None = None,
|
||||||
|
what_if: dict[str, Any] | None = None,
|
||||||
) -> tuple[TableClause | Alias, str | None]:
|
) -> tuple[TableClause | Alias, str | None]:
|
||||||
if not self.is_virtual:
|
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(
|
def adhoc_metric_to_sqla(
|
||||||
self,
|
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.user_registrations import UserRegistrationsView
|
||||||
from superset.views.users.api import CurrentUserRestApi, UserRestApi
|
from superset.views.users.api import CurrentUserRestApi, UserRestApi
|
||||||
from superset.views.users_list import UsersListView
|
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)
|
set_app_error_handlers(self.superset_app)
|
||||||
self.register_request_handlers()
|
self.register_request_handlers()
|
||||||
@@ -266,6 +268,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||||||
appbuilder.add_api(RLSRestApi)
|
appbuilder.add_api(RLSRestApi)
|
||||||
appbuilder.add_api(SavedQueryRestApi)
|
appbuilder.add_api(SavedQueryRestApi)
|
||||||
appbuilder.add_api(TagRestApi)
|
appbuilder.add_api(TagRestApi)
|
||||||
|
appbuilder.add_api(WhatIfRestApi)
|
||||||
appbuilder.add_api(SqlLabRestApi)
|
appbuilder.add_api(SqlLabRestApi)
|
||||||
appbuilder.add_api(SqlLabPermalinkRestApi)
|
appbuilder.add_api(SqlLabPermalinkRestApi)
|
||||||
appbuilder.add_api(LogRestApi)
|
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(RoleRestAPI)
|
||||||
appbuilder.add_view_no_menu(UserInfoView)
|
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
|
# 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.sql.selectable import Alias, TableClause
|
||||||
from sqlalchemy_utils import UUIDType
|
from sqlalchemy_utils import UUIDType
|
||||||
|
|
||||||
|
import sqlglot
|
||||||
|
from sqlglot import exp
|
||||||
|
|
||||||
from superset import db, is_feature_enabled
|
from superset import db, is_feature_enabled
|
||||||
from superset.advanced_data_type.types import AdvancedDataTypeResponse
|
from superset.advanced_data_type.types import AdvancedDataTypeResponse
|
||||||
from superset.common.db_query_status import QueryStatus
|
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)
|
return self.db_engine_spec.get_text_clause(clause)
|
||||||
|
|
||||||
def get_from_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]]:
|
) -> tuple[Union[TableClause, Alias], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Return where to select the columns and metrics from. Either a physical table
|
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
|
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(
|
def adhoc_metric_to_sqla(
|
||||||
self,
|
self,
|
||||||
metric: AdhocMetric,
|
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
|
# Process FROM clause early to populate removed_filters from virtual dataset
|
||||||
# templates before we decide whether to add time filters
|
# 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:
|
||||||
if granularity not in columns_by_name or not dttm_col:
|
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