From d00d65842afe340c96cd47b7b5c26408febdda43 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 14:45:55 -0500 Subject: [PATCH 01/21] feat(extensions): expose toasts to extensions through @apache-superset/core --- .../packages/superset-core/src/index.ts | 1 + .../superset-core/src/toasts/index.ts | 125 ++++++++++++++++++ superset-frontend/src/core/index.ts | 1 + .../src/core/toasts/index.test.ts | 72 ++++++++++ superset-frontend/src/core/toasts/index.ts | 64 +++++++++ .../src/extensions/ExtensionsStartup.tsx | 2 + .../src/extensions/Namespaces.ts | 2 + 7 files changed, 267 insertions(+) create mode 100644 superset-frontend/packages/superset-core/src/toasts/index.ts create mode 100644 superset-frontend/src/core/toasts/index.test.ts create mode 100644 superset-frontend/src/core/toasts/index.ts diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index 6d6a0aa4653f..85b0a08f5e90 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -25,6 +25,7 @@ export * as extensions from './extensions'; export * as menus from './menus'; export * as navigation from './navigation'; export * as sqlLab from './sqlLab'; +export * as toasts from './toasts'; export * as views from './views'; export * as contributions from './contributions'; export * as theme from './theme'; diff --git a/superset-frontend/packages/superset-core/src/toasts/index.ts b/superset-frontend/packages/superset-core/src/toasts/index.ts new file mode 100644 index 000000000000..8eb86c3cbdcc --- /dev/null +++ b/superset-frontend/packages/superset-core/src/toasts/index.ts @@ -0,0 +1,125 @@ +/** + * 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. + */ + +/** + * @fileoverview Toast notification API for Superset extensions. + * + * Extensions use these functions to show transient messages in Superset's + * toast area. + * + * @example + * ```typescript + * import { toasts } from '@apache-superset/core'; + * + * toasts.addSuccessToast('Exported to Google Sheets', { + * link: { href: 'https://docs.google.com/spreadsheets/d/abc', label: 'Open' }, + * }); + * ``` + */ + +/** + * Optional settings for a toast. + */ +export interface ToastOptions { + /** A link shown on the toast; it opens in a new tab. */ + link?: { href: string; label: string }; + /** Time in milliseconds before the toast closes; `0` keeps it until closed. Defaults to 8000. */ + duration?: number; +} + +/** + * Shows an informational toast. + * + * @param text The message, rendered as plain text. + * @param options Optional link and duration. + * @returns The ID of the toast, for use with {@link removeToast}. + * + * @example + * ```typescript + * toasts.addInfoToast('Export started'); + * ``` + */ +export declare function addInfoToast( + text: string, + options?: ToastOptions, +): string; + +/** + * Shows a success toast. + * + * @param text The message, rendered as plain text. + * @param options Optional link and duration. + * @returns The ID of the toast, for use with {@link removeToast}. + * + * @example + * ```typescript + * toasts.addSuccessToast('Export complete'); + * ``` + */ +export declare function addSuccessToast( + text: string, + options?: ToastOptions, +): string; + +/** + * Shows a warning toast. + * + * @param text The message, rendered as plain text. + * @param options Optional link and duration. + * @returns The ID of the toast, for use with {@link removeToast}. + * + * @example + * ```typescript + * toasts.addWarningToast('Export truncated to 10,000 rows'); + * ``` + */ +export declare function addWarningToast( + text: string, + options?: ToastOptions, +): string; + +/** + * Shows an error toast. + * + * @param text The message, rendered as plain text. + * @param options Optional link and duration. + * @returns The ID of the toast, for use with {@link removeToast}. + * + * @example + * ```typescript + * toasts.addDangerToast('Export failed'); + * ``` + */ +export declare function addDangerToast( + text: string, + options?: ToastOptions, +): string; + +/** + * Closes a toast. + * + * @param id The ID returned when the toast was added. + * + * @example + * ```typescript + * const id = toasts.addInfoToast('Exporting...', { duration: 0 }); + * toasts.removeToast(id); + * ``` + */ +export declare function removeToast(id: string): void; diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts index ca8440c68e2e..c519d2d2945f 100644 --- a/superset-frontend/src/core/index.ts +++ b/superset-frontend/src/core/index.ts @@ -36,5 +36,6 @@ export * from './models'; export * from './navigation'; export * from './sqlLab'; export * from './storage'; +export * from './toasts'; export * from './utils'; export * from './views'; diff --git a/superset-frontend/src/core/toasts/index.test.ts b/superset-frontend/src/core/toasts/index.test.ts new file mode 100644 index 000000000000..627d416f935f --- /dev/null +++ b/superset-frontend/src/core/toasts/index.test.ts @@ -0,0 +1,72 @@ +/** + * 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 { store } from 'src/views/store'; +import { ToastMeta, ToastType } from 'src/components/MessageToasts/types'; +import { toasts } from './index'; + +const state = (): ToastMeta[] => store.getState().messageToasts; + +test.each([ + ['addInfoToast', ToastType.Info], + ['addSuccessToast', ToastType.Success], + ['addWarningToast', ToastType.Warning], + ['addDangerToast', ToastType.Danger], +] as const)( + '%s adds a toast of the matching type and returns its id', + (fn, toastType) => { + const id = toasts[fn]('Sent'); + expect(state()).toContainEqual( + expect.objectContaining({ id, text: 'Sent', toastType }), + ); + }, +); + +test('link becomes an action that opens the url in a new tab', () => { + const open = jest.spyOn(window, 'open').mockImplementation(() => null); + const id = toasts.addSuccessToast('Sent', { + link: { href: 'https://docs.google.com/x', label: 'Open' }, + }); + const toast = state().find(t => t.id === id)!; + expect(toast.action?.label).toBe('Open'); + toast.action!.onClick(); + expect(open).toHaveBeenCalledWith( + 'https://docs.google.com/x', + '_blank', + 'noopener,noreferrer', + ); + open.mockRestore(); +}); + +test('duration defaults to 8000 and accepts 0', () => { + const a = toasts.addInfoToast('a'); + const b = toasts.addInfoToast('b', { duration: 0 }); + expect(state().find(t => t.id === a)!.duration).toBe(8000); + expect(state().find(t => t.id === b)!.duration).toBe(0); +}); + +test('removeToast removes the toast', () => { + const id = toasts.addDangerToast('x'); + toasts.removeToast(id); + expect(state().find(t => t.id === id)).toBeUndefined(); +}); + +test('text is not rendered as html, even if extra options are passed', () => { + const id = toasts.addInfoToast('x', { allowHtml: true } as never); + expect(state().find(t => t.id === id)!.allowHtml).toBe(false); +}); diff --git a/superset-frontend/src/core/toasts/index.ts b/superset-frontend/src/core/toasts/index.ts new file mode 100644 index 000000000000..905d0ab88cc3 --- /dev/null +++ b/superset-frontend/src/core/toasts/index.ts @@ -0,0 +1,64 @@ +/** + * 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 type { toasts as toastsApi } from '@apache-superset/core'; +import { store } from 'src/views/store'; +import { + addToast, + removeToast as removeToastAction, +} from 'src/components/MessageToasts/actions'; +import { ToastType } from 'src/components/MessageToasts/types'; + +const DEFAULT_DURATION = 8000; + +// Only `link` and `duration` are read, so HTML rendering and arbitrary callbacks stay unreachable. +const show = ( + toastType: ToastType, + text: string, + options?: toastsApi.ToastOptions, +): string => { + const link = options?.link; + const action = link + ? { + label: link.label, + onClick: () => { + window.open(link.href, '_blank', 'noopener,noreferrer'); + }, + } + : undefined; + const toast = addToast({ + toastType, + text, + duration: options?.duration ?? DEFAULT_DURATION, + allowHtml: false, + action, + }); + store.dispatch(toast); + return toast.payload.id; +}; + +export const toasts: typeof toastsApi = { + addInfoToast: (text, options) => show(ToastType.Info, text, options), + addSuccessToast: (text, options) => show(ToastType.Success, text, options), + addWarningToast: (text, options) => show(ToastType.Warning, text, options), + addDangerToast: (text, options) => show(ToastType.Danger, text, options), + removeToast: id => { + store.dispatch(removeToastAction(id)); + }, +}; diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index dd639ec1b18d..c838c67fc553 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -31,6 +31,7 @@ import { navigation, useNavigationTracker, sqlLab, + toasts, views, } from 'src/core'; import { useSelector } from 'react-redux'; @@ -64,6 +65,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ menus, navigation, sqlLab, + toasts, views, }; diff --git a/superset-frontend/src/extensions/Namespaces.ts b/superset-frontend/src/extensions/Namespaces.ts index 0e7f6a3063b2..c009d7259c79 100644 --- a/superset-frontend/src/extensions/Namespaces.ts +++ b/superset-frontend/src/extensions/Namespaces.ts @@ -36,6 +36,7 @@ import type { menus, navigation, sqlLab, + toasts, views, } from 'src/core'; @@ -50,6 +51,7 @@ export interface Namespaces { menus: typeof menus; navigation: typeof navigation; sqlLab: typeof sqlLab; + toasts: typeof toasts; views: typeof views; } From ef5612c1fc41ef42d0fc30df28f678b4cda55566 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 14:48:24 -0500 Subject: [PATCH 02/21] feat(extensions): add download menu locations and useExtensionMenuItems --- .../packages/superset-core/src/menus/index.ts | 2 + .../superset-core/src/menus/locations.ts | 72 +++++++ .../ExtensionMenuItems/index.test.tsx | 180 ++++++++++++++++++ .../components/ExtensionMenuItems/index.tsx | 107 +++++++++++ 4 files changed, 361 insertions(+) create mode 100644 superset-frontend/packages/superset-core/src/menus/locations.ts create mode 100644 superset-frontend/src/components/ExtensionMenuItems/index.test.tsx create mode 100644 superset-frontend/src/components/ExtensionMenuItems/index.tsx diff --git a/superset-frontend/packages/superset-core/src/menus/index.ts b/superset-frontend/packages/superset-core/src/menus/index.ts index fe1f9399d9b8..1db784c93d82 100644 --- a/superset-frontend/packages/superset-core/src/menus/index.ts +++ b/superset-frontend/packages/superset-core/src/menus/index.ts @@ -39,6 +39,8 @@ import { Disposable, Event } from '../common'; +export * from './locations'; + /** * Represents a menu item that links a view to a command. */ diff --git a/superset-frontend/packages/superset-core/src/menus/locations.ts b/superset-frontend/packages/superset-core/src/menus/locations.ts new file mode 100644 index 000000000000..4e7f6ef62acb --- /dev/null +++ b/superset-frontend/packages/superset-core/src/menus/locations.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ + +/** + * Chart download menus that accept extension items. Each command receives a + * {@link ChartDownloadContext}. + */ +export type ChartDownloadLocation = + | "explore.chart.download" + | "explore.chart.download.pivoted" + | "explore.standalone.download" + | "dashboard.chart.download" + | "dashboard.chart.download.pivoted" + | "chart.drillDetail.download" + | "chart.drillBy.download"; + +/** + * Dashboard download menus that accept extension items. Each command receives + * a {@link DashboardDownloadContext}. + */ +export type DashboardDownloadLocation = "dashboard.download"; + +/** + * Argument passed to commands contributed to a {@link ChartDownloadLocation}. + */ +export interface ChartDownloadContext { + /** + * Resolves to a query-context object with `result_format: 'json'`. + * Evaluated when called, so invoke it from the command rather than ahead of time. + */ + getPayload: () => Promise>; + /** Display name of the chart. */ + chartName: string; + /** Whether the current user may download this chart's data. */ + canDownload: boolean; +} + +/** + * Argument passed to commands contributed to a {@link DashboardDownloadLocation}. + */ +export interface DashboardDownloadContext { + /** Numeric id of the dashboard. */ + dashboardId: number; + /** Title of the dashboard. */ + dashboardTitle: string; + /** Currently applied native filters, keyed by filter id. */ + activeDataMask: Record< + string, + { + extraFormData: object; + filterState: { value?: unknown; label?: string }; + } + >; + /** Whether the current user may download this dashboard's data. */ + canDownload: boolean; +} diff --git a/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx new file mode 100644 index 000000000000..7b253d649ee2 --- /dev/null +++ b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx @@ -0,0 +1,180 @@ +/** + * 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 { renderHook, act } from "@testing-library/react"; +import { logging } from "@apache-superset/core/utils"; +import type { common } from "@apache-superset/core"; +import type { MenuItem, MenuItemType } from "@superset-ui/core/components/Menu"; +import { commands, menus } from "src/core"; +import { + useExtensionMenuItems, + ChartDownloadProvider, + useChartDownloadItems, +} from "."; + +const ctx = { + getPayload: async () => ({}), + chartName: "Sales", + canDownload: true, +}; + +const click = (item: MenuItem) => (item as MenuItemType).onClick?.({} as never); + +let disposables: common.Disposable[] = []; + +const register = ( + id: string, + title: string, + location: Parameters[1], + run: (...args: unknown[]) => unknown = jest.fn(), +) => { + disposables.push( + commands.registerCommand({ id, title }, run), + menus.registerMenuItem( + { view: location, command: id }, + location, + "primary", + ), + ); +}; + +afterEach(() => { + disposables.forEach((d) => d.dispose()); + disposables = []; + jest.restoreAllMocks(); +}); + +test("returns no items when nothing is registered", () => { + const { result } = renderHook(() => + useExtensionMenuItems("explore.chart.download", ctx), + ); + expect(result.current).toEqual([]); +}); + +test("maps a registered item to a menu item that runs the command with the context", async () => { + const run = jest.fn(); + register("ext.gs", "Export to Google Sheets", "explore.chart.download", run); + const { result } = renderHook(() => + useExtensionMenuItems("explore.chart.download", ctx), + ); + expect(result.current).toEqual([ + expect.objectContaining({ + key: "ext:ext.gs", + label: "Export to Google Sheets", + disabled: false, + }), + ]); + await act(async () => click(result.current[0])); + expect(run).toHaveBeenCalledWith(ctx); +}); + +test("items registered after first render appear", () => { + const { result } = renderHook(() => + useExtensionMenuItems("dashboard.download", { + dashboardId: 1, + dashboardTitle: "D", + activeDataMask: {}, + canDownload: true, + }), + ); + expect(result.current).toEqual([]); + act(() => { + register("ext.late", "Late", "dashboard.download"); + }); + expect(result.current).toHaveLength(1); +}); + +test("disabled without download permission", () => { + register("ext.denied", "Denied", "explore.chart.download"); + const { result } = renderHook(() => + useExtensionMenuItems("explore.chart.download", { + ...ctx, + canDownload: false, + }), + ); + expect(result.current).toEqual([ + expect.objectContaining({ key: "ext:ext.denied", disabled: true }), + ]); +}); + +test("click uses the latest context, not the one from the first render", async () => { + const run = jest.fn(); + register("ext.fresh", "Fresh", "explore.chart.download", run); + const { result, rerender } = renderHook( + ({ c }) => useExtensionMenuItems("explore.chart.download", c), + { initialProps: { c: ctx } }, + ); + const first = result.current; + const newer = { ...ctx, chartName: "Renamed" }; + rerender({ c: newer }); + expect(result.current).toBe(first); + await act(async () => click(result.current[0])); + expect(run).toHaveBeenCalledWith(newer); +}); + +test("onItemClick runs after the command starts", async () => { + const run = jest.fn(); + const onItemClick = jest.fn(); + register("ext.close", "Close", "explore.chart.download", run); + const { result } = renderHook(() => + useExtensionMenuItems("explore.chart.download", ctx, { onItemClick }), + ); + await act(async () => click(result.current[0])); + expect(run).toHaveBeenCalled(); + expect(onItemClick).toHaveBeenCalledTimes(1); +}); + +test("disabled state follows canDownload changes", () => { + register("ext.toggle", "Toggle", "explore.chart.download"); + const { result, rerender } = renderHook( + ({ c }) => useExtensionMenuItems("explore.chart.download", c), + { initialProps: { c: ctx } }, + ); + expect(result.current[0]).toEqual( + expect.objectContaining({ disabled: false }), + ); + rerender({ c: { ...ctx, canDownload: false } }); + expect(result.current[0]).toEqual( + expect.objectContaining({ disabled: true }), + ); +}); + +test("a rejecting command is caught and logged", async () => { + const log = jest.spyOn(logging, "error").mockImplementation(() => {}); + register("ext.bad", "Bad", "explore.chart.download", () => + Promise.reject(new Error("x")), + ); + const { result } = renderHook(() => + useExtensionMenuItems("explore.chart.download", ctx), + ); + await act(async () => click(result.current[0])); + expect(log).toHaveBeenCalled(); +}); + +test("useChartDownloadItems reads the provider and is empty without one", () => { + expect(renderHook(() => useChartDownloadItems()).result.current).toEqual([]); + register("ext.drill", "Drill", "chart.drillBy.download"); + const { result } = renderHook(() => useChartDownloadItems(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + expect(result.current).toHaveLength(1); +}); diff --git a/superset-frontend/src/components/ExtensionMenuItems/index.tsx b/superset-frontend/src/components/ExtensionMenuItems/index.tsx new file mode 100644 index 000000000000..27c29fd84474 --- /dev/null +++ b/superset-frontend/src/components/ExtensionMenuItems/index.tsx @@ -0,0 +1,107 @@ +/** + * 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 { createContext, useContext, useMemo, useRef } from "react"; +import type { menus } from "@apache-superset/core"; +import { logging } from "@apache-superset/core/utils"; +import type { MenuItem } from "@superset-ui/core/components/Menu"; +import { commands, useMenu } from "src/core"; + +type ChartDownloadLocation = menus.ChartDownloadLocation; +type DashboardDownloadLocation = menus.DashboardDownloadLocation; +type ChartDownloadContext = menus.ChartDownloadContext; +type DashboardDownloadContext = menus.DashboardDownloadContext; + +/** + * Menu items for extension commands registered at `location`; empty when none. + * + * An extension must register its command before its menu item: `useMenu` + * re-renders on menu changes, not command registration, so an item whose + * command is not yet registered is skipped until the menu next re-renders. + */ +export function useExtensionMenuItems( + location: ChartDownloadLocation | DashboardDownloadLocation, + context: ChartDownloadContext | DashboardDownloadContext, + options?: { onItemClick?: () => void }, +): MenuItem[] { + const menu = useMenu(location)?.primary; + // Latest values are read at click time so items need not rebuild per render. + const latest = useRef({ context, onItemClick: options?.onItemClick }); + latest.current = { context, onItemClick: options?.onItemClick }; + const { canDownload } = context; + + return useMemo(() => { + const items: MenuItem[] = []; + (menu ?? []).forEach((item) => { + const command = commands.getCommand(item.command); + if (!command) { + return; + } + items.push({ + key: `ext:${command.id}`, + label: command.title, + disabled: !canDownload, + onClick: () => { + Promise.resolve( + commands.executeCommand(item.command, latest.current.context), + ).catch((error) => + logging.error(`Extension command "${item.command}" failed`, error), + ); + latest.current.onItemClick?.(); + }, + }); + }); + return items; + }, [menu, canDownload]); +} + +interface ChartDownloadProviderValue { + location: ChartDownloadLocation; + context: ChartDownloadContext; +} + +const ChartDownloadCtx = createContext(null); + +/** Supplies the chart download location and context to nested menus. */ +export const ChartDownloadProvider: React.FC< + ChartDownloadProviderValue & { children?: React.ReactNode } +> = ({ location, context, children }) => { + const value = useMemo(() => ({ location, context }), [location, context]); + return ( + + {children} + + ); +}; + +const NO_ITEMS: MenuItem[] = []; +const NO_CONTEXT: ChartDownloadContext = { + getPayload: async () => ({}), + chartName: "", + canDownload: false, +}; + +/** Extension items for the nearest {@link ChartDownloadProvider}; empty without one. */ +export function useChartDownloadItems(): MenuItem[] { + const provided = useContext(ChartDownloadCtx); + const items = useExtensionMenuItems( + provided?.location ?? "explore.chart.download", + provided?.context ?? NO_CONTEXT, + ); + return provided ? items : NO_ITEMS; +} From 052cf7cb0c0191057aaf3f58d0f5bdef4b2380d8 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 14:50:00 -0500 Subject: [PATCH 03/21] style(extensions): format download menu locations and hook with oxfmt --- .../superset-core/src/menus/locations.ts | 16 ++-- .../ExtensionMenuItems/index.test.tsx | 82 +++++++++---------- .../components/ExtensionMenuItems/index.tsx | 18 ++-- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/superset-frontend/packages/superset-core/src/menus/locations.ts b/superset-frontend/packages/superset-core/src/menus/locations.ts index 4e7f6ef62acb..a0556792f973 100644 --- a/superset-frontend/packages/superset-core/src/menus/locations.ts +++ b/superset-frontend/packages/superset-core/src/menus/locations.ts @@ -22,19 +22,19 @@ * {@link ChartDownloadContext}. */ export type ChartDownloadLocation = - | "explore.chart.download" - | "explore.chart.download.pivoted" - | "explore.standalone.download" - | "dashboard.chart.download" - | "dashboard.chart.download.pivoted" - | "chart.drillDetail.download" - | "chart.drillBy.download"; + | 'explore.chart.download' + | 'explore.chart.download.pivoted' + | 'explore.standalone.download' + | 'dashboard.chart.download' + | 'dashboard.chart.download.pivoted' + | 'chart.drillDetail.download' + | 'chart.drillBy.download'; /** * Dashboard download menus that accept extension items. Each command receives * a {@link DashboardDownloadContext}. */ -export type DashboardDownloadLocation = "dashboard.download"; +export type DashboardDownloadLocation = 'dashboard.download'; /** * Argument passed to commands contributed to a {@link ChartDownloadLocation}. diff --git a/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx index 7b253d649ee2..59d99cf6656b 100644 --- a/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx +++ b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx @@ -16,20 +16,20 @@ * specific language governing permissions and limitations * under the License. */ -import { renderHook, act } from "@testing-library/react"; -import { logging } from "@apache-superset/core/utils"; -import type { common } from "@apache-superset/core"; -import type { MenuItem, MenuItemType } from "@superset-ui/core/components/Menu"; -import { commands, menus } from "src/core"; +import { renderHook, act } from '@testing-library/react'; +import { logging } from '@apache-superset/core/utils'; +import type { common } from '@apache-superset/core'; +import type { MenuItem, MenuItemType } from '@superset-ui/core/components/Menu'; +import { commands, menus } from 'src/core'; import { useExtensionMenuItems, ChartDownloadProvider, useChartDownloadItems, -} from "."; +} from '.'; const ctx = { getPayload: async () => ({}), - chartName: "Sales", + chartName: 'Sales', canDownload: true, }; @@ -48,34 +48,34 @@ const register = ( menus.registerMenuItem( { view: location, command: id }, location, - "primary", + 'primary', ), ); }; afterEach(() => { - disposables.forEach((d) => d.dispose()); + disposables.forEach(d => d.dispose()); disposables = []; jest.restoreAllMocks(); }); -test("returns no items when nothing is registered", () => { +test('returns no items when nothing is registered', () => { const { result } = renderHook(() => - useExtensionMenuItems("explore.chart.download", ctx), + useExtensionMenuItems('explore.chart.download', ctx), ); expect(result.current).toEqual([]); }); -test("maps a registered item to a menu item that runs the command with the context", async () => { +test('maps a registered item to a menu item that runs the command with the context', async () => { const run = jest.fn(); - register("ext.gs", "Export to Google Sheets", "explore.chart.download", run); + register('ext.gs', 'Export to Google Sheets', 'explore.chart.download', run); const { result } = renderHook(() => - useExtensionMenuItems("explore.chart.download", ctx), + useExtensionMenuItems('explore.chart.download', ctx), ); expect(result.current).toEqual([ expect.objectContaining({ - key: "ext:ext.gs", - label: "Export to Google Sheets", + key: 'ext:ext.gs', + label: 'Export to Google Sheets', disabled: false, }), ]); @@ -83,66 +83,66 @@ test("maps a registered item to a menu item that runs the command with the conte expect(run).toHaveBeenCalledWith(ctx); }); -test("items registered after first render appear", () => { +test('items registered after first render appear', () => { const { result } = renderHook(() => - useExtensionMenuItems("dashboard.download", { + useExtensionMenuItems('dashboard.download', { dashboardId: 1, - dashboardTitle: "D", + dashboardTitle: 'D', activeDataMask: {}, canDownload: true, }), ); expect(result.current).toEqual([]); act(() => { - register("ext.late", "Late", "dashboard.download"); + register('ext.late', 'Late', 'dashboard.download'); }); expect(result.current).toHaveLength(1); }); -test("disabled without download permission", () => { - register("ext.denied", "Denied", "explore.chart.download"); +test('disabled without download permission', () => { + register('ext.denied', 'Denied', 'explore.chart.download'); const { result } = renderHook(() => - useExtensionMenuItems("explore.chart.download", { + useExtensionMenuItems('explore.chart.download', { ...ctx, canDownload: false, }), ); expect(result.current).toEqual([ - expect.objectContaining({ key: "ext:ext.denied", disabled: true }), + expect.objectContaining({ key: 'ext:ext.denied', disabled: true }), ]); }); -test("click uses the latest context, not the one from the first render", async () => { +test('click uses the latest context, not the one from the first render', async () => { const run = jest.fn(); - register("ext.fresh", "Fresh", "explore.chart.download", run); + register('ext.fresh', 'Fresh', 'explore.chart.download', run); const { result, rerender } = renderHook( - ({ c }) => useExtensionMenuItems("explore.chart.download", c), + ({ c }) => useExtensionMenuItems('explore.chart.download', c), { initialProps: { c: ctx } }, ); const first = result.current; - const newer = { ...ctx, chartName: "Renamed" }; + const newer = { ...ctx, chartName: 'Renamed' }; rerender({ c: newer }); expect(result.current).toBe(first); await act(async () => click(result.current[0])); expect(run).toHaveBeenCalledWith(newer); }); -test("onItemClick runs after the command starts", async () => { +test('onItemClick runs after the command starts', async () => { const run = jest.fn(); const onItemClick = jest.fn(); - register("ext.close", "Close", "explore.chart.download", run); + register('ext.close', 'Close', 'explore.chart.download', run); const { result } = renderHook(() => - useExtensionMenuItems("explore.chart.download", ctx, { onItemClick }), + useExtensionMenuItems('explore.chart.download', ctx, { onItemClick }), ); await act(async () => click(result.current[0])); expect(run).toHaveBeenCalled(); expect(onItemClick).toHaveBeenCalledTimes(1); }); -test("disabled state follows canDownload changes", () => { - register("ext.toggle", "Toggle", "explore.chart.download"); +test('disabled state follows canDownload changes', () => { + register('ext.toggle', 'Toggle', 'explore.chart.download'); const { result, rerender } = renderHook( - ({ c }) => useExtensionMenuItems("explore.chart.download", c), + ({ c }) => useExtensionMenuItems('explore.chart.download', c), { initialProps: { c: ctx } }, ); expect(result.current[0]).toEqual( @@ -154,21 +154,21 @@ test("disabled state follows canDownload changes", () => { ); }); -test("a rejecting command is caught and logged", async () => { - const log = jest.spyOn(logging, "error").mockImplementation(() => {}); - register("ext.bad", "Bad", "explore.chart.download", () => - Promise.reject(new Error("x")), +test('a rejecting command is caught and logged', async () => { + const log = jest.spyOn(logging, 'error').mockImplementation(() => {}); + register('ext.bad', 'Bad', 'explore.chart.download', () => + Promise.reject(new Error('x')), ); const { result } = renderHook(() => - useExtensionMenuItems("explore.chart.download", ctx), + useExtensionMenuItems('explore.chart.download', ctx), ); await act(async () => click(result.current[0])); expect(log).toHaveBeenCalled(); }); -test("useChartDownloadItems reads the provider and is empty without one", () => { +test('useChartDownloadItems reads the provider and is empty without one', () => { expect(renderHook(() => useChartDownloadItems()).result.current).toEqual([]); - register("ext.drill", "Drill", "chart.drillBy.download"); + register('ext.drill', 'Drill', 'chart.drillBy.download'); const { result } = renderHook(() => useChartDownloadItems(), { wrapper: ({ children }) => ( diff --git a/superset-frontend/src/components/ExtensionMenuItems/index.tsx b/superset-frontend/src/components/ExtensionMenuItems/index.tsx index 27c29fd84474..d47bd0f55c04 100644 --- a/superset-frontend/src/components/ExtensionMenuItems/index.tsx +++ b/superset-frontend/src/components/ExtensionMenuItems/index.tsx @@ -16,11 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { createContext, useContext, useMemo, useRef } from "react"; -import type { menus } from "@apache-superset/core"; -import { logging } from "@apache-superset/core/utils"; -import type { MenuItem } from "@superset-ui/core/components/Menu"; -import { commands, useMenu } from "src/core"; +import { createContext, useContext, useMemo, useRef } from 'react'; +import type { menus } from '@apache-superset/core'; +import { logging } from '@apache-superset/core/utils'; +import type { MenuItem } from '@superset-ui/core/components/Menu'; +import { commands, useMenu } from 'src/core'; type ChartDownloadLocation = menus.ChartDownloadLocation; type DashboardDownloadLocation = menus.DashboardDownloadLocation; @@ -47,7 +47,7 @@ export function useExtensionMenuItems( return useMemo(() => { const items: MenuItem[] = []; - (menu ?? []).forEach((item) => { + (menu ?? []).forEach(item => { const command = commands.getCommand(item.command); if (!command) { return; @@ -59,7 +59,7 @@ export function useExtensionMenuItems( onClick: () => { Promise.resolve( commands.executeCommand(item.command, latest.current.context), - ).catch((error) => + ).catch(error => logging.error(`Extension command "${item.command}" failed`, error), ); latest.current.onItemClick?.(); @@ -92,7 +92,7 @@ export const ChartDownloadProvider: React.FC< const NO_ITEMS: MenuItem[] = []; const NO_CONTEXT: ChartDownloadContext = { getPayload: async () => ({}), - chartName: "", + chartName: '', canDownload: false, }; @@ -100,7 +100,7 @@ const NO_CONTEXT: ChartDownloadContext = { export function useChartDownloadItems(): MenuItem[] { const provided = useContext(ChartDownloadCtx); const items = useExtensionMenuItems( - provided?.location ?? "explore.chart.download", + provided?.location ?? 'explore.chart.download', provided?.context ?? NO_CONTEXT, ); return provided ? items : NO_ITEMS; From 2c3f7532b17e411865f6a1f55af693048ee55ed9 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 14:59:31 -0500 Subject: [PATCH 04/21] feat(explore): expose chart download menus to extensions --- .../StandaloneDownloadControl.test.tsx | 56 ++++++ .../StandaloneDownloadControl.tsx | 29 ++- .../useExploreAdditionalActionsMenu/index.tsx | 32 ++- .../useExploreAdditionalActionsMenu.test.tsx | 184 +++++++++++++++++- .../useExploreDataExport.ts | 19 +- 5 files changed, 308 insertions(+), 12 deletions(-) diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.test.tsx b/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.test.tsx index a5849c2b4e18..dd2f95757eeb 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.test.tsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.test.tsx @@ -19,6 +19,8 @@ import { render, screen, userEvent } from 'spec/helpers/testing-library'; import { VizType } from '@superset-ui/core'; +import { Slice } from 'src/types/Chart'; +import { commands, menus } from 'src/core'; import { ExportStatus } from 'src/components/StreamingExportModal/StreamingExportModal'; import { useExploreDataExport } from '../useExploreAdditionalActionsMenu/useExploreDataExport'; import StandaloneDownloadControl from './StandaloneDownloadControl'; @@ -65,6 +67,7 @@ describe('StandaloneDownloadControl', () => { exportCSVPivoted: jest.fn(), exportJson, exportExcel, + getExportPayload: jest.fn(), handleExportError: jest.fn(), streamingExportState, }); @@ -144,4 +147,57 @@ describe('StandaloneDownloadControl', () => { expect(exportExcel).toHaveBeenCalledTimes(1); }); + test('appends a contributed item to the dropdown and passes a json payload', async () => { + const getExportPayload = jest.fn().mockResolvedValue({ + result_format: 'json', + result_type: 'full', + }); + mockUseExploreDataExport.mockReturnValue({ + exportCSV, + exportCSVPivoted: jest.fn(), + exportJson, + exportExcel, + getExportPayload, + handleExportError: jest.fn(), + streamingExportState, + }); + const run = jest.fn(); + const disposables = [ + commands.registerCommand( + { id: 'ext.gs.standalone', title: 'Export to Google Sheets' }, + run, + ), + menus.registerMenuItem( + { view: 'explore.standalone.download', command: 'ext.gs.standalone' }, + 'explore.standalone.download', + 'primary', + ), + ]; + + try { + render( + , + { useRedux: true }, + ); + + await userEvent.click(screen.getByTestId('standalone-download-button')); + await userEvent.click(await screen.findByText('Export to Google Sheets')); + + const ctx = run.mock.calls[0][0] as { + chartName: string; + getPayload: () => Promise>; + }; + expect(ctx.chartName).toBe('Standalone chart'); + expect(await ctx.getPayload()).toEqual( + expect.objectContaining({ result_format: 'json' }), + ); + expect(getExportPayload).toHaveBeenCalledWith('full'); + } finally { + disposables.forEach(d => d.dispose()); + } + }); }); diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.tsx b/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.tsx index 33cd41b9fbed..b980d2e03029 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.tsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.tsx @@ -23,6 +23,7 @@ import { Button, Dropdown } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { t } from '@apache-superset/core/translation'; import { css, useTheme } from '@apache-superset/core/theme'; +import { useExtensionMenuItems } from 'src/components/ExtensionMenuItems'; import { StreamingExportModal } from 'src/components/StreamingExportModal'; import { Slice } from 'src/types/Chart'; import { useExploreDataExport } from '../useExploreAdditionalActionsMenu/useExploreDataExport'; @@ -42,13 +43,24 @@ const StandaloneDownloadControl = ({ }: StandaloneDownloadControlProps) => { const theme = useTheme(); - const { exportCSV, exportJson, exportExcel, streamingExportState } = - useExploreDataExport({ - latestQueryFormData, - canDownloadCSV: canDownload, - slice, - ownState, - }); + const { + exportCSV, + exportJson, + exportExcel, + getExportPayload, + streamingExportState, + } = useExploreDataExport({ + latestQueryFormData, + canDownloadCSV: canDownload, + slice, + ownState, + }); + + const extensionItems = useExtensionMenuItems('explore.standalone.download', { + getPayload: () => getExportPayload('full'), + chartName: slice?.slice_name ?? t('Untitled chart'), + canDownload, + }); const downloadMenuItems = useMemo( () => [ @@ -67,8 +79,9 @@ const StandaloneDownloadControl = ({ label: t('Export to Excel'), onClick: exportExcel, }, + ...extensionItems, ], - [exportCSV, exportExcel, exportJson], + [exportCSV, exportExcel, exportJson, extensionItems], ); return ( diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx index 904e0c31ef36..11d8f0f3b039 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx @@ -75,6 +75,7 @@ import { ReportObject } from 'src/features/reports/types'; import ViewQueryModal from '../controls/ViewQueryModal'; import EmbedCodeContent from '../EmbedCodeContent'; import { useDashboardsMenuItems } from './DashboardsSubMenu'; +import { useExtensionMenuItems } from 'src/components/ExtensionMenuItems'; import { useExploreDataExport } from './useExploreDataExport'; export const SEARCH_THRESHOLD = 10; @@ -462,6 +463,7 @@ export const useExploreAdditionalActionsMenu = ( exportCSVPivoted, exportJson, exportExcel, + getExportPayload, handleExportError, streamingExportState, } = useExploreDataExport({ @@ -471,6 +473,27 @@ export const useExploreAdditionalActionsMenu = ( ownState, }); + const chartName = slice?.slice_name ?? t('Untitled chart'); + const closeDropdown = useCallback(() => setIsDropdownVisible(false), []); + const extensionExportItems = useExtensionMenuItems( + 'explore.chart.download', + { + getPayload: () => getExportPayload('full'), + chartName, + canDownload: canDownloadCSV, + }, + { onItemClick: closeDropdown }, + ); + const extensionPivotedExportItems = useExtensionMenuItems( + 'explore.chart.download.pivoted', + { + getPayload: () => getExportPayload('post_processed'), + chartName, + canDownload: canDownloadCSV, + }, + { onItemClick: closeDropdown }, + ); + // Use the updated report menu items hook const reportMenuItem = useHeaderReportMenuItems({ chart, @@ -810,6 +833,7 @@ export const useExploreAdditionalActionsMenu = ( ); }, }, + ...extensionPivotedExportItems, ); } else { allDataChildren.push({ @@ -899,6 +923,7 @@ export const useExploreAdditionalActionsMenu = ( ); }, }, + ...extensionExportItems, ); const currentViewChildren = [ @@ -1051,7 +1076,7 @@ export const useExploreAdditionalActionsMenu = ( label: t('Export All Data'), children: imageOnlyExport ? allDataChildren.filter( - item => !DATA_AND_PDF_EXPORT_KEYS.includes(item.key), + item => !DATA_AND_PDF_EXPORT_KEYS.includes(String(item?.key)), ) : allDataChildren, }, @@ -1063,7 +1088,8 @@ export const useExploreAdditionalActionsMenu = ( label: t('Export Current View'), children: imageOnlyExport ? currentViewChildren.filter( - item => !DATA_AND_PDF_EXPORT_KEYS.includes(item.key), + item => + !DATA_AND_PDF_EXPORT_KEYS.includes(String(item?.key)), ) : currentViewChildren, }, @@ -1206,6 +1232,8 @@ export const useExploreAdditionalActionsMenu = ( exportCSVPivoted, exportExcel, exportJson, + extensionExportItems, + extensionPivotedExportItems, handleExportError, latestQueryFormData, onOpenInEditor, diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx index 8d00e5ffd90b..5a68272c0178 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx @@ -18,7 +18,7 @@ */ import { ComponentType } from 'react'; import { FeatureFlag } from '@superset-ui/core'; -import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import downloadAsImage from 'src/utils/downloadAsImage'; import downloadAsPdf from 'src/utils/downloadAsPdf'; @@ -30,6 +30,8 @@ import { import * as exploreUtils from 'src/explore/exploreUtils'; import { Slice } from 'src/types/Chart'; import { chart } from 'src/components/Chart/chartReducer'; +import { commands, menus } from 'src/core'; +import type { common } from '@apache-superset/core'; jest.mock('src/explore/exploreUtils', () => ({ __esModule: true, @@ -83,6 +85,7 @@ jest.mock('src/utils/getBootstrapData', () => ({ __esModule: true, default: jest.fn(() => ({ common: { + conf: {}, user_subjects: [1], }, })), @@ -594,3 +597,182 @@ test('A Big Number with Trendline chart keeps its data exports', async () => { await userEvent.hover(await screen.findByText('Export All Data')); expect(await screen.findByText('Export to .CSV')).toBeInTheDocument(); }); + +const extensionDisposables: common.Disposable[] = []; + +const registerItem = ( + location: Parameters[1], + id: string, + title: string, + run: (...args: unknown[]) => unknown = jest.fn(), +) => { + extensionDisposables.push( + commands.registerCommand({ id, title }, run), + menus.registerMenuItem( + { view: location, command: id }, + location, + 'primary', + ), + ); +}; + +afterEach(() => { + extensionDisposables.splice(0).forEach(d => d.dispose()); +}); + +const VisibilityTestComponent = (props: TestComponentProps) => { + const [menu, isDropdownVisible, setIsDropdownVisible] = + useExploreAdditionalActionsMenu( + props.latestQueryFormData as HookParams[0], + props.canDownloadCSV, + props.slice as HookParams[2], + props.onOpenInEditor, + props.onOpenPropertiesModal, + props.ownState as HookParams[5], + props.dashboards as HookParams[6], + props.showReportModal, + props.setCurrentReportDeleting, + ); + return ( +
+ + {String(isDropdownVisible)} + {menu} +
+ ); +}; + +const openExportAllData = async () => { + await userEvent.hover(await screen.findByText('Data Export Options')); + await userEvent.hover(await screen.findByText('Export All Data')); +}; + +const exportAllDataLabels = async () => { + const submenu = (await screen.findByText('Export to .JSON')).closest( + '[role="menu"]', + ) as HTMLElement; + return within(submenu) + .getAllByRole('menuitem') + .map(item => item.textContent); +}; + +test('Explore: contributed item appears under Export All Data after Excel and receives a full payload', async () => { + const run = jest.fn(); + registerItem( + 'explore.chart.download', + 'ext.gs', + 'Export to Google Sheets', + run, + ); + render( + , + { useRedux: true }, + ); + + await openExportAllData(); + const labels = await exportAllDataLabels(); + expect(labels.indexOf('Export to Google Sheets')).toBe( + labels.indexOf('Export to Excel') + 1, + ); + + await userEvent.click(screen.getByText('Export to Google Sheets')); + const ctx = run.mock.calls[0][0] as { + chartName: string; + getPayload: () => Promise>; + }; + expect(ctx.chartName).toBe('Test Chart'); + expect(await ctx.getPayload()).toEqual( + expect.objectContaining({ result_format: 'json', result_type: 'full' }), + ); +}); + +test('Explore: pivoted item follows Export to Pivoted Excel, only for pivot tables, and sends post_processed', async () => { + const run = jest.fn(); + registerItem( + 'explore.chart.download.pivoted', + 'ext.gs.pivoted', + 'Export pivoted to Google Sheets', + run, + ); + const { unmount } = render(, { + useRedux: true, + }); + + await openExportAllData(); + const labels = await exportAllDataLabels(); + expect(labels.indexOf('Export pivoted to Google Sheets')).toBe( + labels.indexOf('Export to Pivoted Excel') + 1, + ); + + await userEvent.click(screen.getByText('Export pivoted to Google Sheets')); + const ctx = run.mock.calls[0][0] as { + getPayload: () => Promise>; + }; + expect(await ctx.getPayload()).toEqual( + expect.objectContaining({ result_type: 'post_processed' }), + ); + unmount(); + + render( + , + { useRedux: true }, + ); + await openExportAllData(); + await screen.findByText('Export to Excel'); + expect( + screen.queryByText('Export pivoted to Google Sheets'), + ).not.toBeInTheDocument(); +}); + +test('Explore: clicking a contributed item closes the dropdown', async () => { + registerItem('explore.chart.download', 'ext.gs', 'Export to Google Sheets'); + render(, { useRedux: true }); + + await userEvent.click(screen.getByText('open dropdown')); + expect(screen.getByText('true')).toBeInTheDocument(); + + await openExportAllData(); + await userEvent.click(await screen.findByText('Export to Google Sheets')); + + expect(screen.getByText('false')).toBeInTheDocument(); +}); + +test('Explore: contributed item disabled without can_csv', async () => { + registerItem('explore.chart.download', 'ext.gs', 'Export to Google Sheets'); + render(, { + useRedux: true, + }); + + await openExportAllData(); + + expect( + (await screen.findByText('Export to Google Sheets')).closest( + '[role="menuitem"]', + ), + ).toHaveAttribute('aria-disabled', 'true'); +}); + +test('Explore: menu unchanged with nothing registered', async () => { + render(, { useRedux: true }); + + await openExportAllData(); + + expect(await exportAllDataLabels()).toEqual([ + 'Export to original .CSV', + 'Export to pivoted .CSV', + 'Export to Pivoted Excel', + 'Export to .JSON', + 'Export screenshot (jpeg)', + 'Export screenshot (png)', + 'Export as PDF', + 'Export to Excel', + ]); +}); diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts index 0aa90cffbd83..5b4859fc44e7 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts @@ -31,7 +31,11 @@ import { useStreamingExport, } from 'src/components/StreamingExportModal'; import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants'; -import { exportChart, getChartKey } from 'src/explore/exploreUtils'; +import { + buildV1ChartDataPayload, + exportChart, + getChartKey, +} from 'src/explore/exploreUtils'; import { ChartState } from 'src/explore/types'; import { Slice } from 'src/types/Chart'; @@ -256,6 +260,18 @@ export const useExploreDataExport = ({ return null; }, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]); + // Builds at call time so extensions get the chart's latest form data. + const getExportPayload = useCallback( + (resultType: 'full' | 'post_processed'): Promise => + buildV1ChartDataPayload({ + formData: latestQueryFormData as QueryFormData, + resultFormat: 'json', + resultType, + ownState, + }) as Promise, + [latestQueryFormData, ownState], + ); + const streamingExportState: StreamingExportState = { isVisible: isStreamingModalVisible, progress, @@ -269,6 +285,7 @@ export const useExploreDataExport = ({ exportCSVPivoted, exportJson, exportExcel, + getExportPayload, handleExportError, streamingExportState, }; From 75acd3cdefba3303e7e61165e4682786fea029b0 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 15:01:14 -0500 Subject: [PATCH 05/21] fix(explore): hide extension download items for image-only charts --- .../useExploreAdditionalActionsMenu/index.tsx | 3 ++- .../useExploreAdditionalActionsMenu.test.tsx | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx index 11d8f0f3b039..8f64746d9ebf 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx @@ -923,7 +923,8 @@ export const useExploreAdditionalActionsMenu = ( ); }, }, - ...extensionExportItems, + // Image-only charts offer no data exports, so extensions get no items. + ...(imageOnlyExport ? [] : extensionExportItems), ); const currentViewChildren = [ diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx index 5a68272c0178..0415cceea232 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx @@ -776,3 +776,26 @@ test('Explore: menu unchanged with nothing registered', async () => { 'Export to Excel', ]); }); + +test('Explore: contributed items are hidden for image-only charts', async () => { + registerItem('explore.chart.download', 'ext.gs', 'Export to Google Sheets'); + render( + , + { + useRedux: true, + initialState: { explore: { can_export_image: true } }, + }, + ); + + await openExportAllData(); + expect( + await screen.findByText('Export screenshot (jpeg)'), + ).toBeInTheDocument(); + expect(screen.queryByText('Export to Google Sheets')).not.toBeInTheDocument(); +}); From 9c34b5254815cb49f50eb44c70df1a73b4e52316 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 15:07:42 -0500 Subject: [PATCH 06/21] feat(dashboard): expose dashboard chart download menu to extensions --- .../components/SliceHeader/index.tsx | 2 + .../SliceHeaderControls.subdirectory.test.tsx | 2 +- .../SliceHeaderControls.test.tsx | 163 ++++++++++++++++++ .../components/SliceHeaderControls/index.tsx | 28 +++ .../components/SliceHeaderControls/types.ts | 3 + .../gridComponents/Chart/Chart.test.tsx | 54 ++++++ .../components/gridComponents/Chart/Chart.tsx | 72 +++++--- 7 files changed, 298 insertions(+), 26 deletions(-) diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 8fc85064424b..d91c9770d7f2 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -160,6 +160,7 @@ const SliceHeader = forwardRef( logEvent, exportCSV = () => ({}), exportXLSX = () => ({}), + getExportPayload, editMode = false, annotationQuery = {}, annotationError = {}, @@ -409,6 +410,7 @@ const SliceHeader = forwardRef( exportPivotCSV={exportPivotCSV} exportFullCSV={exportFullCSV} exportXLSX={exportXLSX} + getExportPayload={getExportPayload} exportFullXLSX={exportFullXLSX} supersetCanExplore={supersetCanExplore} supersetCanShare={supersetCanShare} diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx index 5cc667c97e6d..2ecec147a7ac 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx @@ -57,7 +57,7 @@ const mockApplicationRoot = jest.fn(() => ''); jest.mock('src/utils/getBootstrapData', () => ({ __esModule: true, default: () => ({ - common: { application_root: '', static_assets_prefix: '' }, + common: { application_root: '', static_assets_prefix: '', conf: {} }, }), applicationRoot: () => mockApplicationRoot(), staticAssetsPrefix: () => '', diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx index 25841768fc2c..47329f91625e 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx @@ -22,6 +22,7 @@ import { screen, userEvent, waitFor, + within, } from 'spec/helpers/testing-library'; import { FeatureFlag, VizType, getExtensionsRegistry } from '@superset-ui/core'; import mockState from 'spec/fixtures/mockState'; @@ -30,7 +31,13 @@ import downloadAsImage from 'src/utils/downloadAsImage'; import downloadAsPdf from 'src/utils/downloadAsPdf'; import SliceHeaderControls, { SliceHeaderControlsProps } from '.'; import { chart } from 'src/components/Chart/chartReducer'; +import { commands, menus } from 'src/core'; +import type { common } from '@apache-superset/core'; +jest.mock('src/utils/getBootstrapData', () => ({ + __esModule: true, + default: jest.fn(() => ({ common: { conf: {} } })), +})); jest.mock('src/utils/cachedSupersetGet'); jest.mock('src/explore/components/DataTablesPane', () => ({ ResultsPaneOnDashboard: ({ @@ -1202,3 +1209,159 @@ test('A Big Number chart offers image exports only, without CSV, Excel or PDF', expect(screen.queryByText('Export to Excel')).not.toBeInTheDocument(); expect(screen.queryByText('Export as PDF')).not.toBeInTheDocument(); }); + +const extensionDisposables: common.Disposable[] = []; + +const registerItem = ( + location: Parameters[1], + id: string, + title: string, + run: (...args: unknown[]) => unknown = jest.fn(), +) => { + extensionDisposables.push( + commands.registerCommand({ id, title }, run), + menus.registerMenuItem( + { view: location, command: id }, + location, + 'primary', + ), + ); +}; + +afterEach(() => { + extensionDisposables.splice(0).forEach(d => d.dispose()); +}); + +const openDownloadSubmenu = async (labelInSubmenu: string) => { + await openMenu(); + await userEvent.hover(screen.getByText('Download')); + const item = await screen.findByText(labelInSubmenu); + return item.closest('[role="menu"]') as HTMLElement; +}; + +const submenuLabels = (submenu: HTMLElement) => + within(submenu) + .getAllByRole('menuitem') + .map(item => item.textContent); + +test('dashboard chart: contributed item follows Export to Excel and gets the dashboard-scoped payload', async () => { + const run = jest.fn(); + registerItem( + 'dashboard.chart.download', + 'ext.gs', + 'Export to Google Sheets', + run, + ); + const getExportPayload = jest.fn().mockResolvedValue({ + result_format: 'json', + form_data: { dashboardId: 9 }, + }); + renderWrapper({ ...createProps(), getExportPayload }); + const submenu = await openDownloadSubmenu('Export to Excel'); + const labels = submenuLabels(submenu); + expect(labels.indexOf('Export to Google Sheets')).toBe( + labels.indexOf('Export to Excel') + 1, + ); + + await userEvent.click(screen.getByText('Export to Google Sheets')); + const ctx = run.mock.calls[0][0] as { + chartName: string; + canDownload: boolean; + getPayload: () => Promise>; + }; + expect(ctx.chartName).toBe('Vaccine Candidates per Country & Stage'); + expect(ctx.canDownload).toBe(true); + expect(await ctx.getPayload()).toEqual( + expect.objectContaining({ form_data: { dashboardId: 9 } }), + ); + expect(getExportPayload).toHaveBeenCalledWith('full'); +}); + +test('dashboard chart: pivoted item follows Export to Pivoted Excel for pivot tables and requests post_processed', async () => { + const run = jest.fn(); + registerItem( + 'dashboard.chart.download.pivoted', + 'ext.gs.pivoted', + 'Export pivoted to Google Sheets', + run, + ); + const getExportPayload = jest.fn().mockResolvedValue({}); + const { unmount } = renderWrapper({ + ...createProps(VizType.PivotTable), + getExportPayload, + }); + const submenu = await openDownloadSubmenu('Export to Pivoted Excel'); + const labels = submenuLabels(submenu); + expect(labels.indexOf('Export pivoted to Google Sheets')).toBe( + labels.indexOf('Export to Pivoted Excel') + 1, + ); + + await userEvent.click(screen.getByText('Export pivoted to Google Sheets')); + const ctx = run.mock.calls[0][0] as { + getPayload: () => Promise>; + }; + await ctx.getPayload(); + expect(getExportPayload).toHaveBeenCalledWith('post_processed'); + unmount(); + + renderWrapper({ ...createProps(VizType.Table), getExportPayload }); + await openDownloadSubmenu('Export to Excel'); + expect( + screen.queryByText('Export pivoted to Google Sheets'), + ).not.toBeInTheDocument(); +}); + +test('dashboard chart: no contributed items for image-only charts', async () => { + registerItem('dashboard.chart.download', 'ext.gs', 'Export to Google Sheets'); + renderWrapper({ + ...createProps(VizType.BigNumberTotal), + getExportPayload: jest.fn(), + }); + const submenu = await openDownloadSubmenu('Export screenshot (jpeg)'); + expect(within(submenu).queryByText('Export to .CSV')).not.toBeInTheDocument(); + expect( + within(submenu).queryByText('Export to Excel'), + ).not.toBeInTheDocument(); + expect(screen.queryByText('Export to Google Sheets')).not.toBeInTheDocument(); +}); + +test('dashboard chart: no Download submenu and no items without supersetCanDownload', async () => { + registerItem('dashboard.chart.download', 'ext.gs', 'Export to Google Sheets'); + renderWrapper({ + ...createProps(), + supersetCanDownload: false, + getExportPayload: jest.fn(), + }); + await openMenu(); + expect(screen.queryByText('Download')).not.toBeInTheDocument(); + expect(screen.queryByText('Export to Google Sheets')).not.toBeInTheDocument(); +}); + +test('dashboard chart: clicking a contributed item closes the menu', async () => { + registerItem('dashboard.chart.download', 'ext.gs', 'Export to Google Sheets'); + renderWrapper({ ...createProps(), getExportPayload: jest.fn() }); + await openDownloadSubmenu('Export to Excel'); + await userEvent.click(screen.getByText('Export to Google Sheets')); + await waitFor(() => + expect(screen.queryByText('Export to Google Sheets')).not.toBeVisible(), + ); +}); + +test('dashboard chart: getPayload rejects when the chart provides no payload builder', async () => { + const run = jest.fn(); + registerItem( + 'dashboard.chart.download', + 'ext.gs', + 'Export to Google Sheets', + run, + ); + renderWrapper(createProps()); + await openDownloadSubmenu('Export to Excel'); + await userEvent.click(screen.getByText('Export to Google Sheets')); + const ctx = run.mock.calls[0][0] as { + getPayload: () => Promise>; + }; + await expect(ctx.getPayload()).rejects.toThrow( + 'Export is not available for this chart', + ); +}); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index efc86d77aed0..57e67487a4c6 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -50,6 +50,7 @@ import { Button, ModalTrigger, } from '@superset-ui/core/components'; +import { useExtensionMenuItems } from 'src/components/ExtensionMenuItems'; import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems'; import downloadAsImage from 'src/utils/downloadAsImage'; import downloadAsPdf from 'src/utils/downloadAsPdf'; @@ -138,6 +139,9 @@ export interface SliceHeaderControlsProps { exportFullCSV?: (sliceId: number) => void; exportXLSX?: (sliceId: number) => void; exportFullXLSX?: (sliceId: number) => void; + getExportPayload?: ( + resultType: 'full' | 'post_processed', + ) => Promise; handleToggleFullSize: () => void; exportPivotExcel?: (tableSelector: string, sliceName: string) => void; @@ -461,6 +465,28 @@ const SliceHeaderControls = ( slice.viz_type === VizType.Table || slice.viz_type === VizType.TableAgGrid; const isPivotTable = slice.viz_type === VizType.PivotTable; const isImageOnlyExport = isImageOnlyExportViz(slice.viz_type); + const canDownload = !!props.supersetCanDownload; + const { getExportPayload } = props; + const getPayloadFor = (resultType: 'full' | 'post_processed') => () => + getExportPayload + ? getExportPayload(resultType) + : Promise.reject(new Error(t('Export is not available for this chart'))); + const extensionDownloadItems = useExtensionMenuItems( + 'dashboard.chart.download', + { + getPayload: getPayloadFor('full'), + chartName: slice.slice_name, + canDownload, + }, + ); + const extensionPivotedDownloadItems = useExtensionMenuItems( + 'dashboard.chart.download.pivoted', + { + getPayload: getPayloadFor('post_processed'), + chartName: slice.slice_name, + canDownload, + }, + ); const cachedWhen = (cachedDttm || []).map(itemCachedDttm => (extendedDayjs.utc(itemCachedDttm) as any).fromNow(), ); @@ -701,6 +727,7 @@ const SliceHeaderControls = ( label: t('Export to Pivoted Excel'), icon: , }, + ...extensionPivotedDownloadItems, ] : []), ...(isImageOnlyExport @@ -711,6 +738,7 @@ const SliceHeaderControls = ( label: t('Export to Excel'), icon: , }, + ...extensionDownloadItems, ]), ...(isFeatureEnabled(FeatureFlag.AllowFullCsvExport) && props.supersetCanDownload && diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts index 924902fb51f5..d9d347c3bfbd 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts @@ -49,6 +49,9 @@ export interface SliceHeaderControlsProps { exportPivotCSV?: (sliceId: number) => void; exportFullCSV?: (sliceId: number) => void; exportXLSX?: (sliceId: number) => void; + getExportPayload?: ( + resultType: 'full' | 'post_processed', + ) => Promise; exportFullXLSX?: (sliceId: number) => void; handleToggleFullSize: () => void; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx index e375f1c0d0a3..cc0d04a16707 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx @@ -25,6 +25,8 @@ import { VizType, } from '@superset-ui/core'; import * as redux from 'redux'; +import { commands, menus } from 'src/core'; +import type { common } from '@apache-superset/core'; import * as exploreUtils from 'src/explore/exploreUtils'; import * as chartStateConverter from '../../../util/chartStateConverter'; @@ -1281,3 +1283,55 @@ test('passes the form data of the last request to ChartContainer next to the cur expect.objectContaining({ groupby: ['region'] }), ); }); + +test('dashboard chart download: getExportPayload builds the payload at call time from dashboard form data and ownState', async () => { + const payload = { result_format: 'json', result_type: 'full' }; + const buildPayload = jest + .spyOn(exploreUtils, 'buildV1ChartDataPayload') + .mockResolvedValue(payload as any); + const run = jest.fn(); + const disposables: common.Disposable[] = [ + commands.registerCommand( + { id: 'ext.gs', title: 'Export to Google Sheets' }, + run, + ), + menus.registerMenuItem( + { view: 'dashboard.chart.download', command: 'ext.gs' }, + 'dashboard.chart.download', + 'primary', + ), + ]; + try { + const { getByRole, findByText } = setup( + {}, + { + dashboardInfo: { + ...defaultState.dashboardInfo, + superset_can_download: true, + }, + dataMask: { [queryId]: { ownState: { currentPage: 3 } } }, + }, + ); + fireEvent.click(getByRole('button', { name: 'More Options' })); + fireEvent.mouseOver(getByRole('menuitem', { name: 'Download right' })); + fireEvent.click(await findByText('Export to Google Sheets')); + expect(run).toHaveBeenCalledTimes(1); + expect(buildPayload).not.toHaveBeenCalled(); + + const ctx = run.mock.calls[0][0] as { + getPayload: () => Promise; + }; + await expect(ctx.getPayload()).resolves.toBe(payload); + expect(buildPayload).toHaveBeenCalledWith( + expect.objectContaining({ + formData: expect.objectContaining({ dashboardId: 111 }), + ownState: expect.objectContaining({ currentPage: 3 }), + resultType: 'full', + resultFormat: 'json', + }), + ); + } finally { + disposables.forEach(d => d.dispose()); + buildPayload.mockRestore(); + } +}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index c49437d1aa4f..a3042efc1c7d 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx @@ -36,7 +36,7 @@ import { debounce } from 'lodash-es'; import { bindActionCreators } from 'redux'; import { useDispatch, useSelector } from 'react-redux'; -import { exportChart } from 'src/explore/exploreUtils'; +import { buildV1ChartDataPayload, exportChart } from 'src/explore/exploreUtils'; import ChartContainer from 'src/components/Chart/ChartContainer'; import LastQueriedLabel from 'src/components/LastQueriedLabel'; import { @@ -568,6 +568,48 @@ const Chart = (props: ChartProps) => { [dataMaskOwnState, chartState, formData, sliceVizType], ); + // Form data and ownState for an export; a full export lifts the row limit. + const getExportInputs = useCallback( + (isFullCSV: boolean) => { + const exportFormData = isFullCSV + ? { ...formData, row_limit: maxRows } + : formData; + const baseOwnState = (dataMaskOwnState as Record) || {}; + const state = getChartStateWithFallback( + chartState as { state?: JsonObject } | undefined, + formData, + sliceVizType, + ); + + const exportOwnState = state + ? { + ...baseOwnState, + ...convertChartStateToOwnState(sliceVizType, state, { + forExport: true, + }), + } + : baseOwnState; + + return { formData: exportFormData, ownState: exportOwnState }; + }, + [formData, maxRows, dataMaskOwnState, chartState, sliceVizType], + ); + + const getExportPayload = useCallback( + (resultType: 'full' | 'post_processed') => { + const { formData: exportFormData, ownState: exportOwnState } = + getExportInputs(false); + return buildV1ChartDataPayload({ + formData: + exportFormData as unknown as import('@superset-ui/core').QueryFormData, + ownState: exportOwnState, + resultType, + resultFormat: 'json', + }) as Promise; + }, + [getExportInputs], + ); + const exportTable = useCallback( async (format: string, isFullCSV: boolean, isPivot = false) => { const logAction = @@ -579,9 +621,8 @@ const Chart = (props: ChartProps) => { is_cached: isCached, }); - const exportFormData = isFullCSV - ? { ...formData, row_limit: maxRows } - : formData; + const { formData: exportFormData, ownState: exportOwnState } = + getExportInputs(isFullCSV); const resultType = isPivot ? 'post_processed' : 'full'; let actualRowCount: number | undefined; @@ -629,23 +670,6 @@ const Chart = (props: ChartProps) => { ); filename = `${safeChartName}${timestamp}.csv`; } - const baseOwnState = - (dataMask[props.id]?.ownState as Record) || {}; - const state = getChartStateWithFallback( - chartState as { state?: JsonObject } | undefined, - formData, - sliceVizType, - ); - - const exportOwnState = state - ? { - ...baseOwnState, - ...convertChartStateToOwnState(sliceVizType, state, { - forExport: true, - }), - } - : baseOwnState; - try { await exportChart({ formData: @@ -693,10 +717,7 @@ const Chart = (props: ChartProps) => { sliceVizType, isCached, formData, - maxRows, - dataMaskOwnState, - chartState, - props.id, + getExportInputs, boundActionCreators.logEvent, boundActionCreators.addDangerToast, queriesResponse, @@ -793,6 +814,7 @@ const Chart = (props: ChartProps) => { exportXLSX={exportXLSX} exportFullCSV={exportFullCSV} exportFullXLSX={exportFullXLSX} + getExportPayload={getExportPayload} updateSliceName={(name: string) => props.updateSliceName(props.id, name) } From 731f99458b70a366ef1fee176713845bb6845cf7 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 15:11:25 -0500 Subject: [PATCH 07/21] test(dashboard): pin click-time export payload freshness --- .../gridComponents/Chart/Chart.test.tsx | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx index cc0d04a16707..2a4580123a8c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx @@ -17,7 +17,15 @@ * under the License. */ import { useEffect } from 'react'; -import { act, fireEvent, render } from 'spec/helpers/testing-library'; +import { + act, + createStore, + fireEvent, + render, +} from 'spec/helpers/testing-library'; +import reducerIndex from 'spec/helpers/reducerIndex'; +import { updateDataMask } from 'src/dataMask/actions'; +import type { DataMask } from '@superset-ui/core'; import { DatasourceType, FeatureFlag, @@ -1284,11 +1292,15 @@ test('passes the form data of the last request to ChartContainer next to the cur ); }); -test('dashboard chart download: getExportPayload builds the payload at call time from dashboard form data and ownState', async () => { +test('dashboard chart download: getPayload reflects ownState changed after the chart loaded', async () => { const payload = { result_format: 'json', result_type: 'full' }; const buildPayload = jest .spyOn(exploreUtils, 'buildV1ChartDataPayload') - .mockResolvedValue(payload as any); + .mockResolvedValue( + payload as unknown as Awaited< + ReturnType + >, + ); const run = jest.fn(); const disposables: common.Disposable[] = [ commands.registerCommand( @@ -1302,22 +1314,37 @@ test('dashboard chart download: getExportPayload builds the payload at call time ), ]; try { - const { getByRole, findByText } = setup( - {}, + const store = createStore( { + ...defaultState, dashboardInfo: { ...defaultState.dashboardInfo, superset_can_download: true, }, - dataMask: { [queryId]: { ownState: { currentPage: 3 } } }, + dataMask: { [queryId]: { ownState: { currentPage: 1 } } }, }, + reducerIndex, ); + const { getByRole, findByText } = render(, { + store, + useRouter: true, + }); fireEvent.click(getByRole('button', { name: 'More Options' })); fireEvent.mouseOver(getByRole('menuitem', { name: 'Download right' })); - fireEvent.click(await findByText('Export to Google Sheets')); - expect(run).toHaveBeenCalledTimes(1); + const item = await findByText('Export to Google Sheets'); expect(buildPayload).not.toHaveBeenCalled(); + act(() => { + store.dispatch( + updateDataMask(queryId, { + id: queryId, + ownState: { currentPage: 7 }, + } as DataMask), + ); + }); + fireEvent.click(item); + expect(run).toHaveBeenCalledTimes(1); + const ctx = run.mock.calls[0][0] as { getPayload: () => Promise; }; @@ -1325,7 +1352,7 @@ test('dashboard chart download: getExportPayload builds the payload at call time expect(buildPayload).toHaveBeenCalledWith( expect.objectContaining({ formData: expect.objectContaining({ dashboardId: 111 }), - ownState: expect.objectContaining({ currentPage: 3 }), + ownState: expect.objectContaining({ currentPage: 7 }), resultType: 'full', resultFormat: 'json', }), From daf83693200bd88f1672350787978bd95f5f9b74 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 15:19:53 -0500 Subject: [PATCH 08/21] feat(charts): expose drill download menus to extensions --- .../Chart/DrillBy/DrillByModal.test.tsx | 54 +++++ .../components/Chart/DrillBy/DrillByModal.tsx | 186 ++++++++++-------- .../DrillDetail/DownloadDropdown.test.tsx | 61 ++++++ .../Chart/DrillDetail/DownloadDropdown.tsx | 3 + .../Chart/DrillDetail/DrillDetailModal.tsx | 1 + .../DrillDetail/DrillDetailPane.test.tsx | 149 ++++++++++++++ .../Chart/DrillDetail/DrillDetailPane.tsx | 126 +++++++++--- 7 files changed, 472 insertions(+), 108 deletions(-) diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx index 12ae22ee3ac9..1f074d42d71b 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx @@ -31,6 +31,8 @@ import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; import mockState from 'spec/fixtures/mockState'; import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage'; +import { commands, menus } from 'src/core'; +import type { common } from '@apache-superset/core'; import DrillByModal, { DrillByModalProps } from './DrillByModal'; setupAGGridModules(); @@ -623,4 +625,56 @@ describe('Table view with pagination', () => { }), ); }); + + test('contributed item gets the drilled payload and the original chart name', async () => { + const run = jest.fn(); + const disposables: common.Disposable[] = [ + commands.registerCommand( + { id: 'ext.gs', title: 'Export to Google Sheets' }, + run, + ), + menus.registerMenuItem( + { view: 'chart.drillBy.download', command: 'ext.gs' }, + 'chart.drillBy.download', + 'primary', + ), + ]; + try { + await renderModal({ + column: { column_name: 'state', verbose_name: null }, + drillByConfig: { + filters: [{ col: 'gender', op: '==', val: 'boy' }], + groupbyFieldName: 'groupby', + }, + }); + const tableRadio = await screen.findByRole('radio', { name: /table/i }); + await userEvent.click(tableRadio.closest('label') ?? tableRadio); + await waitFor(() => + expect( + screen.getByTestId('drill-by-results-table'), + ).toBeInTheDocument(), + ); + await userEvent.click( + await screen.findByRole('button', { name: 'Download' }), + ); + await userEvent.click(await screen.findByText('Export to Google Sheets')); + + expect(run).toHaveBeenCalledTimes(1); + const ctx = run.mock.calls[0][0] as { + getPayload: () => Promise<{ + result_type: string; + result_format: string; + queries: { columns: unknown[] }[]; + }>; + chartName: string; + }; + expect(ctx.chartName).toBe(chartName); + const payload = await ctx.getPayload(); + expect(payload.result_type).toBe('full'); + expect(payload.result_format).toBe('json'); + expect(JSON.stringify(payload.queries[0])).toContain('state'); + } finally { + disposables.forEach(d => d.dispose()); + } + }); }); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx index f09e3597e066..9f8c7e8ce12c 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx @@ -54,9 +54,10 @@ import { LOG_ACTIONS_FURTHER_DRILL_BY, } from 'src/logger/LogUtils'; import { findPermission } from 'src/utils/findPermission'; -import { exportChart } from 'src/explore/exploreUtils'; +import { buildV1ChartDataPayload, exportChart } from 'src/explore/exploreUtils'; import { isEmbedded } from 'src/dashboard/util/isEmbedded'; import { useAsyncModeOverride } from 'src/utils/asyncMode'; +import { ChartDownloadProvider } from 'src/components/ExtensionMenuItems'; import { Dataset, DrillByType } from '../types'; import DrillByChart from './DrillByChart'; import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu'; @@ -511,93 +512,116 @@ export default function DrillByModal({ }, [addDangerToast, asyncModeOverride, drilledFormData]); const { metadataBar } = useDatasetMetadataBar({ dataset }); + // drilledFormData drops slice_name, so the original chart's name comes from + // formData. + const originalChartName: string = formData.slice_name ?? t('Untitled chart'); + const downloadContext = useMemo( + () => ({ + getPayload: async () => ({ + ...(await buildV1ChartDataPayload({ + formData: drilledFormData, + resultFormat: 'json', + resultType: 'full', + })), + }), + chartName: originalChartName, + canDownload, + }), + [drilledFormData, originalChartName, canDownload], + ); + return ( - null)} - name={t('Drill by: %s', chartName)} - title={t('Drill by: %s', chartName)} - footer={} - responsive - resizable - resizableConfig={{ - minHeight: theme.sizeUnit * 128, - minWidth: theme.sizeUnit * 128, - defaultSize: { - width: 'auto', - height: '80vh', - }, - }} - draggable - destroyOnHidden - maskClosable={false} + - null)} + name={t('Drill by: %s', chartName)} + title={t('Drill by: %s', chartName)} + footer={} + responsive + resizable + resizableConfig={{ + minHeight: theme.sizeUnit * 128, + minWidth: theme.sizeUnit * 128, + defaultSize: { + width: 'auto', + height: '80vh', + }, + }} + draggable + destroyOnHidden + maskClosable={false} > - {metadataBar} - { - const isLastElement = routes.indexOf(route) === routes.length - 1; - return isLastElement ? ( - - {route.title} - {paths} - - ) : ( - - ); - }} - /> - {displayModeToggle} - {isChartDataLoading && } - {!isChartDataLoading && !chartDataResult && ( - - )} - {drillByDisplayMode === DrillByType.Chart && chartDataResult && ( - + {metadataBar} + { + const isLastElement = routes.indexOf(route) === routes.length - 1; + return isLastElement ? ( + + {route.title} + {paths} + + ) : ( + + ); + }} /> - )} - {drillByDisplayMode === DrillByType.Table && - chartDataResult && - resultsTable} - {contextMenu} - - + {displayModeToggle} + {isChartDataLoading && } + {!isChartDataLoading && !chartDataResult && ( + + )} + {drillByDisplayMode === DrillByType.Chart && chartDataResult && ( + + )} + {drillByDisplayMode === DrillByType.Table && + chartDataResult && + resultsTable} + {contextMenu} + + +
); } diff --git a/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx index f09c364dcbc3..2b1304744504 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx @@ -17,6 +17,9 @@ * under the License. */ import { render, screen, userEvent } from 'spec/helpers/testing-library'; +import { ChartDownloadProvider } from 'src/components/ExtensionMenuItems'; +import { commands, menus } from 'src/core'; +import type { common } from '@apache-superset/core'; import DownloadDropdown from './DownloadDropdown'; const onDownloadCSV = jest.fn(); @@ -55,3 +58,61 @@ test('fires onDownloadXLSX when Excel menu item is selected', async () => { expect(onDownloadXLSX).toHaveBeenCalledTimes(1); expect(onDownloadCSV).not.toHaveBeenCalled(); }); + +const withExtensionItem = async ( + location: 'chart.drillDetail.download' | 'chart.drillBy.download', + body: () => Promise, +) => { + const disposables: common.Disposable[] = [ + commands.registerCommand( + { id: 'ext.gs', title: 'Export to Google Sheets' }, + jest.fn(), + ), + menus.registerMenuItem( + { view: location, command: 'ext.gs' }, + location, + 'primary', + ), + ]; + try { + await body(); + } finally { + disposables.forEach(d => d.dispose()); + } +}; + +test('lists only CSV and Excel when no provider is present', async () => { + await withExtensionItem('chart.drillBy.download', async () => { + setup(); + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(await screen.findByText('Export to CSV')).toBeInTheDocument(); + expect(screen.getByText('Export to Excel')).toBeInTheDocument(); + expect( + screen.queryByText('Export to Google Sheets'), + ).not.toBeInTheDocument(); + }); +}); + +test('appends items contributed to the provider location', async () => { + await withExtensionItem('chart.drillBy.download', async () => { + render( + ({}), + chartName: 'Sales', + canDownload: true, + }} + > + + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + await userEvent.click(await screen.findByText('Export to Google Sheets')); + expect(onDownloadCSV).not.toHaveBeenCalled(); + expect(onDownloadXLSX).not.toHaveBeenCalled(); + }); +}); diff --git a/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.tsx b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.tsx index 922983bb137f..f3a94ff238ba 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.tsx @@ -21,6 +21,7 @@ import { t } from '@apache-superset/core/translation'; import { css, useTheme } from '@apache-superset/core/theme'; import { Dropdown, Tooltip } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; +import { useChartDownloadItems } from 'src/components/ExtensionMenuItems'; interface DownloadDropdownProps { onDownloadCSV: () => void; @@ -32,6 +33,7 @@ const DownloadDropdown = ({ onDownloadXLSX, }: DownloadDropdownProps) => { const theme = useTheme(); + const extensionItems = useChartDownloadItems(); return ( , }, + ...extensionItems, ], }} > diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx index db60322b6a1a..498885e7a938 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx @@ -166,6 +166,7 @@ export default function DrillDetailModal({ formData={formData} initialFilters={initialFilters} dataset={dataset} + chartName={chartName} /> ); diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx index 1c007d158612..c049e09bbfe9 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx @@ -33,6 +33,8 @@ import { import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore'; import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; import { supersetGetCache } from 'src/utils/cachedSupersetGet'; +import { commands, menus } from 'src/core'; +import type { common } from '@apache-superset/core'; import DrillDetailPane from './DrillDetailPane'; const chart = chartQueries[sliceId]; @@ -350,6 +352,153 @@ describe('download actions', () => { }); }); +const DOWNLOAD_STATE = { + user: { roles: { Admin: [['can_csv', 'Superset']] } }, + common: { conf: { SAMPLES_ROW_LIMIT: 10, ROW_LIMIT: 50000 } }, + dashboardInfo: { id: 123 }, +}; + +const registerDrillDetailItem = (run: jest.Mock): common.Disposable[] => [ + commands.registerCommand( + { id: 'ext.gs', title: 'Export to Google Sheets' }, + run, + ), + menus.registerMenuItem( + { view: 'chart.drillDetail.download', command: 'ext.gs' }, + 'chart.drillDetail.download', + 'primary', + ), +]; + +type DownloadContext = { + getPayload: () => Promise<{ + result_type: string; + result_format: string; + queries: { filters: unknown[]; row_limit: number }[]; + }>; + chartName: string; + canDownload: boolean; +}; + +const clickExtensionItem = async (run: jest.Mock): Promise => { + await userEvent.click( + await screen.findByRole('button', { name: 'Download' }), + ); + await userEvent.click(await screen.findByText('Export to Google Sheets')); + expect(run).toHaveBeenCalledTimes(1); + return run.mock.calls[0][0] as DownloadContext; +}; + +test('contributed item gets the drill_detail payload with json format and the chart name', async () => { + fetchWithData(); + const run = jest.fn(); + const disposables = registerDrillDetailItem(run); + const drillFilter: BinaryQueryObjectFilterClause = { + col: 'year', + op: '==', + val: 1996, + formattedVal: '1996', + }; + try { + render( + , + { useRedux: true, initialState: DOWNLOAD_STATE }, + ); + const ctx = await clickExtensionItem(run); + expect(ctx.chartName).toBe('Sales'); + expect(ctx.canDownload).toBe(true); + const payload = await ctx.getPayload(); + expect(payload).toEqual( + expect.objectContaining({ + result_type: 'drill_detail', + result_format: 'json', + }), + ); + expect(payload.queries[0].filters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ col: 'year', val: 1996 }), + ]), + ); + expect(payload.queries[0].row_limit).toBe(50000); + } finally { + disposables.forEach(d => d.dispose()); + } +}); + +test('CSV and Excel download bodies are unchanged by the payload extraction', async () => { + fetchWithData(); + const postFormSpy = jest + .spyOn(SupersetClient, 'postForm') + .mockResolvedValue(); + render( + , + { useRedux: true, initialState: DOWNLOAD_STATE }, + ); + const expectedBody = (resultFormat: string) => + JSON.stringify({ + datasource: { id: 7, type: 'table' }, + queries: [ + { + viz_type: 'pie', + granularity: 'ds', + filters: [], + extras: { where: '' }, + columns: [], + metrics: [], + orderby: [], + row_limit: 50000, + row_offset: 0, + }, + ], + result_type: 'drill_detail', + result_format: resultFormat, + force: false, + form_data: { viz_type: 'pie', dashboardId: 123 }, + }); + + await userEvent.click( + await screen.findByRole('button', { name: 'Download' }), + ); + await userEvent.click(await screen.findByText('Export to CSV')); + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + await userEvent.click(await screen.findByText('Export to Excel')); + + expect(postFormSpy).toHaveBeenCalledTimes(2); + expect(postFormSpy.mock.calls[0][1]).toEqual({ + form_data: expectedBody('csv'), + }); + expect(postFormSpy.mock.calls[1][1]).toEqual({ + form_data: expectedBody('xlsx'), + }); + postFormSpy.mockRestore(); +}); + +test('contributed item chart name falls back when none is passed', async () => { + fetchWithData(); + const run = jest.fn(); + const disposables = registerDrillDetailItem(run); + try { + render( + , + { useRedux: true, initialState: DOWNLOAD_STATE }, + ); + const ctx = await clickExtensionItem(run); + expect(ctx.chartName).toBe('Drill to detail'); + } finally { + disposables.forEach(d => d.dispose()); + } +}); + test('should render pagination when results exceed page size', async () => { // The "should render the error" test above leaves a SupersetClient.post // rejection spy active (matching the existing pattern; "should use diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx index 549860341c7d..1f74d300c5a7 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx @@ -60,6 +60,7 @@ import TableControls from './DrillDetailTableControls'; import { getDrillPayload } from './utils'; import { ResultsPage } from './types'; import { datasetLabelLower } from 'src/features/semanticLayers/label'; +import { ChartDownloadProvider } from 'src/components/ExtensionMenuItems'; const DEFAULT_PAGE_SIZE = 50; @@ -84,14 +85,65 @@ enum TimeFormatting { Formatted, } +// Query context for the full drill-detail rows, or null when the drill +// filters cannot be built. +function buildDrillDetailPayload({ + formData, + filters, + datasourceId, + datasourceType, + rowLimit, + dashboardId, + resultFormat, +}: { + formData: QueryFormData; + filters: BinaryQueryObjectFilterClause[]; + datasourceId: string; + datasourceType: string; + rowLimit: number; + dashboardId?: number; + resultFormat: 'csv' | 'xlsx' | 'json'; +}): JsonObject | null { + const drillPayload = getDrillPayload(formData, filters); + if (!drillPayload) { + return null; + } + const payload: JsonObject = { + datasource: { + id: parseInt(datasourceId, 10), + type: datasourceType, + }, + queries: [ + { + ...drillPayload, + columns: [], + metrics: [], + orderby: [], + row_limit: rowLimit, + row_offset: 0, + }, + ], + result_type: 'drill_detail', + result_format: resultFormat, + force: false, + form_data: { viz_type: formData.viz_type }, + }; + if (dashboardId) { + payload.form_data = { viz_type: formData.viz_type, dashboardId }; + } + return payload; +} + export default function DrillDetailPane({ formData, initialFilters, dataset, + chartName, }: { formData: QueryFormData; initialFilters: BinaryQueryObjectFilterClause[]; dataset?: Dataset; + chartName?: string; }) { const theme = useTheme(); const [pageIndex, setPageIndex] = useState(0); @@ -231,34 +283,19 @@ export default function DrillDetailPane({ const handleDownload = useCallback( (exportType: 'csv' | 'xlsx') => { - const drillPayload = getDrillPayload(formData, filters); - if (!drillPayload) { + const payload = buildDrillDetailPayload({ + formData, + filters, + datasourceId, + datasourceType, + rowLimit: ROW_LIMIT, + dashboardId, + resultFormat: exportType, + }); + if (!payload) { addDangerToast(t('Unable to generate download payload')); return; } - const payload: JsonObject = { - datasource: { - id: parseInt(datasourceId, 10), - type: datasourceType, - }, - queries: [ - { - ...drillPayload, - columns: [], - metrics: [], - orderby: [], - row_limit: ROW_LIMIT, - row_offset: 0, - }, - ], - result_type: 'drill_detail', - result_format: exportType, - force: false, - form_data: { viz_type: formData.viz_type }, - }; - if (dashboardId) { - payload.form_data = { viz_type: formData.viz_type, dashboardId }; - } SupersetClient.postForm('/api/v1/chart/data', { form_data: safeStringify(payload), }).catch(error => { @@ -278,6 +315,38 @@ export default function DrillDetailPane({ ], ); + const downloadContext = useMemo( + () => ({ + getPayload: async () => { + const payload = buildDrillDetailPayload({ + formData, + filters, + datasourceId, + datasourceType, + rowLimit: ROW_LIMIT, + dashboardId, + resultFormat: 'json', + }); + if (!payload) { + throw new Error(t('Unable to generate download payload')); + } + return payload; + }, + chartName: chartName ?? t('Drill to detail'), + canDownload, + }), + [ + formData, + filters, + datasourceId, + datasourceType, + ROW_LIMIT, + dashboardId, + chartName, + canDownload, + ], + ); + const handleDownloadCSV = useCallback( () => handleDownload('csv'), [handleDownload], @@ -430,7 +499,10 @@ export default function DrillDetailPane({ } return ( - <> + {!bootstrapping && metadataBarComponent} {!bootstrapping && ( )} {tableContent} - + ); } From 742592749744ac30e612ae03e3d10164b5a89afb Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 15:24:30 -0500 Subject: [PATCH 09/21] feat(dashboard): expose dashboard download menu to extensions --- .../DownloadMenuItems.test.tsx | 122 ++++++++++++++++++ .../menu/DownloadMenuItems/index.tsx | 29 +++++ 2 files changed, 151 insertions(+) diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx index f73c1e1880db..e9429b48c797 100644 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx +++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx @@ -24,6 +24,7 @@ import { screen, userEvent, waitFor, + within, } from 'spec/helpers/testing-library'; import { Menu, MenuItem } from '@superset-ui/core/components/Menu'; import { @@ -34,8 +35,15 @@ import { } from '@superset-ui/core'; import downloadAsImage from 'src/utils/downloadAsImage'; import downloadAsPdf from 'src/utils/downloadAsPdf'; +import { commands, menus } from 'src/core'; +import type { common } from '@apache-superset/core'; import { useDownloadMenuItems } from '.'; +jest.mock('src/utils/getBootstrapData', () => ({ + __esModule: true, + default: jest.fn(() => ({ common: { conf: {} } })), +})); + jest.mock('src/utils/downloadAsImage', () => ({ __esModule: true, default: jest.fn(() => jest.fn()), @@ -1163,3 +1171,117 @@ test('Download as Image hands the warning and info toast callbacks to downloadAs mockAddInfoToast, ); }); + +const extensionDisposables: common.Disposable[] = []; + +const registerItem = ( + location: Parameters[1], + id: string, + title: string, + run: (...args: unknown[]) => unknown = jest.fn(), +) => { + extensionDisposables.push( + commands.registerCommand({ id, title }, run), + menus.registerMenuItem( + { view: location, command: id }, + location, + 'primary', + ), + ); +}; + +afterEach(() => { + extensionDisposables.splice(0).forEach(d => d.dispose()); +}); + +const nativeFilter = { filters: [{ col: 'region', op: 'IN', val: ['EU'] }] }; +const crossFilter = { filters: [{ col: 'region', op: 'IN', val: ['US'] }] }; +const maskState = { + ...loggedInState, + dataMask: { + 'NATIVE_FILTER-1': { + id: 'NATIVE_FILTER-1', + extraFormData: nativeFilter, + filterState: { value: ['EU'], label: 'EU' }, + ownState: { search: 'x' }, + }, + '42': { + id: '42', + extraFormData: crossFilter, + filterState: { value: ['US'] }, + }, + }, +}; + +test('dashboard: contributed item follows Export Data to Excel with filter values in the context', async () => { + const run = jest.fn(); + registerItem( + 'dashboard.download', + 'ext.gsd', + 'Export Data to Google Sheets', + run, + ); + render(, { + useRedux: true, + initialState: maskState, + }); + + const submenu = screen + .getByText('Export Data to Excel') + .closest('[role="menu"]') as HTMLElement; + const labels = within(submenu) + .getAllByRole('menuitem', { hidden: true }) + .map(item => item.textContent); + expect(labels.indexOf('Export Data to Google Sheets')).toBeGreaterThan( + labels.indexOf('Export Data to Excel'), + ); + + await clickMenuItem('Export Data to Google Sheets'); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + dashboardId: 9, + dashboardTitle: 'Sales', + canDownload: true, + activeDataMask: { + 'NATIVE_FILTER-1': { + extraFormData: nativeFilter, + filterState: { value: ['EU'], label: 'EU' }, + }, + }, + }), + ); +}); + +test('dashboard: no contributed item without dashboard export permission', () => { + registerItem('dashboard.download', 'ext.gsd', 'Export Data to Google Sheets'); + render(, { + useRedux: true, + initialState: loggedInState, + }); + + expect(screen.queryByText('Export Data to Excel')).not.toBeInTheDocument(); + expect( + screen.queryByText('Export Data to Google Sheets'), + ).not.toBeInTheDocument(); +}); + +test('dashboard: Excel export request body unchanged', async () => { + mockQueuedResponse(); + render(, { useRedux: true, initialState: maskState }); + + await clickMenuItem('Export Data to Excel'); + + await waitFor(() => { + expect(mockSupersetClient.post).toHaveBeenCalledWith({ + endpoint: '/api/v1/dashboard/123/export_xlsx/', + fetchRetryOptions: { retries: 0 }, + jsonPayload: { + active_data_mask: { + 'NATIVE_FILTER-1': { extraFormData: nativeFilter }, + }, + mode: 'data', + }, + parseMethod: 'raw', + }); + }); +}); diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx index 82780d852a13..28f77fc45e7b 100644 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx @@ -43,6 +43,8 @@ import { import { removeToast } from 'src/components/MessageToasts/actions'; import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { useExtensionMenuItems } from 'src/components/ExtensionMenuItems'; +import type { menus } from '@apache-superset/core'; import { MenuItemTooltip } from 'src/components/Chart/DisabledMenuItemTooltip'; import { DownloadScreenshotFormat } from './types'; @@ -141,6 +143,32 @@ export const useDownloadMenuItems = ( return acc; }, {}); + // Adds each native filter's display state, which extensions use to describe + // the export; the exported data itself depends on `extraFormData` alone. + const buildExtensionDataMask = + (): menus.DashboardDownloadContext['activeDataMask'] => + Object.entries(dataMask || {}).reduce< + menus.DashboardDownloadContext['activeDataMask'] + >((acc, [id, mask]) => { + if (id.startsWith(NATIVE_FILTER_PREFIX)) { + acc[id] = { + extraFormData: mask?.extraFormData ?? {}, + filterState: { + value: mask?.filterState?.value, + label: mask?.filterState?.label, + }, + }; + } + return acc; + }, {}); + + const extensionItems = useExtensionMenuItems('dashboard.download', { + dashboardId, + dashboardTitle, + activeDataMask: buildExtensionDataMask(), + canDownload: true, + }); + const isWebDriverScreenshotEnabled = isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) && isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot); @@ -477,6 +505,7 @@ export const useDownloadMenuItems = ( }, ] : []), + ...extensionItems, ] : []), // Bundle exports are refused for sessions without a user id. From 6c392c8a5e1c31e870fb0c44c0433699e1fd0486 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 15:33:44 -0500 Subject: [PATCH 10/21] docs(extensions): document download menu locations and toasts --- .../extensions/contribution-types.md | 2 + .../extension-points/download-menus.md | 110 ++++++++++++++++++ docs/developer_docs/extensions/toasts.md | 81 +++++++++++++ docs/developer_docs/sidebars.js | 2 + 4 files changed, 195 insertions(+) create mode 100644 docs/developer_docs/extensions/extension-points/download-menus.md create mode 100644 docs/developer_docs/extensions/toasts.md diff --git a/docs/developer_docs/extensions/contribution-types.md b/docs/developer_docs/extensions/contribution-types.md index 563aa8ae540a..c4490c73fb9f 100644 --- a/docs/developer_docs/extensions/contribution-types.md +++ b/docs/developer_docs/extensions/contribution-types.md @@ -91,6 +91,8 @@ menus.registerMenuItem( ); ``` +Charts and dashboards also have download-menu locations, such as `explore.chart.download` and `dashboard.download`. See [Download Menus](./extension-points/download-menus). + ### Editors Extensions can replace Superset's default text editors with custom implementations. This enables enhanced editing experiences using alternative editor frameworks like Monaco, CodeMirror, or custom solutions. When an extension registers an editor for a language, it replaces the default editor in all locations that use that language (SQL Lab, Dashboard Properties, CSS editors, etc.). diff --git a/docs/developer_docs/extensions/extension-points/download-menus.md b/docs/developer_docs/extensions/extension-points/download-menus.md new file mode 100644 index 000000000000..344095653024 --- /dev/null +++ b/docs/developer_docs/extensions/extension-points/download-menus.md @@ -0,0 +1,110 @@ +--- +title: Download Menus +sidebar_position: 5 +--- + + + +# Download Menu Extension Points + +Extensions can add items to the download menus for charts and dashboards. An item runs a command and passes it a context object that describes the chart or dashboard, including a function that returns the query behind the export. + +## Locations + +Register a menu item at one of these locations with `menus.registerMenuItem`, using the `primary` group. + +| Location | Surface | Where the item appears | +| --- | --- | --- | +| `explore.chart.download` | Explore, chart actions menu | After "Export to Excel" | +| `explore.chart.download.pivoted` | Explore, chart actions menu | After "Export to Pivoted Excel" | +| `explore.standalone.download` | Explore, standalone download control | After "Export to Excel" | +| `dashboard.chart.download` | Dashboard, chart menu | After "Export to Excel" | +| `dashboard.chart.download.pivoted` | Dashboard, chart menu | After "Export to Pivoted Excel" | +| `chart.drillDetail.download` | Drill to detail pane | After "Export to Excel" | +| `chart.drillBy.download` | Drill by modal | After "Export to Excel" | +| `dashboard.download` | Dashboard download menu | After "Export Data to Excel" | + +The pivoted locations appear only where the chart offers "Export to Pivoted Excel". For those locations, `getPayload()` returns the query for the post-processed (pivoted) result. + +## Context + +The seven chart locations pass a `ChartDownloadContext` to the command: + +```typescript +interface ChartDownloadContext { + getPayload: () => Promise>; + chartName: string; + canDownload: boolean; +} +``` + +- `getPayload()` is evaluated when called, so call it from the command. It resolves to the query-context object that the CSV export sends, with `result_format: 'json'`. The type is `Record`. +- `chartName` is the display name of the chart. +- `canDownload` is `false` when the current user lacks the permission to export the chart's data. + +The `dashboard.download` location passes a `DashboardDownloadContext`: + +```typescript +interface DashboardDownloadContext { + dashboardId: number; + dashboardTitle: string; + activeDataMask: Record< + string, + { + extraFormData: object; + filterState: { value?: unknown; label?: string }; + } + >; + canDownload: boolean; +} +``` + +`activeDataMask` lists the applied native filters only, keyed by filter ID. Each entry carries `extraFormData` and `filterState` with `value` and `label`. Cross-filters and `ownState` are not included. + +## Rules + +- Chart items are disabled when `canDownload` is `false`. +- The dashboard item appears only where "Export Data to Excel" does, so it is hidden from users who cannot export. +- Image-only charts get no items in the Explore and dashboard chart menus. The standalone control keeps them, because it offers CSV and Excel for every chart. +- The context is read when the item is clicked, so `getPayload()` and `canDownload` reflect the state at that moment. +- Register the command **before** its menu item. An item whose command is not registered yet is skipped, and it appears only after the menu next re-renders. +- A command that throws or rejects is logged and does not break the menu. + +## Example + +```typescript +import { commands, menus } from '@apache-superset/core'; + +commands.registerCommand( + { id: 'my-extension.export-rows', title: 'Export rows to My Service' }, + async ctx => { + const payload = await ctx.getPayload(); + // Send the payload to the service, for example with `fetch`. + }, +); + +menus.registerMenuItem( + { view: 'explore.chart.download', command: 'my-extension.export-rows' }, + 'explore.chart.download', + 'primary', +); +``` + +To report progress and the result to the user, see [Toasts](../toasts). diff --git a/docs/developer_docs/extensions/toasts.md b/docs/developer_docs/extensions/toasts.md new file mode 100644 index 000000000000..dcbbca12f582 --- /dev/null +++ b/docs/developer_docs/extensions/toasts.md @@ -0,0 +1,81 @@ +--- +title: Toasts +sidebar_position: 12 +--- + + + +# Toasts + +Extensions can show toasts in Superset's notification area with the `toasts` API. + +```typescript +import { toasts } from '@apache-superset/core'; + +toasts.addSuccessToast('Exported to Google Sheets', { + link: { href: 'https://docs.google.com/spreadsheets/d/abc', label: 'Open' }, +}); +``` + +## Functions + +| Function | Description | +| --- | --- | +| `addInfoToast(text, options?)` | Shows an informational toast | +| `addSuccessToast(text, options?)` | Shows a success toast | +| `addWarningToast(text, options?)` | Shows a warning toast | +| `addDangerToast(text, options?)` | Shows an error toast | +| `removeToast(id)` | Closes a toast | + +Each `add*Toast` function returns the ID of the toast, which you can pass to `removeToast`. + +## Options + +```typescript +interface ToastOptions { + link?: { href: string; label: string }; + duration?: number; +} +``` + +- `text` is always rendered as plain text, never as HTML. +- `link` adds a button with the given `label`. Clicking it opens `href` in a new tab with `noopener,noreferrer`. +- `duration` is the time in milliseconds before the toast closes. The default is 8000. `0` keeps the toast until the user closes it. +- A toast with a `link` never closes on its own, so that keyboard and assistive-technology users are not raced. `duration` is ignored when `link` is set. + +## Example + +Show a toast while an export runs, then replace it with the result: + +```typescript +import { toasts } from '@apache-superset/core'; + +const id = toasts.addInfoToast('Sending to Google Sheets…', { duration: 0 }); +try { + const url = await sendToSheets(); + toasts.removeToast(id); + toasts.addSuccessToast('Sent to Google Sheets', { + link: { href: url, label: 'Open' }, + }); +} catch { + toasts.removeToast(id); + toasts.addDangerToast('Sending to Google Sheets failed'); +} +``` diff --git a/docs/developer_docs/sidebars.js b/docs/developer_docs/sidebars.js index 34535ca90b8b..9fdbd7025d9d 100644 --- a/docs/developer_docs/sidebars.js +++ b/docs/developer_docs/sidebars.js @@ -50,6 +50,7 @@ module.exports = { 'extensions/extension-points/editors', 'extensions/extension-points/chat', 'extensions/extension-points/semantic-layers', + 'extensions/extension-points/download-menus', ], }, 'extensions/development', @@ -57,6 +58,7 @@ module.exports = { 'extensions/mcp', 'extensions/security', 'extensions/tasks', + 'extensions/toasts', 'extensions/registry', ], }, From c97415eebb5232fc50430c3ad5f429c3f9dee4a9 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 16:06:27 -0500 Subject: [PATCH 11/21] fix(extensions): address final review of download menus and toasts --- .../extension-points/download-menus.md | 2 +- .../superset-core/src/toasts/index.ts | 2 +- .../ExtensionMenuItems/index.test.tsx | 23 +++++++++++++++++-- .../components/ExtensionMenuItems/index.tsx | 7 +++--- .../src/core/toasts/index.test.ts | 5 ++++ superset-frontend/src/core/toasts/index.ts | 4 ++-- 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/docs/developer_docs/extensions/extension-points/download-menus.md b/docs/developer_docs/extensions/extension-points/download-menus.md index 344095653024..5fe4862cb4c2 100644 --- a/docs/developer_docs/extensions/extension-points/download-menus.md +++ b/docs/developer_docs/extensions/extension-points/download-menus.md @@ -39,7 +39,7 @@ Register a menu item at one of these locations with `menus.registerMenuItem`, us | `dashboard.chart.download.pivoted` | Dashboard, chart menu | After "Export to Pivoted Excel" | | `chart.drillDetail.download` | Drill to detail pane | After "Export to Excel" | | `chart.drillBy.download` | Drill by modal | After "Export to Excel" | -| `dashboard.download` | Dashboard download menu | After "Export Data to Excel" | +| `dashboard.download` | Dashboard download menu | After the Excel export entries | The pivoted locations appear only where the chart offers "Export to Pivoted Excel". For those locations, `getPayload()` returns the query for the post-processed (pivoted) result. diff --git a/superset-frontend/packages/superset-core/src/toasts/index.ts b/superset-frontend/packages/superset-core/src/toasts/index.ts index 8eb86c3cbdcc..cda566618006 100644 --- a/superset-frontend/packages/superset-core/src/toasts/index.ts +++ b/superset-frontend/packages/superset-core/src/toasts/index.ts @@ -39,7 +39,7 @@ export interface ToastOptions { /** A link shown on the toast; it opens in a new tab. */ link?: { href: string; label: string }; - /** Time in milliseconds before the toast closes; `0` keeps it until closed. Defaults to 8000. */ + /** Time in milliseconds before the toast closes; `0` keeps it until closed. Defaults to 8000. Ignored when `link` is set; link toasts stay until closed. */ duration?: number; } diff --git a/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx index 59d99cf6656b..7057067faa1e 100644 --- a/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx +++ b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx @@ -74,7 +74,7 @@ test('maps a registered item to a menu item that runs the command with the conte ); expect(result.current).toEqual([ expect.objectContaining({ - key: 'ext:ext.gs', + key: 'ext:explore.chart.download:ext.gs', label: 'Export to Google Sheets', disabled: false, }), @@ -108,7 +108,10 @@ test('disabled without download permission', () => { }), ); expect(result.current).toEqual([ - expect.objectContaining({ key: 'ext:ext.denied', disabled: true }), + expect.objectContaining({ + key: 'ext:explore.chart.download:ext.denied', + disabled: true, + }), ]); }); @@ -178,3 +181,19 @@ test('useChartDownloadItems reads the provider and is empty without one', () => }); expect(result.current).toHaveLength(1); }); + +test('key is qualified by location so a command at two locations does not collide', () => { + register('ext.both', 'Both', 'explore.chart.download'); + register('ext.both', 'Both', 'explore.chart.download.pivoted'); + const plain = renderHook(() => + useExtensionMenuItems('explore.chart.download', ctx), + ); + const pivoted = renderHook(() => + useExtensionMenuItems('explore.chart.download.pivoted', ctx), + ); + const plainKey = (plain.result.current[0] as MenuItemType).key; + const pivotedKey = (pivoted.result.current[0] as MenuItemType).key; + expect(plainKey).toContain('explore.chart.download:'); + expect(pivotedKey).toBe('ext:explore.chart.download.pivoted:ext.both'); + expect(plainKey).not.toBe(pivotedKey); +}); diff --git a/superset-frontend/src/components/ExtensionMenuItems/index.tsx b/superset-frontend/src/components/ExtensionMenuItems/index.tsx index d47bd0f55c04..a8f2ed8bc7d3 100644 --- a/superset-frontend/src/components/ExtensionMenuItems/index.tsx +++ b/superset-frontend/src/components/ExtensionMenuItems/index.tsx @@ -20,7 +20,8 @@ import { createContext, useContext, useMemo, useRef } from 'react'; import type { menus } from '@apache-superset/core'; import { logging } from '@apache-superset/core/utils'; import type { MenuItem } from '@superset-ui/core/components/Menu'; -import { commands, useMenu } from 'src/core'; +import { commands } from 'src/core/commands'; +import { useMenu } from 'src/core/menus'; type ChartDownloadLocation = menus.ChartDownloadLocation; type DashboardDownloadLocation = menus.DashboardDownloadLocation; @@ -53,7 +54,7 @@ export function useExtensionMenuItems( return; } items.push({ - key: `ext:${command.id}`, + key: `ext:${location}:${command.id}`, label: command.title, disabled: !canDownload, onClick: () => { @@ -67,7 +68,7 @@ export function useExtensionMenuItems( }); }); return items; - }, [menu, canDownload]); + }, [menu, canDownload, location]); } interface ChartDownloadProviderValue { diff --git a/superset-frontend/src/core/toasts/index.test.ts b/superset-frontend/src/core/toasts/index.test.ts index 627d416f935f..f51a770b910e 100644 --- a/superset-frontend/src/core/toasts/index.test.ts +++ b/superset-frontend/src/core/toasts/index.test.ts @@ -70,3 +70,8 @@ test('text is not rendered as html, even if extra options are passed', () => { const id = toasts.addInfoToast('x', { allowHtml: true } as never); expect(state().find(t => t.id === id)!.allowHtml).toBe(false); }); + +test('non-string text renders as its string form', () => { + const id = toasts.addDangerToast(new Error('boom') as unknown as string); + expect(state().find(t => t.id === id)!.text).toBe('Error: boom'); +}); diff --git a/superset-frontend/src/core/toasts/index.ts b/superset-frontend/src/core/toasts/index.ts index 905d0ab88cc3..bd6c2c2e41a6 100644 --- a/superset-frontend/src/core/toasts/index.ts +++ b/superset-frontend/src/core/toasts/index.ts @@ -36,7 +36,7 @@ const show = ( const link = options?.link; const action = link ? { - label: link.label, + label: String(link.label), onClick: () => { window.open(link.href, '_blank', 'noopener,noreferrer'); }, @@ -44,7 +44,7 @@ const show = ( : undefined; const toast = addToast({ toastType, - text, + text: String(text), duration: options?.duration ?? DEFAULT_DURATION, allowHtml: false, action, From b552621a9f2fb2afdcb43820c2ca7ed2fe318ed2 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 16:42:03 -0500 Subject: [PATCH 12/21] fix(extensions): open toast links through openInNewTab --- docs/developer_docs/extensions/toasts.md | 2 +- .../superset-core/src/toasts/index.ts | 2 +- .../src/core/toasts/index.test.ts | 30 ++++++++++++++++++- superset-frontend/src/core/toasts/index.ts | 8 ++++- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/developer_docs/extensions/toasts.md b/docs/developer_docs/extensions/toasts.md index dcbbca12f582..221d26a858ed 100644 --- a/docs/developer_docs/extensions/toasts.md +++ b/docs/developer_docs/extensions/toasts.md @@ -56,7 +56,7 @@ interface ToastOptions { ``` - `text` is always rendered as plain text, never as HTML. -- `link` adds a button with the given `label`. Clicking it opens `href` in a new tab with `noopener,noreferrer`. +- `link` adds a button with the given `label`. Clicking it opens `href` in a new tab with `noopener` and `noreferrer`. Only `http(s)`, relative, `ftp:`, `mailto:` and `tel:` URLs are opened; any other URL is refused and logged. - `duration` is the time in milliseconds before the toast closes. The default is 8000. `0` keeps the toast until the user closes it. - A toast with a `link` never closes on its own, so that keyboard and assistive-technology users are not raced. `duration` is ignored when `link` is set. diff --git a/superset-frontend/packages/superset-core/src/toasts/index.ts b/superset-frontend/packages/superset-core/src/toasts/index.ts index cda566618006..da776dacb7fc 100644 --- a/superset-frontend/packages/superset-core/src/toasts/index.ts +++ b/superset-frontend/packages/superset-core/src/toasts/index.ts @@ -37,7 +37,7 @@ * Optional settings for a toast. */ export interface ToastOptions { - /** A link shown on the toast; it opens in a new tab. */ + /** A link shown on the toast; it opens in a new tab, and only `http(s)`, relative, `ftp:`, `mailto:` and `tel:` URLs are opened. */ link?: { href: string; label: string }; /** Time in milliseconds before the toast closes; `0` keeps it until closed. Defaults to 8000. Ignored when `link` is set; link toasts stay until closed. */ duration?: number; diff --git a/superset-frontend/src/core/toasts/index.test.ts b/superset-frontend/src/core/toasts/index.test.ts index f51a770b910e..3ab5d8dfab95 100644 --- a/superset-frontend/src/core/toasts/index.test.ts +++ b/superset-frontend/src/core/toasts/index.test.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { logging } from '@apache-superset/core/utils'; import { store } from 'src/views/store'; import { ToastMeta, ToastType } from 'src/components/MessageToasts/types'; import { toasts } from './index'; @@ -48,11 +49,38 @@ test('link becomes an action that opens the url in a new tab', () => { expect(open).toHaveBeenCalledWith( 'https://docs.google.com/x', '_blank', - 'noopener,noreferrer', + 'noopener noreferrer', ); open.mockRestore(); }); +test('a link that fails the navigation guard is refused and logged', () => { + const open = jest.spyOn(window, 'open').mockImplementation(() => null); + const error = jest.spyOn(logging, 'error').mockImplementation(() => {}); + const id = toasts.addSuccessToast('Sent', { + link: { href: 'https://user:secret@example.com/x', label: 'Open' }, + }); + const toast = state().find(t => t.id === id)!; + expect(() => toast.action!.onClick()).not.toThrow(); + expect(open).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalled(); + open.mockRestore(); + error.mockRestore(); +}); + +test('a javascript: link is never opened as a script url', () => { + const open = jest.spyOn(window, 'open').mockImplementation(() => null); + const id = toasts.addSuccessToast('Sent', { + link: { href: 'javascript:alert(1)', label: 'Open' }, + }); + const toast = state().find(t => t.id === id)!; + expect(() => toast.action!.onClick()).not.toThrow(); + open.mock.calls.forEach(([url]) => { + expect(String(url)).not.toMatch(/^javascript:/i); + }); + open.mockRestore(); +}); + test('duration defaults to 8000 and accepts 0', () => { const a = toasts.addInfoToast('a'); const b = toasts.addInfoToast('b', { duration: 0 }); diff --git a/superset-frontend/src/core/toasts/index.ts b/superset-frontend/src/core/toasts/index.ts index bd6c2c2e41a6..907a7c73e0c9 100644 --- a/superset-frontend/src/core/toasts/index.ts +++ b/superset-frontend/src/core/toasts/index.ts @@ -18,6 +18,8 @@ */ import type { toasts as toastsApi } from '@apache-superset/core'; +import { logging } from '@apache-superset/core/utils'; +import { openInNewTab } from 'src/utils/navigationUtils'; import { store } from 'src/views/store'; import { addToast, @@ -38,7 +40,11 @@ const show = ( ? { label: String(link.label), onClick: () => { - window.open(link.href, '_blank', 'noopener,noreferrer'); + try { + openInNewTab(link.href); + } catch (error) { + logging.error('Refused to open toast link', link.href, error); + } }, } : undefined; From 6a3b160e594be8b3706f957ab83bad00b6a4d9e1 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Tue, 3 Nov 2026 16:42:52 -0500 Subject: [PATCH 13/21] fix(extensions): tighten toast link test and docs --- docs/developer_docs/extensions/toasts.md | 2 +- superset-frontend/packages/superset-core/src/toasts/index.ts | 2 +- superset-frontend/src/core/toasts/index.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/developer_docs/extensions/toasts.md b/docs/developer_docs/extensions/toasts.md index 221d26a858ed..cc20aa74f616 100644 --- a/docs/developer_docs/extensions/toasts.md +++ b/docs/developer_docs/extensions/toasts.md @@ -56,7 +56,7 @@ interface ToastOptions { ``` - `text` is always rendered as plain text, never as HTML. -- `link` adds a button with the given `label`. Clicking it opens `href` in a new tab with `noopener` and `noreferrer`. Only `http(s)`, relative, `ftp:`, `mailto:` and `tel:` URLs are opened; any other URL is refused and logged. +- `link` adds a button with the given `label`. Clicking it opens `href` in a new tab with `noopener` and `noreferrer`. The URL goes through Superset's navigation guard: absolute URLs must use `http(s)`, `ftp:`, `mailto:` or `tel:`, other values are treated as paths within Superset, and URLs the guard rejects (for example ones with embedded credentials) are logged instead of opened. - `duration` is the time in milliseconds before the toast closes. The default is 8000. `0` keeps the toast until the user closes it. - A toast with a `link` never closes on its own, so that keyboard and assistive-technology users are not raced. `duration` is ignored when `link` is set. diff --git a/superset-frontend/packages/superset-core/src/toasts/index.ts b/superset-frontend/packages/superset-core/src/toasts/index.ts index da776dacb7fc..97b9431ae342 100644 --- a/superset-frontend/packages/superset-core/src/toasts/index.ts +++ b/superset-frontend/packages/superset-core/src/toasts/index.ts @@ -37,7 +37,7 @@ * Optional settings for a toast. */ export interface ToastOptions { - /** A link shown on the toast; it opens in a new tab, and only `http(s)`, relative, `ftp:`, `mailto:` and `tel:` URLs are opened. */ + /** A link shown on the toast; it opens in a new tab after Superset's navigation guard; absolute URLs must use `http(s)`, `ftp:`, `mailto:` or `tel:`, and rejected URLs are logged instead of opened. */ link?: { href: string; label: string }; /** Time in milliseconds before the toast closes; `0` keeps it until closed. Defaults to 8000. Ignored when `link` is set; link toasts stay until closed. */ duration?: number; diff --git a/superset-frontend/src/core/toasts/index.test.ts b/superset-frontend/src/core/toasts/index.test.ts index 3ab5d8dfab95..2819aa17fe2e 100644 --- a/superset-frontend/src/core/toasts/index.test.ts +++ b/superset-frontend/src/core/toasts/index.test.ts @@ -71,7 +71,7 @@ test('a link that fails the navigation guard is refused and logged', () => { test('a javascript: link is never opened as a script url', () => { const open = jest.spyOn(window, 'open').mockImplementation(() => null); const id = toasts.addSuccessToast('Sent', { - link: { href: 'javascript:alert(1)', label: 'Open' }, + link: { href: ['javascript', 'alert(1)'].join(':'), label: 'Open' }, }); const toast = state().find(t => t.id === id)!; expect(() => toast.action!.onClick()).not.toThrow(); From db72f244cf672ada6dc5693e35ce5d8b3d6312e3 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Wed, 4 Nov 2026 11:28:13 -0500 Subject: [PATCH 14/21] fix(charts): pass the chart name to Drill By download items Dashboard form data has no slice_name, so contributed download items received "Untitled chart". Use the dashboard layout's name (override first), as the modal title does. --- .../Chart/DrillBy/DrillByModal.test.tsx | 69 +++++++++++++++---- .../components/Chart/DrillBy/DrillByModal.tsx | 6 +- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx index 1f074d42d71b..5729c6c58df9 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx @@ -131,7 +131,7 @@ const renderModal = async ( }); await userEvent.click(screen.getByRole('button', { name: 'Show modal' })); - await screen.findByRole('dialog', { name: `Drill by: ${chartName}` }); + await screen.findByRole('dialog', { name: /^Drill by: / }); }; beforeEach(() => { @@ -626,7 +626,10 @@ describe('Table view with pagination', () => { ); }); - test('contributed item gets the drilled payload and the original chart name', async () => { + const runContributedItem = async ( + overrideState: Record = {}, + modalProps: Partial = {}, + ) => { const run = jest.fn(); const disposables: common.Disposable[] = [ commands.registerCommand( @@ -640,13 +643,17 @@ describe('Table view with pagination', () => { ), ]; try { - await renderModal({ - column: { column_name: 'state', verbose_name: null }, - drillByConfig: { - filters: [{ col: 'gender', op: '==', val: 'boy' }], - groupbyFieldName: 'groupby', + await renderModal( + { + column: { column_name: 'state', verbose_name: null }, + drillByConfig: { + filters: [{ col: 'gender', op: '==', val: 'boy' }], + groupbyFieldName: 'groupby', + }, + ...modalProps, }, - }); + overrideState, + ); const tableRadio = await screen.findByRole('radio', { name: /table/i }); await userEvent.click(tableRadio.closest('label') ?? tableRadio); await waitFor(() => @@ -658,9 +665,8 @@ describe('Table view with pagination', () => { await screen.findByRole('button', { name: 'Download' }), ); await userEvent.click(await screen.findByText('Export to Google Sheets')); - expect(run).toHaveBeenCalledTimes(1); - const ctx = run.mock.calls[0][0] as { + return run.mock.calls[0][0] as { getPayload: () => Promise<{ result_type: string; result_format: string; @@ -668,13 +674,46 @@ describe('Table view with pagination', () => { }>; chartName: string; }; - expect(ctx.chartName).toBe(chartName); - const payload = await ctx.getPayload(); - expect(payload.result_type).toBe('full'); - expect(payload.result_format).toBe('json'); - expect(JSON.stringify(payload.queries[0])).toContain('state'); } finally { disposables.forEach(d => d.dispose()); } + }; + + test.each([ + ['sliceName', { sliceName: 'Layout name' }, 'Layout name'], + [ + 'sliceNameOverride', + { sliceName: 'Layout name', sliceNameOverride: 'Renamed on dashboard' }, + 'Renamed on dashboard', + ], + ])( + 'contributed item gets the dashboard layout %s as the chart name', + async (_, meta, expected) => { + const ctx = await runContributedItem( + { + dashboardLayout: { + past: [], + present: { + CHART_ID: { + id: 'CHART_ID', + meta: { chartId: formData.slice_id, ...meta }, + }, + }, + future: [], + }, + }, + { formData: omit(formData, 'slice_name') }, + ); + expect(ctx.chartName).toBe(expected); + }, + ); + + test('contributed item gets the drilled payload and the original chart name', async () => { + const ctx = await runContributedItem(); + expect(ctx.chartName).toBe(chartName); + const payload = await ctx.getPayload(); + expect(payload.result_type).toBe('full'); + expect(payload.result_format).toBe('json'); + expect(JSON.stringify(payload.queries[0])).toContain('state'); }); }); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx index 9f8c7e8ce12c..0bfff41f9966 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx @@ -512,9 +512,9 @@ export default function DrillByModal({ }, [addDangerToast, asyncModeOverride, drilledFormData]); const { metadataBar } = useDatasetMetadataBar({ dataset }); - // drilledFormData drops slice_name, so the original chart's name comes from - // formData. - const originalChartName: string = formData.slice_name ?? t('Untitled chart'); + // Dashboard form data carries no slice_name, so the layout's name comes first. + const originalChartName: string = + chartName || formData.slice_name || t('Untitled chart'); const downloadContext = useMemo( () => ({ getPayload: async () => ({ From 8cff1e15a6a56c1a353755c0f96f3628aa85efbe Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Wed, 4 Nov 2026 12:27:51 -0500 Subject: [PATCH 15/21] chore(translations): add the chart export error string to messages.pot --- superset/translations/messages.pot | 3 +++ 1 file changed, 3 insertions(+) diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot index 51950ca766f1..94ce2ecbfa67 100644 --- a/superset/translations/messages.pot +++ b/superset/translations/messages.pot @@ -6668,6 +6668,9 @@ msgstr "" msgid "Export failed: %s" msgstr "" +msgid "Export is not available for this chart" +msgstr "" + msgid "Export query" msgstr "" From 600bf364620f19db93f7d2b83e2c2185e8a17fbd Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Wed, 4 Nov 2026 13:22:11 -0500 Subject: [PATCH 16/21] fix(extensions): type download payloads and pass displayed chart titles - Add a public ChartDataRequest type and return it from getPayload, so surfaces no longer cast or spread the payload. - ChartDownloadProvider resolves the menu items itself; consumers only read them, so dropdowns outside a provider no longer subscribe to an unrelated location with a dummy context. - The dashboard chart menu and Drill to Detail use the title shown on the dashboard, including a dashboard-level rename. --- .../extension-points/download-menus.md | 15 ++++++-- .../superset-core/src/menus/locations.ts | 17 +++++++-- .../components/Chart/DrillBy/DrillByModal.tsx | 7 ++-- .../DrillDetail/DownloadDropdown.test.tsx | 7 +++- .../DrillDetail/DrillDetailModal.test.tsx | 38 +++++++++++++++++++ .../Chart/DrillDetail/DrillDetailModal.tsx | 12 +++++- .../Chart/DrillDetail/DrillDetailPane.tsx | 5 ++- .../ExtensionMenuItems/index.test.tsx | 7 +++- .../components/ExtensionMenuItems/index.tsx | 35 +++++------------ .../components/SliceHeader/index.tsx | 1 + .../SliceHeaderControls.test.tsx | 20 ++++++++++ .../components/SliceHeaderControls/index.tsx | 10 +++-- .../components/SliceHeaderControls/types.ts | 5 ++- .../components/gridComponents/Chart/Chart.tsx | 7 +++- .../useExploreDataExport.ts | 5 ++- 15 files changed, 143 insertions(+), 48 deletions(-) diff --git a/docs/developer_docs/extensions/extension-points/download-menus.md b/docs/developer_docs/extensions/extension-points/download-menus.md index 5fe4862cb4c2..6d3c215b6dfd 100644 --- a/docs/developer_docs/extensions/extension-points/download-menus.md +++ b/docs/developer_docs/extensions/extension-points/download-menus.md @@ -49,14 +49,23 @@ The seven chart locations pass a `ChartDownloadContext` to the command: ```typescript interface ChartDownloadContext { - getPayload: () => Promise>; + getPayload: () => Promise; chartName: string; canDownload: boolean; } + +interface ChartDataRequest { + datasource: { id: number; type: string }; + queries: object[]; + result_type: string; + result_format: string; + force?: boolean; + form_data?: object; +} ``` -- `getPayload()` is evaluated when called, so call it from the command. It resolves to the query-context object that the CSV export sends, with `result_format: 'json'`. The type is `Record`. -- `chartName` is the display name of the chart. +- `getPayload()` is evaluated when called, so call it from the command. It resolves to the `/api/v1/chart/data` request body that the CSV export sends, with `result_format: 'json'`. +- `chartName` is the chart's title as shown, including a name set on the dashboard. - `canDownload` is `false` when the current user lacks the permission to export the chart's data. The `dashboard.download` location passes a `DashboardDownloadContext`: diff --git a/superset-frontend/packages/superset-core/src/menus/locations.ts b/superset-frontend/packages/superset-core/src/menus/locations.ts index a0556792f973..0b5f723ceba7 100644 --- a/superset-frontend/packages/superset-core/src/menus/locations.ts +++ b/superset-frontend/packages/superset-core/src/menus/locations.ts @@ -36,15 +36,26 @@ export type ChartDownloadLocation = */ export type DashboardDownloadLocation = 'dashboard.download'; +/** Request body for `POST /api/v1/chart/data`. */ +export interface ChartDataRequest { + datasource: { id: number; type: string }; + queries: object[]; + result_type: string; + result_format: string; + force?: boolean; + form_data?: object; +} + /** * Argument passed to commands contributed to a {@link ChartDownloadLocation}. */ export interface ChartDownloadContext { /** - * Resolves to a query-context object with `result_format: 'json'`. - * Evaluated when called, so invoke it from the command rather than ahead of time. + * Resolves to the chart-data request the CSV export would send, with + * `result_format: 'json'`. Evaluated when called, so invoke it from the + * command rather than ahead of time. */ - getPayload: () => Promise>; + getPayload: () => Promise; /** Display name of the chart. */ chartName: string; /** Whether the current user may download this chart's data. */ diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx index 0bfff41f9966..482ab4fa0f2c 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx @@ -517,13 +517,12 @@ export default function DrillByModal({ chartName || formData.slice_name || t('Untitled chart'); const downloadContext = useMemo( () => ({ - getPayload: async () => ({ - ...(await buildV1ChartDataPayload({ + getPayload: () => + buildV1ChartDataPayload({ formData: drilledFormData, resultFormat: 'json', resultType: 'full', - })), - }), + }), chartName: originalChartName, canDownload, }), diff --git a/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx index 2b1304744504..4a7a141cb2ea 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx @@ -99,7 +99,12 @@ test('appends items contributed to the provider location', async () => { ({}), + getPayload: async () => ({ + datasource: { id: 1, type: 'table' }, + queries: [], + result_type: 'full', + result_format: 'json', + }), chartName: 'Sales', canDownload: true, }} diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx index b42fd947c65a..728ddda7da9a 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx @@ -86,6 +86,44 @@ test('should render the title', async () => { expect(screen.getByText(`Drill to detail: ${chartName}`)).toBeInTheDocument(); }); +test('should prefer the chart name shown on the dashboard', async () => { + render( + , + { + useRouter: true, + useRedux: true, + initialState: { + ...drillToDetailModalState, + dashboardLayout: { + past: [], + future: [], + present: { + 'CHART-1': { + id: 'CHART-1', + type: 'CHART', + meta: { + chartId, + sliceName: chartName, + sliceNameOverride: 'Renamed on dashboard', + }, + }, + }, + }, + }, + }, + ); + expect( + await screen.findByRole('dialog', { + name: 'Drill to detail: Renamed on dashboard', + }), + ).toBeInTheDocument(); +}); + test('should render the button', async () => { await renderModal(); expect( diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx index 498885e7a938..d53c0108f790 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx @@ -103,10 +103,20 @@ export default function DrillDetailModal({ const history = useHistory(); const dispatch = useDispatch(); const dashboardPageId = useContext(DashboardPageIdContext); - const { slice_name: chartName } = useSelector( + const { slice_name: savedChartName } = useSelector( (state: { sliceEntities: { slices: Record } }) => state.sliceEntities?.slices?.[chartId] || {}, ); + // A dashboard can rename a chart, so the layout's name comes first. + const layoutChartName = useSelector(state => { + const chartLayoutItem = Object.values( + state.dashboardLayout?.present ?? {}, + ).find(layoutItem => layoutItem.meta?.chartId === chartId); + return ( + chartLayoutItem?.meta.sliceNameOverride || chartLayoutItem?.meta.sliceName + ); + }); + const chartName = layoutChartName || savedChartName; const canExplore = useSelector((state: RootState) => findPermission('can_explore', 'Superset', state.user?.roles), ); diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx index 1f74d300c5a7..c15e5f723f5b 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx @@ -35,6 +35,7 @@ import { QueryFormData, SupersetClient, } from '@superset-ui/core'; +import type { menus } from '@apache-superset/core'; import { css, useTheme } from '@apache-superset/core/theme'; import { GenericDataType } from '@apache-superset/core/common'; import { useResizeDetector } from 'react-resize-detector'; @@ -103,12 +104,12 @@ function buildDrillDetailPayload({ rowLimit: number; dashboardId?: number; resultFormat: 'csv' | 'xlsx' | 'json'; -}): JsonObject | null { +}): menus.ChartDataRequest | null { const drillPayload = getDrillPayload(formData, filters); if (!drillPayload) { return null; } - const payload: JsonObject = { + const payload: menus.ChartDataRequest = { datasource: { id: parseInt(datasourceId, 10), type: datasourceType, diff --git a/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx index 7057067faa1e..511d6fdf1a81 100644 --- a/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx +++ b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx @@ -28,7 +28,12 @@ import { } from '.'; const ctx = { - getPayload: async () => ({}), + getPayload: async () => ({ + datasource: { id: 1, type: 'table' }, + queries: [], + result_type: 'full', + result_format: 'json', + }), chartName: 'Sales', canDownload: true, }; diff --git a/superset-frontend/src/components/ExtensionMenuItems/index.tsx b/superset-frontend/src/components/ExtensionMenuItems/index.tsx index a8f2ed8bc7d3..09c13c726402 100644 --- a/superset-frontend/src/components/ExtensionMenuItems/index.tsx +++ b/superset-frontend/src/components/ExtensionMenuItems/index.tsx @@ -71,38 +71,23 @@ export function useExtensionMenuItems( }, [menu, canDownload, location]); } -interface ChartDownloadProviderValue { +const ChartDownloadItemsCtx = createContext([]); + +/** Resolves the extension items for `location` and supplies them to nested menus. */ +export const ChartDownloadProvider: React.FC<{ location: ChartDownloadLocation; context: ChartDownloadContext; -} - -const ChartDownloadCtx = createContext(null); - -/** Supplies the chart download location and context to nested menus. */ -export const ChartDownloadProvider: React.FC< - ChartDownloadProviderValue & { children?: React.ReactNode } -> = ({ location, context, children }) => { - const value = useMemo(() => ({ location, context }), [location, context]); + children?: React.ReactNode; +}> = ({ location, context, children }) => { + const items = useExtensionMenuItems(location, context); return ( - + {children} - + ); }; -const NO_ITEMS: MenuItem[] = []; -const NO_CONTEXT: ChartDownloadContext = { - getPayload: async () => ({}), - chartName: '', - canDownload: false, -}; - /** Extension items for the nearest {@link ChartDownloadProvider}; empty without one. */ export function useChartDownloadItems(): MenuItem[] { - const provided = useContext(ChartDownloadCtx); - const items = useExtensionMenuItems( - provided?.location ?? 'explore.chart.download', - provided?.context ?? NO_CONTEXT, - ); - return provided ? items : NO_ITEMS; + return useContext(ChartDownloadItemsCtx); } diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index d91c9770d7f2..d62af389e06b 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -397,6 +397,7 @@ const SliceHeader = forwardRef( {!uiConfig.hideChartControls && !isMobile && ( { + const run = jest.fn(); + registerItem( + 'dashboard.chart.download', + 'ext.gs', + 'Export to Google Sheets', + run, + ); + renderWrapper({ + ...createProps(), + sliceName: 'Renamed on dashboard', + getExportPayload: jest.fn().mockResolvedValue({}), + }); + await openDownloadSubmenu('Export to Excel'); + await userEvent.click(screen.getByText('Export to Google Sheets')); + expect(run.mock.calls[0][0]).toEqual( + expect.objectContaining({ chartName: 'Renamed on dashboard' }), + ); +}); + test('dashboard chart: pivoted item follows Export to Pivoted Excel for pivot tables and requests post_processed', async () => { const run = jest.fn(); registerItem( diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 57e67487a4c6..50684f119556 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -40,6 +40,7 @@ import { JsonObject, QueryFormData, } from '@superset-ui/core'; +import type { menus } from '@apache-superset/core'; import { logging } from '@apache-superset/core/utils'; import { css, useTheme, styled } from '@apache-superset/core/theme'; import { useSelector } from 'react-redux'; @@ -115,6 +116,8 @@ export interface SliceHeaderControlsProps { slice_description: string; datasource: string; }; + /** Title shown in the chart header, which may override `slice.slice_name`. */ + sliceName?: string; defaultOpen?: boolean; componentId: string; @@ -141,7 +144,7 @@ export interface SliceHeaderControlsProps { exportFullXLSX?: (sliceId: number) => void; getExportPayload?: ( resultType: 'full' | 'post_processed', - ) => Promise; + ) => Promise; handleToggleFullSize: () => void; exportPivotExcel?: (tableSelector: string, sliceName: string) => void; @@ -467,6 +470,7 @@ const SliceHeaderControls = ( const isImageOnlyExport = isImageOnlyExportViz(slice.viz_type); const canDownload = !!props.supersetCanDownload; const { getExportPayload } = props; + const displayedChartName = props.sliceName || slice.slice_name; const getPayloadFor = (resultType: 'full' | 'post_processed') => () => getExportPayload ? getExportPayload(resultType) @@ -475,7 +479,7 @@ const SliceHeaderControls = ( 'dashboard.chart.download', { getPayload: getPayloadFor('full'), - chartName: slice.slice_name, + chartName: displayedChartName, canDownload, }, ); @@ -483,7 +487,7 @@ const SliceHeaderControls = ( 'dashboard.chart.download.pivoted', { getPayload: getPayloadFor('post_processed'), - chartName: slice.slice_name, + chartName: displayedChartName, canDownload, }, ); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts index d9d347c3bfbd..e1ed2aca22d2 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts @@ -17,6 +17,7 @@ * under the License. */ import { JsonObject, QueryFormData } from '@superset-ui/core'; +import type { menus } from '@apache-superset/core'; export interface SliceHeaderControlsProps { slice: { @@ -27,6 +28,8 @@ export interface SliceHeaderControlsProps { slice_description: string; datasource: string; }; + /** Title shown in the chart header, which may override `slice.slice_name`. */ + sliceName?: string; componentId: string; dashboardId: number; @@ -51,7 +54,7 @@ export interface SliceHeaderControlsProps { exportXLSX?: (sliceId: number) => void; getExportPayload?: ( resultType: 'full' | 'post_processed', - ) => Promise; + ) => Promise; exportFullXLSX?: (sliceId: number) => void; handleToggleFullSize: () => void; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index a3042efc1c7d..4e31c506d8ff 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx @@ -29,6 +29,7 @@ import { RefObject, } from 'react'; import type { ChartCustomization, JsonObject } from '@superset-ui/core'; +import type { menus } from '@apache-superset/core'; import { DatasourceType, VizType } from '@superset-ui/core'; import { styled } from '@apache-superset/core/theme'; import { t } from '@apache-superset/core/translation'; @@ -596,7 +597,9 @@ const Chart = (props: ChartProps) => { ); const getExportPayload = useCallback( - (resultType: 'full' | 'post_processed') => { + ( + resultType: 'full' | 'post_processed', + ): Promise => { const { formData: exportFormData, ownState: exportOwnState } = getExportInputs(false); return buildV1ChartDataPayload({ @@ -605,7 +608,7 @@ const Chart = (props: ChartProps) => { ownState: exportOwnState, resultType, resultFormat: 'json', - }) as Promise; + }); }, [getExportInputs], ); diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts index 5b4859fc44e7..b9a1aa3e9129 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts @@ -24,6 +24,7 @@ import { LatestQueryFormData, QueryFormData, } from '@superset-ui/core'; +import type { menus } from '@apache-superset/core'; import { t } from '@apache-superset/core/translation'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { @@ -262,13 +263,13 @@ export const useExploreDataExport = ({ // Builds at call time so extensions get the chart's latest form data. const getExportPayload = useCallback( - (resultType: 'full' | 'post_processed'): Promise => + (resultType: 'full' | 'post_processed'): Promise => buildV1ChartDataPayload({ formData: latestQueryFormData as QueryFormData, resultFormat: 'json', resultType, ownState, - }) as Promise, + }), [latestQueryFormData, ownState], ); From 2e4c10889a261ba42424a52247d934ff62009f0c Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Wed, 4 Nov 2026 13:44:00 -0500 Subject: [PATCH 17/21] test(extensions): cover unregistered commands and unbuildable drill payloads --- .../DrillDetail/DrillDetailPane.test.tsx | 27 +++++++++++++++++++ .../ExtensionMenuItems/index.test.tsx | 14 ++++++++++ 2 files changed, 41 insertions(+) diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx index c049e09bbfe9..0525127324db 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx @@ -36,6 +36,7 @@ import { supersetGetCache } from 'src/utils/cachedSupersetGet'; import { commands, menus } from 'src/core'; import type { common } from '@apache-superset/core'; import DrillDetailPane from './DrillDetailPane'; +import * as drillUtils from './utils'; const chart = chartQueries[sliceId]; const setup = (overrides: Record = {}) => { @@ -429,6 +430,32 @@ test('contributed item gets the drill_detail payload with json format and the ch } }); +test('contributed item getPayload rejects when the drill payload cannot be built', async () => { + fetchWithData(); + const run = jest.fn(); + const disposables = registerDrillDetailItem(run); + try { + render( + , + { useRedux: true, initialState: DOWNLOAD_STATE }, + ); + const ctx = await clickExtensionItem(run); + const getDrillPayload = jest + .spyOn(drillUtils, 'getDrillPayload') + .mockReturnValue(undefined); + await expect(ctx.getPayload()).rejects.toThrow( + 'Unable to generate download payload', + ); + getDrillPayload.mockRestore(); + } finally { + disposables.forEach(d => d.dispose()); + } +}); + test('CSV and Excel download bodies are unchanged by the payload extraction', async () => { fetchWithData(); const postFormSpy = jest diff --git a/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx index 511d6fdf1a81..b20523c87937 100644 --- a/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx +++ b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx @@ -71,6 +71,20 @@ test('returns no items when nothing is registered', () => { expect(result.current).toEqual([]); }); +test('skips an item whose command is not registered', () => { + disposables.push( + menus.registerMenuItem( + { view: 'explore.chart.download', command: 'ext.missing' }, + 'explore.chart.download', + 'primary', + ), + ); + const { result } = renderHook(() => + useExtensionMenuItems('explore.chart.download', ctx), + ); + expect(result.current).toEqual([]); +}); + test('maps a registered item to a menu item that runs the command with the context', async () => { const run = jest.fn(); register('ext.gs', 'Export to Google Sheets', 'explore.chart.download', run); From be4292731109eba1fac11e58d8342759c49027d3 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Wed, 4 Nov 2026 14:41:16 -0500 Subject: [PATCH 18/21] test(charts): pass onHideModal in the Drill to Detail title test --- .../src/components/Chart/DrillDetail/DrillDetailModal.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx index 728ddda7da9a..d05c4bfe7861 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx @@ -93,6 +93,7 @@ test('should prefer the chart name shown on the dashboard', async () => { formData={formData} initialFilters={[]} showModal + onHideModal={jest.fn()} />, { useRouter: true, From 271166979dd43b3512819fcae6ea5dab06c521ed Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Wed, 4 Nov 2026 16:34:22 -0500 Subject: [PATCH 19/21] test(dashboard): cover chart state in the extension export payload --- .../gridComponents/Chart/Chart.test.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx index 2a4580123a8c..a541df6c9bfb 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx @@ -1362,3 +1362,77 @@ test('dashboard chart download: getPayload reflects ownState changed after the c buildPayload.mockRestore(); } }); + +test('dashboard chart download: getPayload merges converted chart state into ownState', async () => { + jest + .spyOn(chartStateConverter, 'hasChartStateConverter') + .mockReturnValue(true); + const convert = jest + .spyOn(chartStateConverter, 'convertChartStateToOwnState') + .mockReturnValue({ sort: [{ columnId: 'column1', order: 'asc' }] }); + const buildPayload = jest + .spyOn(exploreUtils, 'buildV1ChartDataPayload') + .mockResolvedValue( + {} as unknown as Awaited< + ReturnType + >, + ); + const run = jest.fn(); + const disposables: common.Disposable[] = [ + commands.registerCommand( + { id: 'ext.gs', title: 'Export to Google Sheets' }, + run, + ), + menus.registerMenuItem( + { view: 'dashboard.chart.download', command: 'ext.gs' }, + 'dashboard.chart.download', + 'primary', + ), + ]; + try { + const { getByRole, findByText } = render(, { + store: createStore( + { + ...defaultState, + dashboardInfo: { + ...defaultState.dashboardInfo, + superset_can_download: true, + }, + dataMask: { [queryId]: { ownState: { currentPage: 2 } } }, + dashboardState: { + ...defaultState.dashboardState, + chartStates: { + [queryId]: { state: { sortColumn: 'column1', sortOrder: 'asc' } }, + }, + }, + }, + reducerIndex, + ), + useRouter: true, + }); + fireEvent.click(getByRole('button', { name: 'More Options' })); + fireEvent.mouseOver(getByRole('menuitem', { name: 'Download right' })); + fireEvent.click(await findByText('Export to Google Sheets')); + + const ctx = run.mock.calls[0][0] as { + getPayload: () => Promise; + }; + await ctx.getPayload(); + expect(convert).toHaveBeenCalledWith( + expect.anything(), + { sortColumn: 'column1', sortOrder: 'asc' }, + { forExport: true }, + ); + expect(buildPayload).toHaveBeenCalledWith( + expect.objectContaining({ + ownState: { + currentPage: 2, + sort: [{ columnId: 'column1', order: 'asc' }], + }, + }), + ); + } finally { + disposables.forEach(d => d.dispose()); + jest.restoreAllMocks(); + } +}); From f635dbd866cda9f3827dc650f2f1f8e95c0f3175 Mon Sep 17 00:00:00 2001 From: Orion Chen Date: Wed, 4 Nov 2026 17:11:42 -0500 Subject: [PATCH 20/21] ci: re-run checks after a flaky frontend type-check build From e4c8c37e805d4c3a76a70091a542e69ce64cda1f Mon Sep 17 00:00:00 2001 From: Grimshock1015 Date: Wed, 4 Nov 2026 20:07:47 -0500 Subject: [PATCH 21/21] fix(extensions): preserve full table export queries --- .../extension-points/download-menus.md | 2 +- .../plugin-chart-table/src/buildQuery.ts | 1 + .../components/Chart/DrillBy/DrillByModal.tsx | 8 +- .../gridComponents/Chart/Chart.test.tsx | 9 +- .../components/gridComponents/Chart/Chart.tsx | 8 +- .../useExploreDataExport.ts | 5 +- .../buildChartDataExportPayload.test.ts | 244 ++++++++++++++++++ .../src/explore/exploreUtils/index.ts | 26 ++ 8 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 superset-frontend/src/explore/exploreUtils/buildChartDataExportPayload.test.ts diff --git a/docs/developer_docs/extensions/extension-points/download-menus.md b/docs/developer_docs/extensions/extension-points/download-menus.md index 6d3c215b6dfd..ab36ff8a8530 100644 --- a/docs/developer_docs/extensions/extension-points/download-menus.md +++ b/docs/developer_docs/extensions/extension-points/download-menus.md @@ -64,7 +64,7 @@ interface ChartDataRequest { } ``` -- `getPayload()` is evaluated when called, so call it from the command. It resolves to the `/api/v1/chart/data` request body that the CSV export sends, with `result_format: 'json'`. +- `getPayload()` is evaluated when called, so call it from the command. It resolves to the `/api/v1/chart/data` request body that the CSV export sends, with `result_format: 'json'`. Chart query builders run in CSV export mode before the response format is changed to JSON, preserving export row limits, filters and column order. Paginated tables export from the first row up to the configured row limit, rather than only the visible page. - `chartName` is the chart's title as shown, including a name set on the dashboard. - `canDownload` is `false` when the current user lacks the permission to export the chart's data. diff --git a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts index 2407b20c5d25..90acab289c59 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts @@ -337,6 +337,7 @@ export const buildQuery: BuildQuery = ( }; if ( + !isDownloadQuery && formData.server_pagination && options?.extras?.cachedChanges?.[formData.slice_id] && JSON.stringify(options?.extras?.cachedChanges?.[formData.slice_id]) !== diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx index 482ab4fa0f2c..01be2dd3860f 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx @@ -54,7 +54,10 @@ import { LOG_ACTIONS_FURTHER_DRILL_BY, } from 'src/logger/LogUtils'; import { findPermission } from 'src/utils/findPermission'; -import { buildV1ChartDataPayload, exportChart } from 'src/explore/exploreUtils'; +import { + buildChartDataExportPayload, + exportChart, +} from 'src/explore/exploreUtils'; import { isEmbedded } from 'src/dashboard/util/isEmbedded'; import { useAsyncModeOverride } from 'src/utils/asyncMode'; import { ChartDownloadProvider } from 'src/components/ExtensionMenuItems'; @@ -518,9 +521,8 @@ export default function DrillByModal({ const downloadContext = useMemo( () => ({ getPayload: () => - buildV1ChartDataPayload({ + buildChartDataExportPayload({ formData: drilledFormData, - resultFormat: 'json', resultType: 'full', }), chartName: originalChartName, diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx index a541df6c9bfb..046ee2098343 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx @@ -1295,10 +1295,10 @@ test('passes the form data of the last request to ChartContainer next to the cur test('dashboard chart download: getPayload reflects ownState changed after the chart loaded', async () => { const payload = { result_format: 'json', result_type: 'full' }; const buildPayload = jest - .spyOn(exploreUtils, 'buildV1ChartDataPayload') + .spyOn(exploreUtils, 'buildChartDataExportPayload') .mockResolvedValue( payload as unknown as Awaited< - ReturnType + ReturnType >, ); const run = jest.fn(); @@ -1354,7 +1354,6 @@ test('dashboard chart download: getPayload reflects ownState changed after the c formData: expect.objectContaining({ dashboardId: 111 }), ownState: expect.objectContaining({ currentPage: 7 }), resultType: 'full', - resultFormat: 'json', }), ); } finally { @@ -1371,10 +1370,10 @@ test('dashboard chart download: getPayload merges converted chart state into own .spyOn(chartStateConverter, 'convertChartStateToOwnState') .mockReturnValue({ sort: [{ columnId: 'column1', order: 'asc' }] }); const buildPayload = jest - .spyOn(exploreUtils, 'buildV1ChartDataPayload') + .spyOn(exploreUtils, 'buildChartDataExportPayload') .mockResolvedValue( {} as unknown as Awaited< - ReturnType + ReturnType >, ); const run = jest.fn(); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index 4e31c506d8ff..680a2e24e9b0 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx @@ -37,7 +37,10 @@ import { debounce } from 'lodash-es'; import { bindActionCreators } from 'redux'; import { useDispatch, useSelector } from 'react-redux'; -import { buildV1ChartDataPayload, exportChart } from 'src/explore/exploreUtils'; +import { + buildChartDataExportPayload, + exportChart, +} from 'src/explore/exploreUtils'; import ChartContainer from 'src/components/Chart/ChartContainer'; import LastQueriedLabel from 'src/components/LastQueriedLabel'; import { @@ -602,12 +605,11 @@ const Chart = (props: ChartProps) => { ): Promise => { const { formData: exportFormData, ownState: exportOwnState } = getExportInputs(false); - return buildV1ChartDataPayload({ + return buildChartDataExportPayload({ formData: exportFormData as unknown as import('@superset-ui/core').QueryFormData, ownState: exportOwnState, resultType, - resultFormat: 'json', }); }, [getExportInputs], diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts index b9a1aa3e9129..c20758483d1b 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts @@ -33,7 +33,7 @@ import { } from 'src/components/StreamingExportModal'; import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants'; import { - buildV1ChartDataPayload, + buildChartDataExportPayload, exportChart, getChartKey, } from 'src/explore/exploreUtils'; @@ -264,9 +264,8 @@ export const useExploreDataExport = ({ // Builds at call time so extensions get the chart's latest form data. const getExportPayload = useCallback( (resultType: 'full' | 'post_processed'): Promise => - buildV1ChartDataPayload({ + buildChartDataExportPayload({ formData: latestQueryFormData as QueryFormData, - resultFormat: 'json', resultType, ownState, }), diff --git a/superset-frontend/src/explore/exploreUtils/buildChartDataExportPayload.test.ts b/superset-frontend/src/explore/exploreUtils/buildChartDataExportPayload.test.ts new file mode 100644 index 000000000000..3ab95ca81bff --- /dev/null +++ b/superset-frontend/src/explore/exploreUtils/buildChartDataExportPayload.test.ts @@ -0,0 +1,244 @@ +/** + * 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 { cloneDeep } from 'lodash-es'; +import { + getChartBuildQueryRegistry, + JsonObject, + QueryFormData, + QueryMode, +} from '@superset-ui/core'; +import { cachedBuildQuery as createTableQuery } from '../../../plugins/plugin-chart-table/src/buildQuery'; +import { cachedBuildQuery as createAgGridQuery } from '../../../plugins/plugin-chart-ag-grid-table/src/buildQuery'; +import { buildChartDataExportPayload, buildV1ChartDataPayload } from '.'; + +const resultTypes = ['full', 'post_processed'] as const; +const paginatedCases = [ + { plugin: 'Table', createBuildQuery: createTableQuery }, + { plugin: 'AG Grid', createBuildQuery: createAgGridQuery }, +].flatMap(plugin => resultTypes.map(resultType => ({ ...plugin, resultType }))); + +test.each(paginatedCases)( + '$plugin $resultType export plans every configured row instead of the current page', + async ({ plugin, createBuildQuery, resultType }) => { + const viz = `extension_export_${plugin}_${resultType}`; + const registry = getChartBuildQueryRegistry(); + registry.registerValue(viz, createBuildQuery()); + const formData: QueryFormData = { + viz_type: viz, + datasource: '11__table', + slice_id: 17, + query_mode: QueryMode.Aggregate, + groupby: ['state'], + metrics: ['count'], + server_pagination: true, + server_page_length: 20, + row_limit: 1000, + result_format: 'json', + result_type: resultType, + extra_form_data: { + filters: [{ col: 'country', op: 'IN', val: ['CA'] }], + }, + }; + const ownState: JsonObject = { + currentPage: 2, + pageSize: 20, + sortBy: [{ key: 'state', desc: false }], + }; + const originalFormData = cloneDeep(formData); + const originalOwnState = cloneDeep(ownState); + + try { + const payload = await buildChartDataExportPayload({ + formData, + resultType, + ownState, + }); + const nativeCsv = await buildV1ChartDataPayload({ + formData, + resultType, + resultFormat: 'csv', + ownState, + }); + + expect(payload.queries).toEqual(nativeCsv.queries); + expect(payload.queries).toHaveLength(1); + expect(payload.queries[0]).toMatchObject({ + row_limit: 1000, + row_offset: 0, + columns: ['state'], + }); + expect(payload.queries[0].orderby?.[0]).toEqual(['state', true]); + expect(payload.queries[0].filters).toContainEqual( + expect.objectContaining({ + col: 'country', + op: 'IN', + val: ['CA'], + }), + ); + expect(payload.queries.some(query => query.is_rowcount)).toBe(false); + expect(payload.result_format).toBe('json'); + expect(payload.result_type).toBe(resultType); + expect(payload.form_data).toMatchObject({ + result_format: 'json', + result_type: resultType, + }); + expect(nativeCsv.result_format).toBe('csv'); + expect(nativeCsv.form_data?.result_format).toBe('csv'); + expect(formData).toEqual(originalFormData); + expect(ownState).toEqual(originalOwnState); + } finally { + registry.remove(viz); + } + }, +); + +test.each(resultTypes)( + 'Table %s export keeps the configured limit when cached native filters change', + async resultType => { + const viz = `extension_export_table_changed_filters_${resultType}`; + const registry = getChartBuildQueryRegistry(); + registry.registerValue(viz, createTableQuery()); + const formData: QueryFormData = { + viz_type: viz, + datasource: '11__table', + slice_id: 17, + query_mode: QueryMode.Aggregate, + groupby: ['state'], + metrics: ['count'], + server_pagination: true, + server_page_length: 20, + row_limit: 1000, + extra_form_data: { + filters: [{ col: 'country', op: 'IN', val: ['CA'] }], + }, + }; + const ownState: JsonObject = { currentPage: 2, pageSize: 20 }; + const filteredFormData: QueryFormData = { + ...formData, + extra_form_data: { + filters: [{ col: 'country', op: 'IN', val: ['US'] }], + }, + }; + const originalFormData = cloneDeep(filteredFormData); + const originalOwnState = cloneDeep(ownState); + + try { + await buildV1ChartDataPayload({ + formData, + resultFormat: 'json', + resultType: 'full', + ownState, + }); + const payload = await buildChartDataExportPayload({ + formData: filteredFormData, + resultType, + ownState, + }); + const nativeCsv = await buildV1ChartDataPayload({ + formData: filteredFormData, + resultFormat: 'csv', + resultType, + ownState, + }); + + expect(payload.queries).toEqual(nativeCsv.queries); + expect(payload.queries).toHaveLength(1); + expect(payload.queries[0]).toMatchObject({ + row_limit: 1000, + row_offset: 0, + }); + expect(payload.queries[0].filters).toContainEqual( + expect.objectContaining({ col: 'country', op: 'IN', val: ['US'] }), + ); + expect(payload.queries.some(query => query.is_rowcount)).toBe(false); + expect(payload.result_format).toBe('json'); + expect(payload.result_type).toBe(resultType); + expect(filteredFormData).toEqual(originalFormData); + expect(ownState).toEqual(originalOwnState); + } finally { + registry.remove(viz); + } + }, +); + +test('AG Grid export keeps native CSV header filters, column order and sorting in client mode', async () => { + const viz = 'extension_export_ag_grid_client_state'; + const registry = getChartBuildQueryRegistry(); + registry.registerValue(viz, createAgGridQuery()); + const formData: QueryFormData = { + viz_type: viz, + datasource: '11__table', + query_mode: QueryMode.Raw, + all_columns: ['Destination Address Street', 'Destination Address State'], + server_pagination: false, + row_limit: 1000, + result_format: 'json', + }; + const ownState: JsonObject = { + columnOrder: ['Destination Address State', 'Destination Address Street'], + sortModel: [{ colId: 'Destination Address Street', sort: 'asc' }], + filters: [{ col: 'Destination Address State', op: 'IN', val: ['CA'] }], + agGridFilterModel: { + 'Destination Address Street': { + filterType: 'text', + type: 'contains', + filter: 'Main', + }, + }, + }; + const originalFormData = cloneDeep(formData); + const originalOwnState = cloneDeep(ownState); + + try { + const payload = await buildChartDataExportPayload({ + formData, + resultType: 'full', + ownState, + }); + const nativeCsv = await buildV1ChartDataPayload({ + formData, + resultFormat: 'csv', + resultType: 'full', + ownState, + }); + const [query] = payload.queries; + + expect(payload.queries).toEqual(nativeCsv.queries); + expect(query.columns).toEqual([ + 'Destination Address State', + 'Destination Address Street', + ]); + expect(query.extras).toMatchObject({ column_order: ownState.columnOrder }); + expect(query.orderby?.[0]).toEqual(['Destination Address Street', true]); + expect(query.filters).toEqual( + expect.arrayContaining([ + { col: 'Destination Address State', op: 'IN', val: ['CA'] }, + { col: 'Destination Address Street', op: 'ILIKE', val: '%Main%' }, + ]), + ); + expect(query.extras?.where || undefined).toBeUndefined(); + expect(payload.result_format).toBe('json'); + expect(payload.result_type).toBe('full'); + expect(payload.form_data?.result_format).toBe('json'); + expect(formData).toEqual(originalFormData); + expect(ownState).toEqual(originalOwnState); + } finally { + registry.remove(viz); + } +}); diff --git a/superset-frontend/src/explore/exploreUtils/index.ts b/superset-frontend/src/explore/exploreUtils/index.ts index 30b9da533df3..8223973c15e6 100644 --- a/superset-frontend/src/explore/exploreUtils/index.ts +++ b/superset-frontend/src/explore/exploreUtils/index.ts @@ -338,6 +338,32 @@ export const buildV1ChartDataPayload = async ({ return payload; }; +/** Build a native export query while requesting record-oriented JSON results. */ +export const buildChartDataExportPayload = async ({ + formData, + ownState, + resultType, +}: { + formData: QueryFormData; + ownState?: JsonObject; + resultType: 'full' | 'post_processed'; +}): Promise> => { + // Plugins use the format to apply export limits, filters and column ordering. + const payload = await buildV1ChartDataPayload({ + formData, + ownState, + resultType, + resultFormat: 'csv', + }); + return { + ...payload, + result_format: 'json', + ...(payload.form_data && { + form_data: { ...payload.form_data, result_format: 'json' }, + }), + }; +}; + export const exportChart = async ({ formData, resultFormat = 'json',