feat(embed): get charts payload (#36237)

Co-authored-by: Vitor Avila <vitorfragadeavila@gmail.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
This commit is contained in:
Alexandru Soare
2025-11-28 16:26:30 +02:00
committed by GitHub
parent 81e561bdc9
commit 341ae994c5
7 changed files with 385 additions and 6 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "@superset-ui/embedded-sdk",
"version": "0.2.0",
"version": "0.3.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@superset-ui/embedded-sdk",
"version": "0.2.0",
"version": "0.3.0",
"license": "Apache-2.0",
"dependencies": {
"@superset-ui/switchboard": "^0.20.3",

View File

@@ -1,6 +1,6 @@
{
"name": "@superset-ui/embedded-sdk",
"version": "0.2.0",
"version": "0.3.0",
"description": "SDK for embedding resources from Superset into your own application",
"access": "public",
"keywords": [

View File

@@ -93,6 +93,7 @@ export type EmbeddedDashboard = {
) => void;
getDataMask: () => Promise<Record<string, any>>;
getChartStates: () => Promise<Record<string, any>>;
getChartDataPayloads: (params?: { chartId?: number }) => Promise<Record<string, any>>;
setThemeConfig: (themeConfig: Record<string, any>) => void;
setThemeMode: (mode: ThemeMode) => void;
};
@@ -249,6 +250,8 @@ export async function embedDashboard({
const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs');
const getDataMask = () => ourPort.get<Record<string, any>>('getDataMask');
const getChartStates = () => ourPort.get<Record<string, any>>('getChartStates');
const getChartDataPayloads = (params?: { chartId?: number }) =>
ourPort.get<Record<string, any>>('getChartDataPayloads', params);
const observeDataMask = (
callbackFn: ObserveDataMaskCallbackFn,
) => {
@@ -288,6 +291,7 @@ export async function embedDashboard({
observeDataMask,
getDataMask,
getChartStates,
getChartDataPayloads,
setThemeConfig,
setThemeMode,
};

View File

@@ -16,12 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DataMaskStateWithId } from '@superset-ui/core';
import { DataMaskStateWithId, JsonObject } from '@superset-ui/core';
import getBootstrapData from 'src/utils/getBootstrapData';
import { store } from '../views/store';
import { getDashboardPermalink as getDashboardPermalinkUtil } from '../utils/urlUtils';
import { DashboardChartStates } from '../dashboard/types/chartState';
import { hasStatefulCharts } from '../dashboard/util/chartStateConverter';
import { getChartDataPayloads as getChartDataPayloadsUtil } from './utils';
const bootstrapData = getBootstrapData();
@@ -36,6 +37,9 @@ type EmbeddedSupersetApi = {
getActiveTabs: () => string[];
getDataMask: () => DataMaskStateWithId;
getChartStates: () => DashboardChartStates;
getChartDataPayloads: (params?: {
chartId?: number;
}) => Promise<Record<string, JsonObject>>;
};
const getScrollSize = (): Size => ({
@@ -80,10 +84,20 @@ const getDataMask = () => store?.getState()?.dataMask || {};
const getChartStates = () =>
store?.getState()?.dashboardState?.chartStates || {};
const getChartDataPayloads = async (params?: {
chartId?: number;
}): Promise<Record<string, JsonObject>> => {
const state = store?.getState();
if (!state) return {};
return getChartDataPayloadsUtil(state, params);
};
export const embeddedApi: EmbeddedSupersetApi = {
getScrollSize,
getDashboardPermalink,
getActiveTabs,
getDataMask,
getChartStates,
getChartDataPayloads,
};

View File

@@ -247,6 +247,10 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
Switchboard.defineMethod('getActiveTabs', embeddedApi.getActiveTabs);
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
Switchboard.defineMethod('getChartStates', embeddedApi.getChartStates);
Switchboard.defineMethod(
'getChartDataPayloads',
embeddedApi.getChartDataPayloads,
);
Switchboard.defineMethod(
'setThemeConfig',
(payload: { themeConfig: SupersetThemeConfig }) => {

View File

@@ -18,7 +18,17 @@
*/
import { DataMaskStateWithId } from '@superset-ui/core';
import { cloneDeep } from 'lodash';
import { getDataMaskChangeTrigger } from './utils';
import { getDataMaskChangeTrigger, getChartDataPayloads } from './utils';
import { RootState } from 'src/views/store';
import * as chartStateConverter from 'src/dashboard/util/chartStateConverter';
import * as exploreUtils from 'src/explore/exploreUtils';
import getFormDataWithExtraFilters from 'src/dashboard/util/charts/getFormDataWithExtraFilters';
import { getAppliedFilterValues } from 'src/dashboard/util/activeDashboardFilters';
jest.mock('src/dashboard/util/chartStateConverter');
jest.mock('src/dashboard/util/charts/getFormDataWithExtraFilters');
jest.mock('src/dashboard/util/activeDashboardFilters');
jest.mock('src/explore/exploreUtils');
const dataMask: DataMaskStateWithId = {
'1': {
@@ -74,3 +84,219 @@ test('a cross filter changed - crossFiltersChanged set to true', () => {
nativeFiltersChanged: false,
});
});
const mockState: Partial<RootState> = {
charts: {
'123': {
id: 123,
form_data: {
viz_type: 'ag-grid-table',
datasource: '1__table',
},
},
'456': {
id: 456,
form_data: {
viz_type: 'table',
datasource: '2__table',
},
},
},
sliceEntities: {
slices: {
'123': {
slice_id: 123,
slice_name: 'Test Chart 1',
viz_type: 'ag-grid-table',
},
'456': {
slice_id: 456,
slice_name: 'Test Chart 2',
viz_type: 'table',
},
},
isLoading: false,
},
dataMask: {
'123': {
id: '123',
extraFormData: {},
filterState: {},
ownState: { someState: 'value' },
},
},
dashboardState: {
sliceIds: [123, 456],
chartStates: {
'123': {
state: { sortModel: [{ colId: 'col1', sort: 'asc' }] },
},
},
colorScheme: 'supersetColors',
colorNamespace: 'dashboard',
activeTabs: [],
},
dashboardInfo: {
metadata: {
chart_configuration: {},
},
},
nativeFilters: {
filters: {},
},
} as Partial<RootState>;
beforeEach(() => {
jest.clearAllMocks();
(getFormDataWithExtraFilters as jest.Mock).mockImplementation(
({ chart }: any) => chart.form_data,
);
(getAppliedFilterValues as jest.Mock).mockReturnValue({});
});
test('getChartDataPayloads returns empty object when charts with state converters are not found', async () => {
const mockHasChartStateConverter = jest
.spyOn(chartStateConverter, 'hasChartStateConverter')
.mockReturnValue(false);
const result = await getChartDataPayloads(mockState as RootState);
expect(result).toEqual({});
expect(mockHasChartStateConverter).toHaveBeenCalledWith('ag-grid-table');
expect(mockHasChartStateConverter).toHaveBeenCalledWith('table');
});
test('getChartDataPayloads generates payloads for charts with state converters', async () => {
const mockPayload = {
queries: [{ some: 'query' }],
};
jest
.spyOn(chartStateConverter, 'hasChartStateConverter')
.mockImplementation((vizType: string) => vizType === 'ag-grid-table');
jest
.spyOn(chartStateConverter, 'convertChartStateToOwnState')
.mockReturnValue({ converted: 'state' });
jest
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
.mockResolvedValue(mockPayload);
const result = await getChartDataPayloads(mockState as RootState);
expect(result).toEqual({
'123': mockPayload,
});
expect(chartStateConverter.convertChartStateToOwnState).toHaveBeenCalledWith(
'ag-grid-table',
{
sortModel: [{ colId: 'col1', sort: 'asc' }],
},
);
});
test('getChartDataPayloads filters by specific chartId when provided', async () => {
const mockPayload = {
queries: [{ some: 'query' }],
};
jest
.spyOn(chartStateConverter, 'hasChartStateConverter')
.mockReturnValue(true);
jest
.spyOn(chartStateConverter, 'convertChartStateToOwnState')
.mockReturnValue({ converted: 'state' });
jest
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
.mockResolvedValue(mockPayload);
const result = await getChartDataPayloads(mockState as RootState, {
chartId: 123,
});
expect(result).toEqual({
'123': mockPayload,
});
});
test('getChartDataPayloads returns error object for specific chartId not found', async () => {
jest
.spyOn(chartStateConverter, 'hasChartStateConverter')
.mockReturnValue(false);
const result = await getChartDataPayloads(mockState as RootState, {
chartId: 999,
});
expect(result).toEqual({
'999': {
error: true,
message: 'Chart 999 not found or is not a stateful chart',
},
});
});
test('getChartDataPayloads handles errors during payload generation gracefully', async () => {
const mockPayload = {
queries: [{ some: 'query' }],
};
jest
.spyOn(chartStateConverter, 'hasChartStateConverter')
.mockReturnValue(true);
jest
.spyOn(chartStateConverter, 'convertChartStateToOwnState')
.mockReturnValue({ converted: 'state' });
jest
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
.mockImplementation((params: any) => {
if (params.formData.viz_type === 'ag-grid-table') {
return Promise.reject(new Error('Failed to build payload'));
}
return Promise.resolve(mockPayload);
});
const result = await getChartDataPayloads(mockState as RootState);
expect(result).toEqual({
'123': {
error: true,
message: 'Failed to build payload',
},
'456': mockPayload,
});
});
test('getChartDataPayloads merges baseOwnState with converted chart state', async () => {
const mockPayload = {
queries: [{ some: 'query' }],
};
jest
.spyOn(chartStateConverter, 'hasChartStateConverter')
.mockReturnValue(true);
jest
.spyOn(chartStateConverter, 'convertChartStateToOwnState')
.mockReturnValue({ converted: 'state' });
const mockBuildPayload = jest
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
.mockResolvedValue(mockPayload);
await getChartDataPayloads(mockState as RootState, { chartId: 123 });
expect(mockBuildPayload).toHaveBeenCalledWith(
expect.objectContaining({
ownState: {
someState: 'value',
converted: 'state',
},
}),
);
});

View File

@@ -17,9 +17,17 @@
* under the License.
*/
import { DataMaskStateWithId } from '@superset-ui/core';
import { DataMaskStateWithId, JsonObject, logging } from '@superset-ui/core';
import { isEmpty, isEqual } from 'lodash';
import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
import {
hasChartStateConverter,
convertChartStateToOwnState,
} from 'src/dashboard/util/chartStateConverter';
import getFormDataWithExtraFilters from 'src/dashboard/util/charts/getFormDataWithExtraFilters';
import { getAppliedFilterValues } from 'src/dashboard/util/activeDashboardFilters';
import { buildV1ChartDataPayload } from 'src/explore/exploreUtils';
import { RootState } from 'src/views/store';
export const getDataMaskChangeTrigger = (
dataMask: DataMaskStateWithId,
@@ -44,3 +52,126 @@ export const getDataMaskChangeTrigger = (
}
return { crossFiltersChanged, nativeFiltersChanged };
};
/**
* Get query context payloads for stateful charts only (e.g., AG Grid tables).
* Returns payloads only for charts that have registered state converters.
* Non-stateful charts will not be included in the result.
*
* These payloads include dashboard filters and chart state (sorting, column order, etc.)
* and can be POSTed directly to /api/v1/chart/data for CSV export.
*
* If payload generation fails for a chart, an error object will be returned for that chart
* containing `{ error: true, message: string }`, allowing other charts to process successfully.
*
* @param state - Redux store state
* @param chartId - Optional chart ID to get payload for a specific chart only
* @returns Record of chart IDs to their query context payloads (only for stateful charts).
* Failed charts will have an error object instead of a valid payload.
*/
export const getChartDataPayloads = async (
state: RootState,
params?: {
chartId?: number;
},
): Promise<Record<string, JsonObject>> => {
const { chartId } = params || {};
const charts = state.charts || {};
const sliceEntities = state.sliceEntities?.slices || {};
const dataMask = state.dataMask || {};
const chartStates = state.dashboardState?.chartStates || {};
const chartConfiguration =
state.dashboardInfo?.metadata?.chart_configuration || {};
const nativeFilters = state.nativeFilters?.filters || {};
const allSliceIds = state.dashboardState?.sliceIds || [];
const colorScheme = state.dashboardState?.colorScheme;
const colorNamespace = state.dashboardState?.colorNamespace;
const chartEntries = Object.entries(charts).filter(([id]) => {
const numericId = Number(id);
const slice = sliceEntities[id];
if (!slice || !hasChartStateConverter(slice.viz_type)) {
return false;
}
if (chartId !== undefined && numericId !== chartId) {
return false;
}
return true;
});
const payloadPromises = chartEntries.map(async ([id, chart]) => {
const numericId = Number(id);
const slice = sliceEntities[id];
try {
if (!chart || typeof chart !== 'object' || !('form_data' in chart)) {
throw new Error(`Chart ${id} is missing form_data`);
}
const formData = getFormDataWithExtraFilters({
chart: { id: numericId, form_data: (chart as JsonObject).form_data },
chartConfiguration,
filters: getAppliedFilterValues(numericId),
colorScheme,
colorNamespace,
sliceId: numericId,
nativeFilters,
allSliceIds,
dataMask,
extraControls: {},
});
const chartState = chartStates[id]?.state;
const baseOwnState = dataMask[id]?.ownState || {};
const convertedState = chartState
? convertChartStateToOwnState(slice.viz_type, chartState)
: {};
const ownState = {
...baseOwnState,
...convertedState,
};
const payload = await buildV1ChartDataPayload({
formData,
resultFormat: 'json',
resultType: 'results',
ownState,
setDataMask: null,
force: false,
});
return [id, payload] as const;
} catch (error) {
logging.error(`Failed to build payload for chart ${id}:`, error);
return [
id,
{
error: true,
message: error instanceof Error ? error.message : String(error),
},
] as const;
}
});
const results = await Promise.all(payloadPromises);
const payloads = Object.fromEntries(results);
if (chartId !== undefined && Object.keys(payloads).length === 0) {
logging.warn(
`Chart ${chartId} not found or is not a stateful chart with a registered state converter`,
);
return {
[chartId]: {
error: true,
message: `Chart ${chartId} not found or is not a stateful chart`,
},
};
}
return payloads;
};