diff --git a/superset-embedded-sdk/package-lock.json b/superset-embedded-sdk/package-lock.json index 4805d5afa33..902edff160f 100644 --- a/superset-embedded-sdk/package-lock.json +++ b/superset-embedded-sdk/package-lock.json @@ -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", diff --git a/superset-embedded-sdk/package.json b/superset-embedded-sdk/package.json index 884e3595332..f5710345087 100644 --- a/superset-embedded-sdk/package.json +++ b/superset-embedded-sdk/package.json @@ -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": [ diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts index b9405393c57..a10d6e2a61e 100644 --- a/superset-embedded-sdk/src/index.ts +++ b/superset-embedded-sdk/src/index.ts @@ -93,6 +93,7 @@ export type EmbeddedDashboard = { ) => void; getDataMask: () => Promise>; getChartStates: () => Promise>; + getChartDataPayloads: (params?: { chartId?: number }) => Promise>; setThemeConfig: (themeConfig: Record) => void; setThemeMode: (mode: ThemeMode) => void; }; @@ -249,6 +250,8 @@ export async function embedDashboard({ const getActiveTabs = () => ourPort.get('getActiveTabs'); const getDataMask = () => ourPort.get>('getDataMask'); const getChartStates = () => ourPort.get>('getChartStates'); + const getChartDataPayloads = (params?: { chartId?: number }) => + ourPort.get>('getChartDataPayloads', params); const observeDataMask = ( callbackFn: ObserveDataMaskCallbackFn, ) => { @@ -288,6 +291,7 @@ export async function embedDashboard({ observeDataMask, getDataMask, getChartStates, + getChartDataPayloads, setThemeConfig, setThemeMode, }; diff --git a/superset-frontend/src/embedded/api.tsx b/superset-frontend/src/embedded/api.tsx index dd6c077d02b..f1674f0af89 100644 --- a/superset-frontend/src/embedded/api.tsx +++ b/superset-frontend/src/embedded/api.tsx @@ -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>; }; 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> => { + const state = store?.getState(); + if (!state) return {}; + + return getChartDataPayloadsUtil(state, params); +}; + export const embeddedApi: EmbeddedSupersetApi = { getScrollSize, getDashboardPermalink, getActiveTabs, getDataMask, getChartStates, + getChartDataPayloads, }; diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx index 560aed5d0b6..11730d5ed88 100644 --- a/superset-frontend/src/embedded/index.tsx +++ b/superset-frontend/src/embedded/index.tsx @@ -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 }) => { diff --git a/superset-frontend/src/embedded/utils.test.ts b/superset-frontend/src/embedded/utils.test.ts index ed52a84799b..83c727f4a17 100644 --- a/superset-frontend/src/embedded/utils.test.ts +++ b/superset-frontend/src/embedded/utils.test.ts @@ -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 = { + 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; + +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', + }, + }), + ); +}); diff --git a/superset-frontend/src/embedded/utils.ts b/superset-frontend/src/embedded/utils.ts index 8645dd7f08a..7a79938bf04 100644 --- a/superset-frontend/src/embedded/utils.ts +++ b/superset-frontend/src/embedded/utils.ts @@ -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> => { + 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; +};