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 <juan.cabanas@grafana.com>

* 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 <juan.cabanas@grafana.com>
This commit is contained in:
Lucy Chen 2024-06-27 15:36:23 -04:00 committed by Ryan McKinley
parent 79092ebc6a
commit 058538287f
11 changed files with 349 additions and 6 deletions

View File

@ -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: {

View File

@ -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', () => {

View File

@ -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: () => <ExportButton key="new-export-dashboard-button" dashboard={dashboard} />,
});
toolbarActions.push({
group: 'new-share-dashboard-buttons',
condition: config.featureToggles.newDashboardSharingComponent && showShareButton,
render: () => <ShareButton key="new-share-dashboard-button" dashboard={dashboard} />,
});

View File

@ -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<ExportAsJson>) {
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 (
<>
<p>
<Trans i18nKey="export.json.info-text">
Copy or download a JSON file containing the JSON of your dashboard
</Trans>
</p>
<Stack gap={1} alignItems="center">
<Switch
label={switchLabel}
data-testid={selector.exportExternallyToggle}
id="export-externally-toggle"
value={isSharingExternally}
onChange={model.onShareExternallyChange}
/>
<Label>{switchLabel}</Label>
</Stack>
<AutoSizer disableHeight className={styles.codeEditorBox} data-testid={selector.codeEditor}>
{({ width }) => {
if (dashboardJson.value) {
return (
<CodeEditor
value={dashboardJson.value}
language="json"
showMiniMap={false}
height="500px"
width={width}
readOnly={true}
/>
);
}
return dashboardJson.loading && <Spinner />;
}}
</AutoSizer>
<div className={styles.container}>
<Stack gap={1} flex={1} direction={{ xs: 'column', sm: 'row' }}>
<Button
data-testid={selector.saveToFileButton}
variant="primary"
icon="download-alt"
onClick={model.onSaveAsFile}
>
<Trans i18nKey="export.json.download-button">Download file</Trans>
</Button>
<ClipboardButton
data-testid={selector.copyToClipboardButton}
variant="secondary"
icon="copy"
disabled={dashboardJson.loading}
getText={() => dashboardJson.value ?? ''}
>
<Trans i18nKey="export.json.copy-button">Copy to clipboard</Trans>
</ClipboardButton>
<Button
data-testid={selector.cancelButton}
variant="secondary"
onClick={() => dashboard.closeModal()}
fill="outline"
>
<Trans i18nKey="export.json.cancel-button">Cancel</Trans>
</Button>
</Stack>
</div>
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
codeEditorBox: css({
margin: `${theme.spacing(2)} 0`,
}),
container: css({
paddingBottom: theme.spacing(2),
}),
};
}

View File

@ -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(<ExportButton dashboard={dashboard} />);
}

View File

@ -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 = () => <ExportMenu dashboard={dashboard} />;
return (
<ButtonGroup data-testid={newExportButtonSelector.container}>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={onMenuClick}>
<Button
data-testid={newExportButtonSelector.arrowMenu}
size="sm"
variant="secondary"
fill="solid"
tooltip={t('export.menu.export-as-json-tooltip', 'Export')}
>
<Trans i18nKey="export.menu.export-as-json-label">Export</Trans>&nbsp;
<Icon name={isOpen ? 'angle-up' : 'angle-down'} size="lg" />
</Button>
</Dropdown>
</ButtonGroup>
);
}

View File

@ -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(<ExportMenu dashboard={dashboard} />);
}

View File

@ -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 (
<Menu data-testid={newExportButtonSelector.container}>
<Menu.Item
testId={newExportButtonSelector.exportAsJson}
label={t('share-dashboard.menu.export-json-title', 'Export as JSON')}
icon="arrow"
onClick={onExportAsJsonClick}
/>
</Menu>
);
}

View File

@ -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<ShareExportTabState> {
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<ShareExportTabState> {
: 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<ShareExportTabState> {
DashboardInteractions.exportDownloadJsonClicked({
externally: isSharingExternally,
});
}
};
}
function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>) {

View File

@ -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",

View File

@ -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",