Sharing: Export dashboard as image (#104207)
Co-authored-by: AgnesToulet <35176601+AgnesToulet@users.noreply.github.com>
This commit is contained in:
co-authored by
AgnesToulet
parent
a977aa9d03
commit
b691b3288d
@@ -13,7 +13,7 @@ import (
|
||||
// schema is unexported to prevent accidental overwrites
|
||||
var (
|
||||
schemaReceiver = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &Receiver{}, &ReceiverList{}, resource.WithKind("Receiver"),
|
||||
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
|
||||
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
|
||||
FieldSelector: "spec.title",
|
||||
FieldValueFunc: func(o resource.Object) (string, error) {
|
||||
cast, ok := o.(*Receiver)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// schema is unexported to prevent accidental overwrites
|
||||
var (
|
||||
schemaTemplateGroup = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TemplateGroup{}, &TemplateGroupList{}, resource.WithKind("TemplateGroup"),
|
||||
resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
|
||||
resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
|
||||
FieldSelector: "spec.title",
|
||||
FieldValueFunc: func(o resource.Object) (string, error) {
|
||||
cast, ok := o.(*TemplateGroup)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// schema is unexported to prevent accidental overwrites
|
||||
var (
|
||||
schemaTimeInterval = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TimeInterval{}, &TimeIntervalList{}, resource.WithKind("TimeInterval"),
|
||||
resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
|
||||
resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
|
||||
FieldSelector: "spec.name",
|
||||
FieldValueFunc: func(o resource.Object) (string, error) {
|
||||
cast, ok := o.(*TimeInterval)
|
||||
|
||||
@@ -993,6 +993,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
alertingImportAlertmanagerAPI?: boolean;
|
||||
/**
|
||||
* Enables image sharing functionality for dashboards
|
||||
*/
|
||||
sharingDashboardImage?: boolean;
|
||||
/**
|
||||
* Prefer library panel title over viz panel title.
|
||||
* @default false
|
||||
*/
|
||||
|
||||
@@ -1362,6 +1362,63 @@ export const versionedComponents = {
|
||||
'11.5.0': 'data-testid portal-container',
|
||||
},
|
||||
},
|
||||
ExportImage: {
|
||||
formatOptions: {
|
||||
container: {
|
||||
['12.1.0']: 'data-testid export-image-format-options',
|
||||
},
|
||||
png: {
|
||||
['12.1.0']: 'data-testid export-image-format-png',
|
||||
},
|
||||
jpg: {
|
||||
['12.1.0']: 'data-testid export-image-format-jpg',
|
||||
},
|
||||
},
|
||||
rendererAlert: {
|
||||
container: {
|
||||
['12.1.0']: 'data-testid export-image-renderer-alert',
|
||||
},
|
||||
title: {
|
||||
['12.1.0']: 'data-testid export-image-renderer-alert-title',
|
||||
},
|
||||
description: {
|
||||
['12.1.0']: 'data-testid export-image-renderer-alert-description',
|
||||
},
|
||||
},
|
||||
buttons: {
|
||||
generate: {
|
||||
['12.1.0']: 'data-testid export-image-generate-button',
|
||||
},
|
||||
download: {
|
||||
['12.1.0']: 'data-testid export-image-download-button',
|
||||
},
|
||||
cancel: {
|
||||
['12.1.0']: 'data-testid export-image-cancel-button',
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
container: {
|
||||
['12.1.0']: 'data-testid export-image-preview-container',
|
||||
},
|
||||
loading: {
|
||||
['12.1.0']: 'data-testid export-image-preview-loading',
|
||||
},
|
||||
image: {
|
||||
['12.1.0']: 'data-testid export-image-preview',
|
||||
},
|
||||
error: {
|
||||
container: {
|
||||
['12.1.0']: 'data-testid export-image-error',
|
||||
},
|
||||
title: {
|
||||
['12.1.0']: 'data-testid export-image-error-title',
|
||||
},
|
||||
message: {
|
||||
['12.1.0']: 'data-testid export-image-error-message',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies VersionedSelectorGroup;
|
||||
|
||||
export type VersionedComponents = typeof versionedComponents;
|
||||
|
||||
@@ -227,6 +227,9 @@ export const versionedPages = {
|
||||
exportAsJson: {
|
||||
'11.2.0': 'data-testid new export button export as json',
|
||||
},
|
||||
exportAsImage: {
|
||||
'12.1.0': 'data-testid new export button export as image',
|
||||
},
|
||||
},
|
||||
},
|
||||
playlistControls: {
|
||||
|
||||
@@ -1705,6 +1705,14 @@ var (
|
||||
HideFromDocs: true,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "sharingDashboardImage",
|
||||
Description: "Enables image sharing functionality for dashboards",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSharingSquad,
|
||||
HideFromDocs: true,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "preferLibraryPanelTitle",
|
||||
Description: "Prefer library panel title over viz panel title.",
|
||||
|
||||
@@ -222,6 +222,7 @@ restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,fa
|
||||
skipTokenRotationIfRecent,GA,@grafana/identity-access-team,false,false,false
|
||||
alertEnrichment,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertingImportAlertmanagerAPI,experimental,@grafana/alerting-squad,false,false,false
|
||||
sharingDashboardImage,experimental,@grafana/sharing-squad,false,false,true
|
||||
preferLibraryPanelTitle,privatePreview,@grafana/dashboards-squad,false,false,false
|
||||
tabularNumbers,GA,@grafana/grafana-frontend-platform,false,false,false
|
||||
newInfluxDSConfigPageDesign,privatePreview,@grafana/partner-datasources,false,false,false
|
||||
|
||||
|
@@ -899,6 +899,10 @@ const (
|
||||
// Enables the API to import Alertmanager configuration
|
||||
FlagAlertingImportAlertmanagerAPI = "alertingImportAlertmanagerAPI"
|
||||
|
||||
// FlagSharingDashboardImage
|
||||
// Enables image sharing functionality for dashboards
|
||||
FlagSharingDashboardImage = "sharingDashboardImage"
|
||||
|
||||
// FlagPreferLibraryPanelTitle
|
||||
// Prefer library panel title over viz panel title.
|
||||
FlagPreferLibraryPanelTitle = "preferLibraryPanelTitle"
|
||||
|
||||
@@ -2815,6 +2815,20 @@
|
||||
"codeowner": "@grafana/grafana-operator-experience-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "sharingDashboardImage",
|
||||
"resourceVersion": "1747090555040",
|
||||
"creationTimestamp": "2025-05-12T22:55:55Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables image sharing functionality for dashboards",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/sharing-squad",
|
||||
"frontend": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "showDashboardValidationWarnings",
|
||||
|
||||
@@ -151,7 +151,7 @@ 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);
|
||||
const newExportButton = screen.queryByRole('button', { name: /export dashboard/i });
|
||||
expect(newExportButton).not.toBeInTheDocument();
|
||||
});
|
||||
it('Should show new share button when newDashboardSharingComponent FF is enabled', async () => {
|
||||
@@ -165,7 +165,7 @@ describe('NavToolbarActions', () => {
|
||||
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);
|
||||
const newExportButton = screen.getByRole('button', { name: /export dashboard/i });
|
||||
expect(newExportButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { useEffect } from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
|
||||
import { Alert, Button, useStyles2 } from '@grafana/ui';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
import { getDashboardSceneFor } from 'app/features/dashboard-scene/utils/utils';
|
||||
|
||||
import { ImagePreview } from '../components/ImagePreview';
|
||||
import { SceneShareTabState, ShareView } from '../types';
|
||||
|
||||
import { generateDashboardImage } from './utils';
|
||||
|
||||
export class ExportAsImage extends SceneObjectBase<SceneShareTabState> implements ShareView {
|
||||
static Component = ExportAsImageRenderer;
|
||||
|
||||
public getTabLabel() {
|
||||
return t('share-modal.image.title', 'Export as image');
|
||||
}
|
||||
}
|
||||
|
||||
function ExportAsImageRenderer({ model }: SceneComponentProps<ExportAsImage>) {
|
||||
const { onDismiss } = model.useState();
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [{ loading: isLoading, value: imageBlob, error }, onExport] = useAsyncFn(async () => {
|
||||
try {
|
||||
const result = await generateDashboardImage({
|
||||
dashboard,
|
||||
scale: config.rendererDefaultImageScale || 1,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
DashboardInteractions.generateDashboardImageClicked({
|
||||
scale: config.rendererDefaultImageScale || 1,
|
||||
shareResource: 'dashboard',
|
||||
success: true,
|
||||
});
|
||||
|
||||
return result.blob;
|
||||
} catch (error) {
|
||||
console.error('Error exporting image:', error);
|
||||
DashboardInteractions.generateDashboardImageClicked({
|
||||
scale: config.rendererDefaultImageScale || 1,
|
||||
shareResource: 'dashboard',
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to generate image',
|
||||
});
|
||||
throw error; // Re-throw to let useAsyncFn handle the error state
|
||||
}
|
||||
}, [dashboard]);
|
||||
|
||||
// Clean up object URLs when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageBlob) {
|
||||
URL.revokeObjectURL(URL.createObjectURL(imageBlob));
|
||||
}
|
||||
};
|
||||
}, [imageBlob]);
|
||||
|
||||
const onDownload = () => {
|
||||
if (!imageBlob) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = new Date().getTime();
|
||||
const name = dashboard.state.title;
|
||||
saveAs(imageBlob, `${name}-${time}.png`);
|
||||
|
||||
DashboardInteractions.downloadDashboardImageClicked({
|
||||
fileName: `${name}-${time}.png`,
|
||||
shareResource: 'dashboard',
|
||||
});
|
||||
};
|
||||
|
||||
if (!config.rendererAvailable) {
|
||||
return <RendererAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className={styles.info}>
|
||||
<Trans i18nKey="share-modal.image.info-text">Save this dashboard as an image</Trans>
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={styles.buttonRow}
|
||||
role="group"
|
||||
aria-label={t('share-modal.image.actions', 'Image export actions')}
|
||||
>
|
||||
{!imageBlob ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onExport}
|
||||
disabled={isLoading}
|
||||
icon="gf-layout-simple"
|
||||
aria-describedby={isLoading ? 'generate-status' : undefined}
|
||||
>
|
||||
<Trans i18nKey="share-modal.image.generate-button">Generate image</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={onDownload} icon="download-alt">
|
||||
<Trans i18nKey="share-modal.image.download-button">Download image</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={onDismiss} fill="outline">
|
||||
<Trans i18nKey="share-modal.image.cancel-button">Cancel</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div id="generate-status" aria-live="polite" className="sr-only">
|
||||
<Trans i18nKey="share-modal.image.generating">Generating image...</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImagePreview
|
||||
imageBlob={imageBlob || null}
|
||||
isLoading={isLoading}
|
||||
error={
|
||||
error
|
||||
? {
|
||||
title: t('share-modal.image.error-title', 'Failed to generate image'),
|
||||
message: error instanceof Error ? error.message : 'Failed to generate image',
|
||||
}
|
||||
: null
|
||||
}
|
||||
title={dashboard.state.title}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function RendererAlert() {
|
||||
if (config.rendererAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert severity="info" title={t('share-modal.link.render-alert', 'Image renderer plugin not installed')}>
|
||||
<div>{t('share-modal.link.render-alert', 'Image renderer plugin not installed')}</div>
|
||||
<div>
|
||||
<Trans i18nKey="share-modal.link.render-instructions">
|
||||
To render an image, you must install the{' '}
|
||||
<a
|
||||
href="https://grafana.com/grafana/plugins/grafana-image-renderer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="external-link"
|
||||
>
|
||||
Grafana image renderer plugin
|
||||
</a>
|
||||
. Please contact your Grafana administrator to install the plugin.
|
||||
</Trans>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
info: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
buttonRow: css({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(2),
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
@@ -9,21 +8,32 @@ import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGrid
|
||||
|
||||
import ExportButton from './ExportButton';
|
||||
|
||||
const selector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton;
|
||||
|
||||
describe('ExportButton', () => {
|
||||
it('should render Export menu', async () => {
|
||||
it('should render Export button', async () => {
|
||||
setup();
|
||||
expect(await screen.findByTestId(selector.arrowMenu)).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: /export dashboard/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render menu when arrow button clicked', async () => {
|
||||
it('should show menu when export button is clicked', async () => {
|
||||
setup();
|
||||
|
||||
const arrowMenu = await screen.findByTestId(selector.arrowMenu);
|
||||
await userEvent.click(arrowMenu);
|
||||
const exportButton = await screen.findByRole('button', { name: /export dashboard/i });
|
||||
expect(exportButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
expect(await screen.findByTestId(selector.Menu.container)).toBeInTheDocument();
|
||||
await userEvent.click(exportButton);
|
||||
|
||||
expect(exportButton).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show export options in the menu', async () => {
|
||||
setup();
|
||||
|
||||
const exportButton = await screen.findByRole('button', { name: /export dashboard/i });
|
||||
await userEvent.click(exportButton);
|
||||
|
||||
// Should show JSON export option
|
||||
expect(await screen.findByRole('menuitem', { name: /export as json/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import { DashboardScene } from '../../scene/DashboardScene';
|
||||
|
||||
import ExportMenu from './ExportMenu';
|
||||
|
||||
const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton;
|
||||
|
||||
interface Props {
|
||||
dashboard: DashboardScene;
|
||||
}
|
||||
@@ -24,17 +22,20 @@ export default function ExportButton({ dashboard }: Props) {
|
||||
const MenuActions = () => <ExportMenu dashboard={dashboard} />;
|
||||
|
||||
return (
|
||||
<ButtonGroup data-testid={newExportButtonSelector.container}>
|
||||
<ButtonGroup>
|
||||
<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')}
|
||||
aria-label={t('dashboard.export.button.label', 'Export dashboard')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
data-testid={e2eSelectors.pages.Dashboard.DashNav.NewExportButton.arrowMenu}
|
||||
>
|
||||
<Trans i18nKey="export.menu.export-as-json-label">Export</Trans>
|
||||
<Icon name={isOpen ? 'angle-up' : 'angle-down'} size="sm" />
|
||||
<Icon name={isOpen ? 'angle-up' : 'angle-down'} size="sm" aria-hidden="true" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
@@ -8,12 +8,24 @@ import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGrid
|
||||
|
||||
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();
|
||||
expect(await screen.findByRole('menuitem', { name: /export as json/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('sharingDashboardImage feature toggle', () => {
|
||||
it('should render image export option when enabled', async () => {
|
||||
config.featureToggles.sharingDashboardImage = true;
|
||||
setup();
|
||||
expect(await screen.findByRole('menuitem', { name: /export as image/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render image export option when disabled', async () => {
|
||||
config.featureToggles.sharingDashboardImage = false;
|
||||
setup();
|
||||
expect(screen.queryByRole('menuitem', { name: /export as image/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,11 +35,13 @@ function setup() {
|
||||
pluginId: 'table',
|
||||
key: 'panel-12',
|
||||
});
|
||||
|
||||
const dashboard = new DashboardScene({
|
||||
title: 'hello',
|
||||
uid: 'dash-1',
|
||||
$timeRange: new SceneTimeRange({}),
|
||||
body: DefaultGridLayoutManager.fromVizPanels([panel]),
|
||||
});
|
||||
|
||||
render(<ExportMenu dashboard={dashboard} />);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportBu
|
||||
|
||||
export interface ExportDrawerMenuItem {
|
||||
shareId: string;
|
||||
testId: string;
|
||||
testId?: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: IconName;
|
||||
@@ -50,6 +50,14 @@ export default function ExportMenu({ dashboard }: { dashboard: DashboardScene })
|
||||
onClick: () => onMenuItemClick(shareDashboardType.export),
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
shareId: shareDashboardType.image,
|
||||
icon: 'camera',
|
||||
label: t('share-dashboard.menu.export-image-title', 'Export as image'),
|
||||
renderCondition: Boolean(config.featureToggles.sharingDashboardImage),
|
||||
onClick: () => onMenuItemClick(shareDashboardType.image),
|
||||
});
|
||||
|
||||
return menuItems.filter((item) => item.renderCondition);
|
||||
}, []);
|
||||
|
||||
@@ -63,15 +71,18 @@ export default function ExportMenu({ dashboard }: { dashboard: DashboardScene })
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu data-testid={newExportButtonSelector.container}>
|
||||
<Menu
|
||||
ariaLabel={t('dashboard.export.menu.label', 'Export dashboard menu')}
|
||||
data-testid={newExportButtonSelector.container}
|
||||
>
|
||||
{buildMenuItems().map((item) => (
|
||||
<Menu.Item
|
||||
key={item.label}
|
||||
testId={item.testId}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
description={item.description}
|
||||
onClick={() => onClick(item)}
|
||||
testId={item.testId}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
|
||||
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
|
||||
import { generateDashboardImage } from './utils';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
config: {
|
||||
rendererAvailable: true,
|
||||
bootData: {
|
||||
user: {
|
||||
orgId: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
getBackendSrv: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('app/features/dashboard-scene/utils/getDashboardUrl', () => ({
|
||||
getDashboardUrl: jest
|
||||
.fn()
|
||||
.mockImplementation((params: { updateQuery?: Record<string, string | number | boolean> }) => {
|
||||
const url = new URL('http://test-url');
|
||||
if (params.updateQuery) {
|
||||
Object.entries(params.updateQuery).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Dashboard Export Image Utils', () => {
|
||||
describe('generateDashboardImage', () => {
|
||||
it('should handle various error scenarios', async () => {
|
||||
const testCases = [
|
||||
{
|
||||
setup: () => {
|
||||
config.rendererAvailable = false;
|
||||
// Reset the mock for this test case
|
||||
(getBackendSrv as jest.Mock).mockReset();
|
||||
},
|
||||
expectedError: 'Image renderer plugin not installed',
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
config.rendererAvailable = true;
|
||||
(getBackendSrv as jest.Mock).mockReturnValue({
|
||||
fetch: jest.fn().mockReturnValue(of({ ok: false, status: 500, statusText: 'Server Error' })),
|
||||
});
|
||||
},
|
||||
expectedError: 'Failed to generate image: 500 Server Error',
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
config.rendererAvailable = true;
|
||||
(getBackendSrv as jest.Mock).mockReturnValue({
|
||||
fetch: jest.fn().mockReturnValue(of({ ok: true, data: 'invalid-data' })),
|
||||
});
|
||||
},
|
||||
expectedError: 'Invalid response data format',
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
config.rendererAvailable = true;
|
||||
(getBackendSrv as jest.Mock).mockReturnValue({
|
||||
fetch: jest.fn().mockReturnValue(of(Promise.reject(new Error('Network error')))),
|
||||
});
|
||||
},
|
||||
expectedError: 'Network error',
|
||||
},
|
||||
];
|
||||
|
||||
const dashboard = {
|
||||
state: {
|
||||
uid: 'test-uid',
|
||||
},
|
||||
} as DashboardScene;
|
||||
|
||||
for (const testCase of testCases) {
|
||||
testCase.setup();
|
||||
const result = await generateDashboardImage({ dashboard });
|
||||
expect(result.error).toBe(testCase.expectedError);
|
||||
expect(result.blob).toBeInstanceOf(Blob);
|
||||
expect(result.blob.size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate image successfully with custom scale', async () => {
|
||||
config.rendererAvailable = true;
|
||||
const mockBlob = new Blob(['test'], { type: 'image/png' });
|
||||
const fetchMock = jest.fn().mockReturnValue(of({ ok: true, data: mockBlob }));
|
||||
(getBackendSrv as jest.Mock).mockReturnValue({ fetch: fetchMock });
|
||||
|
||||
const dashboard = {
|
||||
state: {
|
||||
uid: 'test-uid',
|
||||
},
|
||||
} as DashboardScene;
|
||||
|
||||
const result = await generateDashboardImage({ dashboard, scale: 2 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.blob).toBe(mockBlob);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringMatching(/height=-1.*scale=2.*kiosk=true.*hideNav=true.*fullPageImage=true/),
|
||||
responseType: 'blob',
|
||||
})
|
||||
);
|
||||
expect(getDashboardUrl).toHaveBeenCalledWith({
|
||||
uid: 'test-uid',
|
||||
currentQueryParams: '',
|
||||
render: true,
|
||||
absolute: true,
|
||||
updateQuery: {
|
||||
height: -1,
|
||||
width: 1000,
|
||||
scale: 2,
|
||||
kiosk: true,
|
||||
hideNav: true,
|
||||
orgId: '1',
|
||||
fullPageImage: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
|
||||
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
|
||||
/**
|
||||
* Options for generating a dashboard image
|
||||
*/
|
||||
export interface ImageGenerationOptions {
|
||||
dashboard: DashboardScene;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of image generation attempt
|
||||
*/
|
||||
export interface ImageGenerationResult {
|
||||
blob: Blob;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a dashboard image using the renderer service
|
||||
* @param options The options for image generation
|
||||
* @returns A promise that resolves to the image generation result
|
||||
*/
|
||||
export async function generateDashboardImage({
|
||||
dashboard,
|
||||
scale = config.rendererDefaultImageScale || 1,
|
||||
}: ImageGenerationOptions): Promise<ImageGenerationResult> {
|
||||
try {
|
||||
// Check if renderer plugin is available
|
||||
if (!config.rendererAvailable) {
|
||||
return {
|
||||
blob: new Blob(),
|
||||
error: 'Image renderer plugin not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const imageUrl = getDashboardUrl({
|
||||
uid: dashboard.state.uid,
|
||||
currentQueryParams: window.location.search,
|
||||
render: true,
|
||||
absolute: true,
|
||||
updateQuery: {
|
||||
height: -1, // image renderer will scroll through the dashboard and set the appropriate height
|
||||
width: config.rendererDefaultImageWidth || 1000,
|
||||
scale,
|
||||
kiosk: true,
|
||||
hideNav: true,
|
||||
orgId: String(config.bootData.user.orgId),
|
||||
fullPageImage: true,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await lastValueFrom(
|
||||
getBackendSrv().fetch<Blob>({
|
||||
url: imageUrl,
|
||||
responseType: 'blob',
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
blob: new Blob(),
|
||||
error: `Failed to generate image: ${response.status} ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate response data format
|
||||
if (!(response.data instanceof Blob)) {
|
||||
return {
|
||||
blob: new Blob(),
|
||||
error: 'Invalid response data format',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
blob: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
blob: new Blob(),
|
||||
error: error instanceof Error && error.message ? error.message : 'Failed to generate image',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { shareDashboardType } from '../../../dashboard/components/ShareModal/uti
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
import { ExportAsCode } from '../ExportButton/ExportAsCode';
|
||||
import { ExportAsImage } from '../ExportButton/ExportAsImage';
|
||||
import { ShareExternally } from '../ShareButton/share-externally/ShareExternally';
|
||||
import { ShareInternally } from '../ShareButton/share-internally/ShareInternally';
|
||||
import { ShareSnapshot } from '../ShareButton/share-snapshot/ShareSnapshot';
|
||||
@@ -93,6 +94,8 @@ function getShareView(
|
||||
return new ShareSnapshot({ dashboardRef, panelRef, onDismiss });
|
||||
case shareDashboardType.export:
|
||||
return new ExportAsCode({ onDismiss });
|
||||
case shareDashboardType.image:
|
||||
return new ExportAsImage({ onDismiss });
|
||||
default:
|
||||
return new ShareInternally({ onDismiss });
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function ShareInternallyConfiguration({
|
||||
'link.share.time-range-description',
|
||||
'Change the current relative time range to an absolute time range'
|
||||
)}
|
||||
id="time-range-description"
|
||||
>
|
||||
<Trans i18nKey="link.share.time-range-label">Lock time range</Trans>
|
||||
</Label>
|
||||
|
||||
@@ -214,7 +214,7 @@ function ShareLinkTabRenderer({ model }: SceneComponentProps<ShareLinkTab>) {
|
||||
bottomSpacing={0}
|
||||
>
|
||||
<Trans i18nKey="share-modal.link.render-instructions">
|
||||
To render a panel image, you must install the{' '}
|
||||
To render an image, you must install the{' '}
|
||||
<a
|
||||
href="https://grafana.com/grafana/plugins/grafana-image-renderer"
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
|
||||
import { ImagePreview } from './ImagePreview';
|
||||
|
||||
// Mock URL.createObjectURL and URL.revokeObjectURL
|
||||
const mockCreateObjectURL = jest.fn();
|
||||
const mockRevokeObjectURL = jest.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
global.URL.createObjectURL = mockCreateObjectURL;
|
||||
global.URL.revokeObjectURL = mockRevokeObjectURL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockCreateObjectURL.mockClear();
|
||||
mockRevokeObjectURL.mockClear();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('ImagePreview', () => {
|
||||
const defaultProps = {
|
||||
imageBlob: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
it('should render empty container when no image, loading, or error', () => {
|
||||
render(<ImagePreview {...defaultProps} />);
|
||||
// Container should exist with proper role and label
|
||||
expect(screen.getByRole('region', { name: 'Image preview' })).toBeInTheDocument();
|
||||
// Loading bar should not be visible
|
||||
expect(screen.queryByLabelText('Loading bar')).not.toBeInTheDocument();
|
||||
// Image should not be visible
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
// Error alert should not be visible
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading state with title when loading', () => {
|
||||
render(<ImagePreview {...defaultProps} isLoading={true} title="Test Title" />);
|
||||
// Loading state should be announced properly
|
||||
expect(screen.getByRole('status', { name: 'Generating image...' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Loading bar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error state when error is present', () => {
|
||||
const error = {
|
||||
title: 'Error Title',
|
||||
message: 'Error Message',
|
||||
};
|
||||
render(<ImagePreview {...defaultProps} error={error} />);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Error Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Error Message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show image when imageBlob is present', () => {
|
||||
const imageBlob = new Blob(['test'], { type: 'image/png' });
|
||||
mockCreateObjectURL.mockReturnValue('mock-url');
|
||||
render(<ImagePreview {...defaultProps} imageBlob={imageBlob} />);
|
||||
const image = screen.getByRole('img', { name: 'Generated image preview' });
|
||||
expect(image).toBeInTheDocument();
|
||||
expect(image).toHaveAttribute('alt', 'Preview');
|
||||
expect(image).toHaveAttribute('src', 'mock-url');
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(imageBlob);
|
||||
});
|
||||
|
||||
it('should revoke object URL on unmount', () => {
|
||||
const imageBlob = new Blob(['test'], { type: 'image/png' });
|
||||
mockCreateObjectURL.mockReturnValue('mock-url');
|
||||
const { unmount } = render(<ImagePreview {...defaultProps} imageBlob={imageBlob} />);
|
||||
unmount();
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('mock-url');
|
||||
});
|
||||
|
||||
it('should not show image when loading', () => {
|
||||
const imageBlob = new Blob(['test'], { type: 'image/png' });
|
||||
render(<ImagePreview {...defaultProps} imageBlob={imageBlob} isLoading={true} />);
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
// Should show loading state instead
|
||||
expect(screen.getByRole('status', { name: 'Generating image...' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show error when loading', () => {
|
||||
const error = {
|
||||
title: 'Error Title',
|
||||
message: 'Error Message',
|
||||
};
|
||||
render(<ImagePreview {...defaultProps} error={error} isLoading={true} />);
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
// Should show loading state instead
|
||||
expect(screen.getByRole('status', { name: 'Generating image...' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show duplicate message when error title and message are the same', () => {
|
||||
const error = {
|
||||
title: 'Failed to generate image',
|
||||
message: 'Failed to generate image',
|
||||
};
|
||||
render(<ImagePreview {...defaultProps} error={error} />);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed to generate image')).toBeInTheDocument();
|
||||
// Check that the message doesn't appear twice by counting occurrences
|
||||
expect(screen.getAllByText('Failed to generate image')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Alert, LoadingBar, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
type ErrorState = {
|
||||
message: string;
|
||||
title: string;
|
||||
} | null;
|
||||
|
||||
interface ImagePreviewProps {
|
||||
imageBlob: Blob | null;
|
||||
isLoading: boolean;
|
||||
error: ErrorState;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ImagePreview({ imageBlob, isLoading, error, title }: ImagePreviewProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [ref, { width: measuredWidth }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
// Memoize and clean up the object URL for the image
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!imageBlob) {
|
||||
return undefined;
|
||||
}
|
||||
const url = URL.createObjectURL(imageBlob);
|
||||
return url;
|
||||
}, [imageBlob]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageUrl) {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
}
|
||||
};
|
||||
}, [imageUrl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.previewContainer}
|
||||
ref={ref}
|
||||
aria-label={t('share-modal.image.preview-region', 'Image preview')}
|
||||
role="region"
|
||||
>
|
||||
{isLoading && (
|
||||
<div
|
||||
className={styles.loadingBarContainer}
|
||||
role="status"
|
||||
aria-label={t('share-modal.image.generating', 'Generating image...')}
|
||||
>
|
||||
<LoadingBar width={measuredWidth} />
|
||||
{title && (
|
||||
<div className={styles.titleContainer}>
|
||||
<Text variant="body">{title}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && <ErrorAlert error={error} />}
|
||||
{!isLoading && imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={t('share-modal.image.preview', 'Preview')}
|
||||
className={styles.image}
|
||||
aria-label={t('share-modal.image.preview-aria', 'Generated image preview')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorAlert({ error }: { error: ErrorState }) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only show message if it's different from the title to avoid repetition
|
||||
const showMessage = error.message && error.message !== error.title;
|
||||
|
||||
return (
|
||||
<Alert severity="error" title={error.title}>
|
||||
{showMessage && <div>{error.message}</div>}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
previewContainer: css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '200px',
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
loadingBarContainer: css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}),
|
||||
titleContainer: css({
|
||||
padding: theme.spacing(1),
|
||||
}),
|
||||
image: css({
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}),
|
||||
});
|
||||
+27
-21
@@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config, setPluginImportUtils } from '@grafana/runtime';
|
||||
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
|
||||
|
||||
@@ -17,38 +16,45 @@ setPluginImportUtils({
|
||||
getPanelPluginFromCache: (id: string) => undefined,
|
||||
});
|
||||
|
||||
const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel;
|
||||
|
||||
describe('SharePanelInternally', () => {
|
||||
it('should disable all image generation inputs when renderer is not available', async () => {
|
||||
config.rendererAvailable = false;
|
||||
buildAndRenderScenario();
|
||||
|
||||
expect(await screen.findByTestId(selector.preview)).toBeInTheDocument();
|
||||
[
|
||||
selector.widthInput,
|
||||
selector.heightInput,
|
||||
selector.scaleFactorInput,
|
||||
selector.generateImageButton,
|
||||
selector.downloadImageButton,
|
||||
].forEach((selector) => {
|
||||
expect(screen.getByTestId(selector)).toBeDisabled();
|
||||
});
|
||||
// Check that the panel preview is rendered
|
||||
expect(await screen.findByText('Panel preview')).toBeInTheDocument();
|
||||
|
||||
// All inputs should be disabled - use placeholder text to find inputs
|
||||
expect(screen.getByPlaceholderText('1000')).toBeDisabled(); // Width input
|
||||
expect(screen.getByPlaceholderText('500')).toBeDisabled(); // Height input
|
||||
expect(screen.getByPlaceholderText('1')).toBeDisabled(); // Scale factor input
|
||||
expect(screen.getByRole('button', { name: /generate image/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /download image/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable all image generation inputs when renderer is available', async () => {
|
||||
config.rendererAvailable = true;
|
||||
buildAndRenderScenario();
|
||||
|
||||
expect(await screen.findByTestId(selector.preview)).toBeInTheDocument();
|
||||
[selector.widthInput, selector.heightInput, selector.scaleFactorInput].forEach((selector) => {
|
||||
expect(screen.getByTestId(selector)).toBeEnabled();
|
||||
});
|
||||
// Check that the panel preview is rendered
|
||||
expect(await screen.findByText('Panel preview')).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByTestId(selector.widthInput), '1000');
|
||||
await userEvent.type(screen.getByTestId(selector.widthInput), '2000');
|
||||
expect(screen.getByTestId(selector.generateImageButton)).toBeEnabled();
|
||||
expect(screen.getByTestId(selector.downloadImageButton)).toBeDisabled();
|
||||
// Form inputs should be enabled
|
||||
expect(screen.getByPlaceholderText('1000')).toBeEnabled(); // Width input
|
||||
expect(screen.getByPlaceholderText('500')).toBeEnabled(); // Height input
|
||||
expect(screen.getByPlaceholderText('1')).toBeEnabled(); // Scale factor input
|
||||
|
||||
// Test form interaction
|
||||
const widthInput = screen.getByPlaceholderText('1000');
|
||||
const heightInput = screen.getByPlaceholderText('500');
|
||||
|
||||
await userEvent.clear(widthInput);
|
||||
await userEvent.type(widthInput, '1000');
|
||||
await userEvent.clear(heightInput);
|
||||
await userEvent.type(heightInput, '2000');
|
||||
|
||||
expect(screen.getByRole('button', { name: /generate image/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /download image/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ function SharePanelInternallyRenderer({ model }: SceneComponentProps<SharePanelI
|
||||
bottomSpacing={0}
|
||||
>
|
||||
<Trans i18nKey="share-modal.link.render-instructions">
|
||||
To render a panel image, you must install the{' '}
|
||||
To render an image, you must install the{' '}
|
||||
<a
|
||||
href="https://grafana.com/grafana/plugins/grafana-image-renderer"
|
||||
target="_blank"
|
||||
|
||||
@@ -6,12 +6,12 @@ import { useAsyncFn } from 'react-use';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
|
||||
import { Alert, Button, Field, FieldSet, Icon, Input, LoadingBar, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { Button, Field, FieldSet, Icon, Input, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { DashboardInteractions } from '../../utils/interactions';
|
||||
import { ImagePreview } from '../components/ImagePreview';
|
||||
|
||||
type ImageSettingsForm = {
|
||||
width: number;
|
||||
@@ -27,8 +27,6 @@ type Props = {
|
||||
theme: string;
|
||||
};
|
||||
|
||||
const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel;
|
||||
|
||||
export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
@@ -78,12 +76,15 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid={selector.preview}>
|
||||
<section aria-labelledby="panel-preview-heading">
|
||||
<Stack gap={2} direction="column">
|
||||
<Text element="h4">
|
||||
<Text element="h4" id="panel-preview-heading">
|
||||
<Trans i18nKey="share-panel-image.preview.title">Panel preview</Trans>
|
||||
</Text>
|
||||
<form onSubmit={handleSubmit(onRenderImageClick)}>
|
||||
<form
|
||||
onSubmit={handleSubmit(onRenderImageClick)}
|
||||
aria-label={t('share-panel-image.form.label', 'Panel image settings')}
|
||||
>
|
||||
<FieldSet
|
||||
disabled={!config.rendererAvailable}
|
||||
label={
|
||||
@@ -123,7 +124,7 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
|
||||
placeholder={t('share-panel-image.settings.width-placeholder', '1000')}
|
||||
type="number"
|
||||
suffix="px"
|
||||
data-testid={selector.widthInput}
|
||||
aria-label={t('share-panel-image.settings.width-label', 'Width')}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -146,7 +147,7 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
|
||||
placeholder={t('share-panel-image.settings.height-placeholder', '500')}
|
||||
type="number"
|
||||
suffix="px"
|
||||
data-testid={selector.heightInput}
|
||||
aria-label={t('share-panel-image.settings.height-label', 'Height')}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -171,7 +172,7 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
|
||||
})}
|
||||
placeholder={t('share-panel-image.settings.scale-factor-placeholder', '1')}
|
||||
type="number"
|
||||
data-testid={selector.scaleFactorInput}
|
||||
aria-label={t('share-panel-image.settings.scale-factor-label', 'Scale factor')}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
@@ -182,7 +183,7 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
|
||||
fill="solid"
|
||||
type="submit"
|
||||
disabled={disabled || loading || !isValid}
|
||||
data-testid={selector.generateImageButton}
|
||||
aria-describedby={disabled ? 'generate-button-disabled-help' : undefined}
|
||||
>
|
||||
<Trans i18nKey="link.share-panel.render-image">Generate image</Trans>
|
||||
</Button>
|
||||
@@ -191,31 +192,38 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
|
||||
icon={'download-alt'}
|
||||
variant="secondary"
|
||||
disabled={!image || loading || disabled}
|
||||
data-testid={selector.downloadImageButton}
|
||||
aria-describedby={!image && !loading ? 'download-button-disabled-help' : undefined}
|
||||
>
|
||||
<Trans i18nKey="link.share-panel.download-image">Download image</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
{disabled && (
|
||||
<Text variant="bodySmall" color="secondary" id="generate-button-disabled-help">
|
||||
<Trans i18nKey="share-panel-image.disabled-help">Save the dashboard to enable image generation</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{!image && !loading && (
|
||||
<Text variant="bodySmall" color="secondary" id="download-button-disabled-help">
|
||||
<Trans i18nKey="share-panel-image.download-disabled-help">
|
||||
Generate an image first to enable download
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</FieldSet>
|
||||
</form>
|
||||
{loading && (
|
||||
<div>
|
||||
<LoadingBar width={128} />
|
||||
<div className={styles.imageLoadingContainer}>
|
||||
<Text variant="body">{title || ''}</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{image && !loading && <img src={URL.createObjectURL(image)} alt="panel-preview-img" className={styles.image} />}
|
||||
{error && !loading && (
|
||||
<Alert severity="error" title={t('link.share-panel.render-image-error', 'Failed to render panel image')}>
|
||||
{isFetchError(error)
|
||||
? error.statusText
|
||||
: t('link.share-panel.render-image-error-description', 'An error occurred when generating the image')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ImagePreview
|
||||
imageBlob={image || null}
|
||||
isLoading={loading}
|
||||
error={
|
||||
error
|
||||
? { title: t('share-panel-image.error-title', 'Failed to generate image'), message: error.message }
|
||||
: null
|
||||
}
|
||||
title={title}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,14 +231,4 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
imageConfigurationField: css({
|
||||
flex: 1,
|
||||
}),
|
||||
image: css({
|
||||
maxWidth: '100%',
|
||||
width: 'max-content',
|
||||
}),
|
||||
imageLoadingContainer: css({
|
||||
maxWidth: '100%',
|
||||
height: 362,
|
||||
border: `1px solid ${theme.components.input.borderColor}`,
|
||||
padding: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -124,6 +124,14 @@ export const DashboardInteractions = {
|
||||
showMoreVersionsClicked: () => {
|
||||
reportDashboardInteraction('show_more_versions_clicked');
|
||||
},
|
||||
|
||||
// Image export interactions
|
||||
generateDashboardImageClicked: (properties?: Record<string, unknown>) => {
|
||||
reportDashboardInteraction('dashboard_image_generated', properties);
|
||||
},
|
||||
downloadDashboardImageClicked: (properties?: Record<string, unknown>) => {
|
||||
reportDashboardInteraction('dashboard_image_downloaded', properties);
|
||||
},
|
||||
};
|
||||
|
||||
const reportDashboardInteraction: typeof reportInteraction = (name, properties) => {
|
||||
|
||||
@@ -163,7 +163,7 @@ export class ShareLink extends PureComponent<Props, State> {
|
||||
bottomSpacing={0}
|
||||
>
|
||||
<Trans i18nKey="share-modal.link.render-instructions">
|
||||
To render a panel image, you must install the{' '}
|
||||
To render an image, you must install the{' '}
|
||||
<TextLink href="https://grafana.com/grafana/plugins/grafana-image-renderer" external>
|
||||
Grafana image renderer plugin
|
||||
</TextLink>
|
||||
|
||||
@@ -181,4 +181,5 @@ export const shareDashboardType: {
|
||||
report: 'report',
|
||||
publicDashboard: 'public_dashboard',
|
||||
inviteUser: 'invite_user',
|
||||
image: 'image',
|
||||
};
|
||||
|
||||
@@ -4617,6 +4617,14 @@
|
||||
"errors": {
|
||||
"failed-to-load": "Failed to load dashboard"
|
||||
},
|
||||
"export": {
|
||||
"button": {
|
||||
"label": "Export dashboard"
|
||||
},
|
||||
"menu": {
|
||||
"label": "Export dashboard menu"
|
||||
}
|
||||
},
|
||||
"fetch-dashboard": {
|
||||
"message": {
|
||||
"failed-to-fetch-dashboard": "Failed to fetch dashboard"
|
||||
@@ -8641,9 +8649,7 @@
|
||||
"share-panel": {
|
||||
"config-description": "Create a personalized, direct link to share your panel within your organization, with the following customization settings:",
|
||||
"download-image": "Download image",
|
||||
"render-image": "Generate image",
|
||||
"render-image-error": "Failed to render panel image",
|
||||
"render-image-error-description": "An error occurred when generating the image"
|
||||
"render-image": "Generate image"
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
@@ -11406,6 +11412,7 @@
|
||||
},
|
||||
"share-dashboard": {
|
||||
"menu": {
|
||||
"export-image-title": "Export as image",
|
||||
"export-json-title": "Export as JSON",
|
||||
"share-externally-title": "Share externally",
|
||||
"share-internally-title": "Share internally",
|
||||
@@ -11441,6 +11448,19 @@
|
||||
"view-button": "View JSON",
|
||||
"view-button-yaml": "View YAML"
|
||||
},
|
||||
"image": {
|
||||
"actions": "Image export actions",
|
||||
"cancel-button": "Cancel",
|
||||
"download-button": "Download image",
|
||||
"error-title": "Failed to generate image",
|
||||
"generate-button": "Generate image",
|
||||
"generating": "Generating image...",
|
||||
"info-text": "Save this dashboard as an image",
|
||||
"preview": "Preview",
|
||||
"preview-aria": "Generated image preview",
|
||||
"preview-region": "Image preview",
|
||||
"title": "Export as image"
|
||||
},
|
||||
"library": {
|
||||
"info": "Create library panel."
|
||||
},
|
||||
@@ -11449,7 +11469,7 @@
|
||||
"info-text": "Create a direct link to this dashboard or panel, customized with the options below.",
|
||||
"link-url": "Link URL",
|
||||
"render-alert": "Image renderer plugin not installed",
|
||||
"render-instructions": "To render a panel image, you must install the <2>Grafana image renderer plugin</2>. Please contact your Grafana administrator to install the plugin.",
|
||||
"render-instructions": "To render an image, you must install the <2>Grafana image renderer plugin</2>. Please contact your Grafana administrator to install the plugin.",
|
||||
"rendered-image": "Direct link rendered image",
|
||||
"save-alert": "Dashboard is not saved",
|
||||
"save-dashboard": "To render a panel image, you must save the dashboard first.",
|
||||
@@ -11517,6 +11537,12 @@
|
||||
}
|
||||
},
|
||||
"share-panel-image": {
|
||||
"disabled-help": "Save the dashboard to enable image generation",
|
||||
"download-disabled-help": "Generate an image first to enable download",
|
||||
"error-title": "Failed to generate image",
|
||||
"form": {
|
||||
"label": "Panel image settings"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Panel preview"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user