mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
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:
4
superset-embedded-sdk/package-lock.json
generated
4
superset-embedded-sdk/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user