From 058538287ff854d3c5cd37f08efcd91cf2550146 Mon Sep 17 00:00:00 2001 From: Lucy Chen <140550297+lucychen-grafana@users.noreply.github.com> Date: Thu, 27 Jun 2024 15:36:23 -0400 Subject: [PATCH] ShareModal: Export options (JSON) (#87082) * Adding new export button * Create Export as JSON drawer * update scene drawer and add css * update css * Update ExportAsJson to be regular react component * add tests to export menu and button * add tests * prettier and lint * fix translations * update translation * Apply suggestions from code review Co-authored-by: Juan Cabanas * delete extra file * Update to use SceneObject * add spinner * Rename ExportAsJSON.tsx to ExportAsJson.tsx * update i18n * Upate texts * small fixes from code review * add space * i18n * fix build issues * changes from review feedback * update test * update test --------- Co-authored-by: Juan Cabanas --- .../src/selectors/pages.ts | 18 +++ .../scene/NavToolbarActions.test.tsx | 8 ++ .../scene/NavToolbarActions.tsx | 9 +- .../sharing/ExportButton/ExportAsJson.tsx | 110 ++++++++++++++++++ .../ExportButton/ExportButton.test.tsx | 56 +++++++++ .../sharing/ExportButton/ExportButton.tsx | 38 ++++++ .../sharing/ExportButton/ExportMenu.test.tsx | 44 +++++++ .../sharing/ExportButton/ExportMenu.tsx | 32 +++++ .../sharing/ShareExportTab.tsx | 10 +- public/locales/en-US/grafana.json | 15 +++ public/locales/pseudo-LOCALE/grafana.json | 15 +++ 11 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.test.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.test.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index a387f8538b3..45c453130fa 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -69,6 +69,14 @@ export const Pages = { shareSnapshot: 'data-testid new share button share snapshot', }, }, + NewExportButton: { + container: 'data-testid new export button', + arrowMenu: 'data-testid new export button arrow menu', + Menu: { + container: 'data-testid new export button menu', + exportAsJson: 'data-testid new export button export as json', + }, + }, playlistControls: { prev: 'data-testid playlist previous dashboard button', stop: 'data-testid playlist stop dashboard button', @@ -287,6 +295,16 @@ export const Pages = { container: 'data-testid share snapshot drawer container', }, }, + ExportDashboardDrawer: { + ExportAsJson: { + container: 'data-testid export as Json drawer container', + codeEditor: 'data-testid export as Json code editor', + exportExternallyToggle: 'data-testid export externally toggle type select', + saveToFileButton: 'data-testid save to file button', + copyToClipboardButton: 'data-testid copy to clipboard button', + cancelButton: 'data-testid cancel button', + }, + }, PublicDashboard: { page: 'public-dashboard-page', NotAvailable: { diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx index 9e8b862be00..af7d4d4a858 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx @@ -153,6 +153,8 @@ describe('NavToolbarActions', () => { expect(await screen.findByText('Share')).toBeInTheDocument(); const newShareButton = screen.queryByTestId(selectors.pages.Dashboard.DashNav.newShareButton.container); expect(newShareButton).not.toBeInTheDocument(); + const newExportButton = screen.queryByTestId(selectors.pages.Dashboard.DashNav.NewExportButton.container); + expect(newExportButton).not.toBeInTheDocument(); }); it('Should show new share button when newDashboardSharingComponent FF is enabled', async () => { config.featureToggles.newDashboardSharingComponent = true; @@ -162,6 +164,12 @@ describe('NavToolbarActions', () => { const newShareButton = screen.getByTestId(selectors.pages.Dashboard.DashNav.newShareButton.container); expect(newShareButton).toBeInTheDocument(); }); + it('Should show new export button when newDashboardSharingComponent FF is enabled', async () => { + config.featureToggles.newDashboardSharingComponent = true; + setup(); + const newExportButton = screen.getByTestId(selectors.pages.Dashboard.DashNav.NewExportButton.container); + expect(newExportButton).toBeInTheDocument(); + }); }); describe('Snapshot', () => { diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index f3a147d9578..e851c1ea4cc 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -24,6 +24,7 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor'; +import ExportButton from '../sharing/ExportButton/ExportButton'; import ShareButton from '../sharing/ShareButton/ShareButton'; import { ShareModal } from '../sharing/ShareModal'; import { DashboardInteractions } from '../utils/interactions'; @@ -373,7 +374,13 @@ export function ToolbarActions({ dashboard }: Props) { }); toolbarActions.push({ - group: 'new-share-dashboard-button', + group: 'new-share-dashboard-buttons', + condition: config.featureToggles.newDashboardSharingComponent && showShareButton, + render: () => , + }); + + toolbarActions.push({ + group: 'new-share-dashboard-buttons', condition: config.featureToggles.newDashboardSharingComponent && showShareButton, render: () => , }); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx new file mode 100644 index 00000000000..8739b919099 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx @@ -0,0 +1,110 @@ +import { css } from '@emotion/css'; +import { useAsync } from 'react-use'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { SceneComponentProps } from '@grafana/scenes'; +import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +import { getDashboardSceneFor } from '../../utils/utils'; +import { ShareExportTab } from '../ShareExportTab'; + +const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson; + +export class ExportAsJson extends ShareExportTab { + static Component = ExportAsJsonRenderer; +} + +function ExportAsJsonRenderer({ model }: SceneComponentProps) { + const dashboard = getDashboardSceneFor(model); + const styles = useStyles2(getStyles); + + const { isSharingExternally } = model.useState(); + + const dashboardJson = useAsync(async () => { + const json = await model.getExportableDashboardJson(); + return JSON.stringify(json, null, 2); + }, [isSharingExternally]); + + const switchLabel = t('export.json.export-externally-label', 'Export the dashboard to use in another instance'); + + return ( + <> +

+ + Copy or download a JSON file containing the JSON of your dashboard + +

+ + + + + + {({ width }) => { + if (dashboardJson.value) { + return ( + + ); + } + + return dashboardJson.loading && ; + }} + +
+ + + dashboardJson.value ?? ''} + > + Copy to clipboard + + + +
+ + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + codeEditorBox: css({ + margin: `${theme.spacing(2)} 0`, + }), + container: css({ + paddingBottom: theme.spacing(2), + }), + }; +} diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.test.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.test.tsx new file mode 100644 index 00000000000..4bc553b457c --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes'; + +import { DashboardGridItem } from '../../scene/DashboardGridItem'; +import { DashboardScene } from '../../scene/DashboardScene'; + +import ExportButton from './ExportButton'; + +const selector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton; + +describe('ExportButton', () => { + it('should render Export menu', async () => { + setup(); + expect(await screen.findByTestId(selector.arrowMenu)).toBeInTheDocument(); + }); + + it('should render menu when arrow button clicked', async () => { + setup(); + + const arrowMenu = await screen.findByTestId(selector.arrowMenu); + await userEvent.click(arrowMenu); + + expect(await screen.findByTestId(selector.Menu.container)).toBeInTheDocument(); + }); +}); + +function setup() { + const panel = new VizPanel({ + title: 'Panel A', + pluginId: 'table', + key: 'panel-12', + }); + + const dashboard = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + $timeRange: new SceneTimeRange({}), + body: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: panel, + }), + ], + }), + }); + + render(); +} diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx new file mode 100644 index 00000000000..52ec254b2d7 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx @@ -0,0 +1,38 @@ +import { useCallback, useState } from 'react'; + +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { Button, ButtonGroup, Dropdown, Icon } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +import { DashboardScene } from '../../scene/DashboardScene'; + +import ExportMenu from './ExportMenu'; + +const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton; + +export default function ExportButton({ dashboard }: { dashboard: DashboardScene }) { + const [isOpen, setIsOpen] = useState(false); + + const onMenuClick = useCallback((isOpen: boolean) => { + setIsOpen(isOpen); + }, []); + + const MenuActions = () => ; + + return ( + + + + + + ); +} diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.test.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.test.tsx new file mode 100644 index 00000000000..38045ba05b0 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react'; + +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes'; + +import { DashboardGridItem } from '../../scene/DashboardGridItem'; +import { DashboardScene } from '../../scene/DashboardScene'; + +import ExportMenu from './ExportMenu'; + +const selector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton.Menu; + +describe('ExportMenu', () => { + it('should render menu items', async () => { + setup(); + expect(await screen.findByTestId(selector.exportAsJson)).toBeInTheDocument(); + }); +}); + +function setup() { + const panel = new VizPanel({ + title: 'Panel A', + pluginId: 'table', + key: 'panel-12', + }); + const dashboard = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + $timeRange: new SceneTimeRange({}), + body: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: panel, + }), + ], + }), + }); + render(); +} diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx new file mode 100644 index 00000000000..85bcb6c7749 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx @@ -0,0 +1,32 @@ +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { Menu } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { DashboardScene } from '../../scene/DashboardScene'; +import { ShareDrawer } from '../ShareDrawer/ShareDrawer'; + +import { ExportAsJson } from './ExportAsJson'; + +const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton.Menu; + +export default function ExportMenu({ dashboard }: { dashboard: DashboardScene }) { + const onExportAsJsonClick = () => { + const drawer = new ShareDrawer({ + title: t('export.json.title', 'Save dashboard JSON'), + body: new ExportAsJson({}), + }); + + dashboard.showModal(drawer); + }; + + return ( + + + + ); +} diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx index a56428878da..746caf6b1ce 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx @@ -16,7 +16,7 @@ import { getDashboardSceneFor } from '../utils/utils'; import { SceneShareTabState } from './types'; -interface ShareExportTabState extends SceneShareTabState { +export interface ShareExportTabState extends SceneShareTabState { isSharingExternally?: boolean; isViewingJSON?: boolean; } @@ -55,7 +55,7 @@ export class ShareExportTab extends SceneObjectBase { return; } - public async getExportableDashboardJson() { + public getExportableDashboardJson = async () => { const { isSharingExternally } = this.state; const saveModel = transformSceneToSaveModel(getDashboardSceneFor(this)); @@ -70,9 +70,9 @@ export class ShareExportTab extends SceneObjectBase { : saveModel; return exportable; - } + }; - public async onSaveAsFile() { + public onSaveAsFile = async () => { const dashboardJson = await this.getExportableDashboardJson(); const dashboardJsonPretty = JSON.stringify(dashboardJson, null, 2); const { isSharingExternally } = this.state; @@ -90,7 +90,7 @@ export class ShareExportTab extends SceneObjectBase { DashboardInteractions.exportDownloadJsonClicked({ externally: isSharingExternally, }); - } + }; } function ShareExportTabRenderer({ model }: SceneComponentProps) { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 84d88cce4f2..70a894d96fd 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -686,6 +686,20 @@ "split-widen": "Widen pane" } }, + "export": { + "json": { + "cancel-button": "Cancel", + "copy-button": "Copy to clipboard", + "download-button": "Download file", + "export-externally-label": "Export the dashboard to use in another instance", + "info-text": "Copy or download a JSON file containing the JSON of your dashboard", + "title": "Save dashboard JSON" + }, + "menu": { + "export-as-json-label": "Export", + "export-as-json-tooltip": "Export" + } + }, "folder-picker": { "loading": "Loading folders..." }, @@ -1729,6 +1743,7 @@ }, "share-dashboard": { "menu": { + "export-json-title": "Export as JSON", "share-externally-title": "Share externally", "share-internally-description": "Advanced settings", "share-internally-title": "Share internally", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 46369331744..3d992a19570 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -686,6 +686,20 @@ "split-widen": "Ŵįđęʼn päʼnę" } }, + "export": { + "json": { + "cancel-button": "Cäʼnčęľ", + "copy-button": "Cőpy ŧő čľįpþőäřđ", + "download-button": "Đőŵʼnľőäđ ƒįľę", + "export-externally-label": "Ēχpőřŧ ŧĥę đäşĥþőäřđ ŧő ūşę įʼn äʼnőŧĥęř įʼnşŧäʼnčę", + "info-text": "Cőpy őř đőŵʼnľőäđ ä ĴŜØŃ ƒįľę čőʼnŧäįʼnįʼnģ ŧĥę ĴŜØŃ őƒ yőūř đäşĥþőäřđ", + "title": "Ŝävę đäşĥþőäřđ ĴŜØŃ" + }, + "menu": { + "export-as-json-label": "Ēχpőřŧ", + "export-as-json-tooltip": "Ēχpőřŧ" + } + }, "folder-picker": { "loading": "Ŀőäđįʼnģ ƒőľđęřş..." }, @@ -1729,6 +1743,7 @@ }, "share-dashboard": { "menu": { + "export-json-title": "Ēχpőřŧ äş ĴŜØŃ", "share-externally-title": "Ŝĥäřę ęχŧęřʼnäľľy", "share-internally-description": "Åđväʼnčęđ şęŧŧįʼnģş", "share-internally-title": "Ŝĥäřę įʼnŧęřʼnäľľy",