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..ab36ff8a8530 --- /dev/null +++ b/docs/developer_docs/extensions/extension-points/download-menus.md @@ -0,0 +1,119 @@ +--- +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 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. + +## Context + +The seven chart locations pass a `ChartDownloadContext` to the command: + +```typescript +interface ChartDownloadContext { + 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 `/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. + +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..cc20aa74f616 --- /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` 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. + +## 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', ], }, 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/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..0b5f723ceba7 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/menus/locations.ts @@ -0,0 +1,83 @@ +/** + * 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'; + +/** 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 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; + /** 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/packages/superset-core/src/toasts/index.ts b/superset-frontend/packages/superset-core/src/toasts/index.ts new file mode 100644 index 000000000000..97b9431ae342 --- /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 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; +} + +/** + * 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/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.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx index 12ae22ee3ac9..5729c6c58df9 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(); @@ -129,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(() => { @@ -623,4 +625,95 @@ describe('Table view with pagination', () => { }), ); }); + + const runContributedItem = async ( + overrideState: Record = {}, + modalProps: Partial = {}, + ) => { + 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', + }, + ...modalProps, + }, + overrideState, + ); + 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); + return run.mock.calls[0][0] as { + getPayload: () => Promise<{ + result_type: string; + result_format: string; + queries: { columns: unknown[] }[]; + }>; + chartName: string; + }; + } 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 f09e3597e066..01be2dd3860f 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx @@ -54,9 +54,13 @@ import { LOG_ACTIONS_FURTHER_DRILL_BY, } from 'src/logger/LogUtils'; import { findPermission } from 'src/utils/findPermission'; -import { 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'; import { Dataset, DrillByType } from '../types'; import DrillByChart from './DrillByChart'; import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu'; @@ -511,93 +515,114 @@ export default function DrillByModal({ }, [addDangerToast, asyncModeOverride, drilledFormData]); const { metadataBar } = useDatasetMetadataBar({ dataset }); + // 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: () => + buildChartDataExportPayload({ + formData: drilledFormData, + 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..4a7a141cb2ea 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,66 @@ 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( + ({ + datasource: { id: 1, type: 'table' }, + queries: [], + result_type: 'full', + result_format: 'json', + }), + 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.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx index b42fd947c65a..d05c4bfe7861 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx @@ -86,6 +86,45 @@ 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 db60322b6a1a..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), ); @@ -166,6 +176,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..0525127324db 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx @@ -33,7 +33,10 @@ 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'; +import * as drillUtils from './utils'; const chart = chartQueries[sliceId]; const setup = (overrides: Record = {}) => { @@ -350,6 +353,179 @@ 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('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 + .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..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'; @@ -60,6 +61,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 +86,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'; +}): menus.ChartDataRequest | null { + const drillPayload = getDrillPayload(formData, filters); + if (!drillPayload) { + return null; + } + const payload: menus.ChartDataRequest = { + 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 +284,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 +316,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 +500,10 @@ export default function DrillDetailPane({ } return ( - <> + {!bootstrapping && metadataBarComponent} {!bootstrapping && ( )} {tableContent} - + ); } 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..b20523c87937 --- /dev/null +++ b/superset-frontend/src/components/ExtensionMenuItems/index.test.tsx @@ -0,0 +1,218 @@ +/** + * 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 () => ({ + datasource: { id: 1, type: 'table' }, + queries: [], + result_type: 'full', + result_format: 'json', + }), + 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('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); + const { result } = renderHook(() => + useExtensionMenuItems('explore.chart.download', ctx), + ); + expect(result.current).toEqual([ + expect.objectContaining({ + key: 'ext:explore.chart.download: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:explore.chart.download: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); +}); + +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 new file mode 100644 index 000000000000..09c13c726402 --- /dev/null +++ b/superset-frontend/src/components/ExtensionMenuItems/index.tsx @@ -0,0 +1,93 @@ +/** + * 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 } from 'src/core/commands'; +import { useMenu } from 'src/core/menus'; + +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:${location}:${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, location]); +} + +const ChartDownloadItemsCtx = createContext([]); + +/** Resolves the extension items for `location` and supplies them to nested menus. */ +export const ChartDownloadProvider: React.FC<{ + location: ChartDownloadLocation; + context: ChartDownloadContext; + children?: React.ReactNode; +}> = ({ location, context, children }) => { + const items = useExtensionMenuItems(location, context); + return ( + + {children} + + ); +}; + +/** Extension items for the nearest {@link ChartDownloadProvider}; empty without one. */ +export function useChartDownloadItems(): MenuItem[] { + return useContext(ChartDownloadItemsCtx); +} 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..2819aa17fe2e --- /dev/null +++ b/superset-frontend/src/core/toasts/index.test.ts @@ -0,0 +1,105 @@ +/** + * 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 { logging } from '@apache-superset/core/utils'; +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('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)'].join(':'), 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 }); + 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); +}); + +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 new file mode 100644 index 000000000000..907a7c73e0c9 --- /dev/null +++ b/superset-frontend/src/core/toasts/index.ts @@ -0,0 +1,70 @@ +/** + * 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 { logging } from '@apache-superset/core/utils'; +import { openInNewTab } from 'src/utils/navigationUtils'; +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: String(link.label), + onClick: () => { + try { + openInNewTab(link.href); + } catch (error) { + logging.error('Refused to open toast link', link.href, error); + } + }, + } + : undefined; + const toast = addToast({ + toastType, + text: String(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/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 8fc85064424b..d62af389e06b 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 = {}, @@ -396,6 +397,7 @@ const SliceHeader = forwardRef( {!uiConfig.hideChartControls && !isMobile && ( ( 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..284d76671a75 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,179 @@ 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: contributed item gets the title shown in the chart header', async () => { + 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( + '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..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'; @@ -50,6 +51,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'; @@ -114,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; @@ -138,6 +142,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 +468,29 @@ 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 displayedChartName = props.sliceName || slice.slice_name; + 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: displayedChartName, + canDownload, + }, + ); + const extensionPivotedDownloadItems = useExtensionMenuItems( + 'dashboard.chart.download.pivoted', + { + getPayload: getPayloadFor('post_processed'), + chartName: displayedChartName, + canDownload, + }, + ); const cachedWhen = (cachedDttm || []).map(itemCachedDttm => (extendedDayjs.utc(itemCachedDttm) as any).fromNow(), ); @@ -701,6 +731,7 @@ const SliceHeaderControls = ( label: t('Export to Pivoted Excel'), icon: , }, + ...extensionPivotedDownloadItems, ] : []), ...(isImageOnlyExport @@ -711,6 +742,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..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; @@ -49,6 +52,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..046ee2098343 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, @@ -25,6 +33,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 +1291,147 @@ test('passes the form data of the last request to ChartContainer next to the cur expect.objectContaining({ groupby: ['region'] }), ); }); + +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, 'buildChartDataExportPayload') + .mockResolvedValue( + payload 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 store = createStore( + { + ...defaultState, + dashboardInfo: { + ...defaultState.dashboardInfo, + superset_can_download: true, + }, + 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' })); + 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; + }; + await expect(ctx.getPayload()).resolves.toBe(payload); + expect(buildPayload).toHaveBeenCalledWith( + expect.objectContaining({ + formData: expect.objectContaining({ dashboardId: 111 }), + ownState: expect.objectContaining({ currentPage: 7 }), + resultType: 'full', + }), + ); + } finally { + disposables.forEach(d => d.dispose()); + 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, 'buildChartDataExportPayload') + .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(); + } +}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index c49437d1aa4f..680a2e24e9b0 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'; @@ -36,7 +37,10 @@ import { debounce } from 'lodash-es'; import { bindActionCreators } from 'redux'; import { useDispatch, useSelector } from 'react-redux'; -import { 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 { @@ -568,6 +572,49 @@ 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', + ): Promise => { + const { formData: exportFormData, ownState: exportOwnState } = + getExportInputs(false); + return buildChartDataExportPayload({ + formData: + exportFormData as unknown as import('@superset-ui/core').QueryFormData, + ownState: exportOwnState, + resultType, + }); + }, + [getExportInputs], + ); + const exportTable = useCallback( async (format: string, isFullCSV: boolean, isPivot = false) => { const logAction = @@ -579,9 +626,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 +675,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 +722,7 @@ const Chart = (props: ChartProps) => { sliceVizType, isCached, formData, - maxRows, - dataMaskOwnState, - chartState, - props.id, + getExportInputs, boundActionCreators.logEvent, boundActionCreators.addDangerToast, queriesResponse, @@ -793,6 +819,7 @@ const Chart = (props: ChartProps) => { exportXLSX={exportXLSX} exportFullCSV={exportFullCSV} exportFullXLSX={exportFullXLSX} + getExportPayload={getExportPayload} updateSliceName={(name: string) => props.updateSliceName(props.id, name) } 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. 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..8f64746d9ebf 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,8 @@ export const useExploreAdditionalActionsMenu = ( ); }, }, + // Image-only charts offer no data exports, so extensions get no items. + ...(imageOnlyExport ? [] : extensionExportItems), ); const currentViewChildren = [ @@ -1051,7 +1077,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 +1089,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 +1233,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..0415cceea232 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,205 @@ 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', + ]); +}); + +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(); +}); diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts index 0aa90cffbd83..c20758483d1b 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 { @@ -31,7 +32,11 @@ import { useStreamingExport, } from 'src/components/StreamingExportModal'; import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants'; -import { exportChart, getChartKey } from 'src/explore/exploreUtils'; +import { + buildChartDataExportPayload, + exportChart, + getChartKey, +} from 'src/explore/exploreUtils'; import { ChartState } from 'src/explore/types'; import { Slice } from 'src/types/Chart'; @@ -256,6 +261,17 @@ 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 => + buildChartDataExportPayload({ + formData: latestQueryFormData as QueryFormData, + resultType, + ownState, + }), + [latestQueryFormData, ownState], + ); + const streamingExportState: StreamingExportState = { isVisible: isStreamingModalVisible, progress, @@ -269,6 +285,7 @@ export const useExploreDataExport = ({ exportCSVPivoted, exportJson, exportExcel, + getExportPayload, handleExportError, streamingExportState, }; 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', 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; } 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 ""