diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 45ab6518453..ae0a8e08d3f 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -697,6 +697,26 @@ export const versionedPages = { copyUrlButton: { '11.3.0': 'data-testid share internally copy url button', }, + SharePanel: { + preview: { + '11.5.0': 'data-testid share panel internally image generation preview', + }, + widthInput: { + '11.5.0': 'data-testid share panel internally width input', + }, + heightInput: { + '11.5.0': 'data-testid share panel internally height input', + }, + scaleFactorInput: { + '11.5.0': 'data-testid share panel internally scale factor input', + }, + generateImageButton: { + '11.5.0': 'data-testid share panel internally generate image button', + }, + downloadImageButton: { + '11.5.0': 'data-testid share panel internally download image button', + }, + }, }, ShareExternally: { container: { diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index d576a093b55..4268276f60d 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -19,7 +19,7 @@ import { contextSrv } from 'app/core/services/context_srv'; import { getMessageFromError } from 'app/core/utils/errors'; import { getCreateAlertInMenuAvailability } from 'app/features/alerting/unified/utils/access-control'; import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form'; -import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; +import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; import { InspectTab } from 'app/features/inspector/types'; import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; @@ -90,6 +90,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) { iconClassName: 'link', shortcut: 'p u', onClick: () => { + DashboardInteractions.sharingCategoryClicked({ + item: shareDashboardType.link, + shareResource: getTrackingSource(panel?.getRef()), + }); + const drawer = new ShareDrawer({ shareView: shareDashboardType.link, panelRef: panel.getRef(), @@ -103,6 +108,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) { iconClassName: 'arrow', shortcut: 'p e', onClick: () => { + DashboardInteractions.sharingCategoryClicked({ + item: shareDashboardType.embed, + shareResource: getTrackingSource(panel.getRef()), + }); + const drawer = new ShareDrawer({ shareView: shareDashboardType.embed, panelRef: panel.getRef(), @@ -122,6 +132,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) { iconClassName: 'camera', shortcut: 'p s', onClick: () => { + DashboardInteractions.sharingCategoryClicked({ + item: shareDashboardType.snapshot, + shareResource: getTrackingSource(panel.getRef()), + }); + const drawer = new ShareDrawer({ shareView: shareDashboardType.snapshot, panelRef: panel.getRef(), diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index 02436ca3879..6151a44fa5f 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -1,7 +1,7 @@ -import { dateTime } from '@grafana/data'; +import { dateTime, UrlQueryMap } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { SceneComponentProps, SceneObjectBase, SceneObjectRef, VizPanel, sceneGraph } from '@grafana/scenes'; +import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes'; import { TimeZone } from '@grafana/schema'; import { Alert, ClipboardButton, Field, FieldSet, Icon, Input, Switch } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; @@ -14,6 +14,7 @@ import { getDashboardUrl } from '../utils/urlBuilders'; import { getDashboardSceneFor } from '../utils/utils'; import { SceneShareTabState, ShareView } from './types'; + export interface ShareLinkTabState extends SceneShareTabState, ShareOptions { panelRef?: SceneObjectRef; } @@ -55,7 +56,7 @@ export class ShareLinkTab extends SceneObjectBase implements this.onThemeChange = this.onThemeChange.bind(this); } - async buildUrl() { + buildUrl = async (queryOptions?: UrlQueryMap) => { this.setState({ isBuildUrlLoading: true }); const { panelRef, useLockedTime: useAbsoluteTimeRange, useShortUrl, selectedTheme } = this.state; const dashboard = getDashboardSceneFor(this); @@ -83,7 +84,7 @@ export class ShareLinkTab extends SceneObjectBase implements const imageUrl = getDashboardUrl({ uid: dashboard.state.uid, currentQueryParams: location.search, - updateQuery: { ...urlParamsUpdate, panelId: panel?.state.key }, + updateQuery: { ...urlParamsUpdate, ...queryOptions, panelId: panel?.state.key }, absolute: true, soloRoute: true, render: true, @@ -91,7 +92,7 @@ export class ShareLinkTab extends SceneObjectBase implements }); this.setState({ shareUrl, imageUrl, isBuildUrlLoading: false }); - } + }; public getTabLabel() { return t('share-modal.tab-title.link', 'Link'); diff --git a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx new file mode 100644 index 00000000000..ac5daf5eac4 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; + +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { config, setPluginImportUtils } from '@grafana/runtime'; +import { SceneTimeRange, VizPanel } from '@grafana/scenes'; + +import { userEvent } from '../../../../../test/test-utils'; +import { DashboardScene } from '../../scene/DashboardScene'; +import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; +import { activateFullSceneTree } from '../../utils/test-utils'; + +import { SharePanelInternally } from './SharePanelInternally'; + +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + 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(); + }); + }); + + 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(); + }); + + 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(); + }); +}); + +function buildAndRenderScenario() { + const panel = new VizPanel({ + title: 'Panel A', + pluginId: 'table', + key: 'panel-12', + }); + const tab = new SharePanelInternally({ panelRef: panel.getRef() }); + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + $timeRange: new SceneTimeRange({}), + body: DefaultGridLayoutManager.fromVizPanels([panel]), + overlay: tab, + }); + + activateFullSceneTree(scene); + + render(); + + return tab; +} diff --git a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx index 11f9af59a48..0dbf038cbba 100644 --- a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx +++ b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx @@ -3,13 +3,15 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; import { SceneComponentProps } from '@grafana/scenes'; -import { Alert, ClipboardButton, Divider, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Alert, ClipboardButton, Divider, Stack, Text, useStyles2 } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; import { getDashboardSceneFor } from '../../utils/utils'; import ShareInternallyConfiguration from '../ShareInternallyConfiguration'; import { ShareLinkTab, ShareLinkTabState } from '../ShareLinkTab'; +import { SharePanelPreview } from './SharePanelPreview'; + export class SharePanelInternally extends ShareLinkTab { static Component = SharePanelInternallyRenderer; @@ -24,56 +26,44 @@ export class SharePanelInternally extends ShareLinkTab { function SharePanelInternallyRenderer({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); - const { useLockedTime, useShortUrl, selectedTheme, isBuildUrlLoading, imageUrl } = model.useState(); + const { useLockedTime, useShortUrl, selectedTheme, isBuildUrlLoading, imageUrl, panelRef } = model.useState(); + + const panelTitle = panelRef?.resolve().state.title; const dashboard = getDashboardSceneFor(model); const isDashboardSaved = Boolean(dashboard.state.uid); return ( - <> -
- - - Create a personalized, direct link to share your panel within your organization, with the following - customization settings: - - +
+ + + Create a personalized, direct link to share your panel within your organization, with the following + customization settings: + + +
+ + + Copy link +
- model.onToggleLockedTime()} - useShortUrl={useShortUrl} - onUrlShorten={() => model.onUrlShorten()} - selectedTheme={selectedTheme} - onChangeTheme={(t) => model.onThemeChange(t)} - isLoading={isBuildUrlLoading} - /> - + -
- - - Copy link - - - Render image - - -
{!isDashboardSaved && ( @@ -101,16 +91,20 @@ function SharePanelInternallyRenderer({ model }: SceneComponentProps )} +
- +
); } const getStyles = (theme: GrafanaTheme2) => ({ - configDescription: css({ - marginBottom: theme.spacing(2), - }), - buttonsContainer: css({ + configurationContainer: css({ marginTop: theme.spacing(2), }), }); diff --git a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelPreview.tsx b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelPreview.tsx new file mode 100644 index 00000000000..94345914fdb --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelPreview.tsx @@ -0,0 +1,228 @@ +import { css } from '@emotion/css'; +import saveAs from 'file-saver'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useAsyncFn } from 'react-use'; +import { lastValueFrom } from 'rxjs'; + +import { GrafanaTheme2, UrlQueryMap } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { config, getBackendSrv, isFetchError } from '@grafana/runtime'; +import { Alert, Button, Field, FieldSet, Icon, Input, LoadingBar, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { DashboardInteractions } from '../../utils/interactions'; + +type ImageSettingsForm = { + width: number; + height: number; + scaleFactor: number; +}; + +type Props = { + title: string; + buildUrl: (urlParams: UrlQueryMap) => void; + imageUrl: string; + disabled: boolean; + theme: string; +}; + +const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel; + +export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }: Props) { + const styles = useStyles2(getStyles); + + const { + handleSubmit, + register, + watch, + formState: { errors, isValid }, + } = useForm({ + mode: 'onChange', + defaultValues: { + width: 1000, + height: 500, + scaleFactor: 1, + }, + }); + + useEffect(() => { + buildUrl({ width: watch('width'), height: watch('height'), scale: watch('scaleFactor') }); + }, [buildUrl, watch]); + + const [{ loading, value: image, error }, renderImage] = useAsyncFn(async () => { + const { width, height, scaleFactor } = watch(); + DashboardInteractions.generatePanelImageClicked({ + width, + height, + scaleFactor, + theme, + shareResource: 'panel', + }); + const response = await lastValueFrom(getBackendSrv().fetch({ url: imageUrl, responseType: 'blob' })); + return new Blob([response.data], { type: 'image/png' }); + }, [imageUrl, watch('width'), watch('height'), watch('scaleFactor'), theme]); + + const onDownloadImageClick = () => { + DashboardInteractions.downloadPanelImageClicked({ shareResource: 'panel' }); + saveAs(image!, `${title}.png`); + }; + + const onChange = () => { + buildUrl({ width: watch('width'), height: watch('height'), scale: watch('scaleFactor') }); + }; + + return ( +
+ + + Panel preview + +
+
+ + Image settings + + + + + + } + > + + + + + + + + + + + + + + + +
+
+ {loading && ( +
+ +
+ {title || ''} +
+
+ )} + {image && !loading && panel-preview-img} + {error && !loading && ( + + {isFetchError(error) + ? error.statusText + : t('link.share-panel.render-image-error-description', 'An error occurred when generating the image')} + + )} +
+
+ ); +} + +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), + }), +}); diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index 6ed95fe6cb8..988d05c9e12 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -45,6 +45,12 @@ export const DashboardInteractions = { embedSnippetCopy: (properties?: Record) => { reportDashboardInteraction('sharing_embed_copy_clicked', properties); }, + generatePanelImageClicked: (properties?: Record) => { + reportDashboardInteraction('sharing_link_generate_image_clicked', properties); + }, + downloadPanelImageClicked: (properties?: Record) => { + reportDashboardInteraction('sharing_link_download_image_clicked', properties); + }, publishSnapshotClicked: (properties?: Record) => { reportDashboardInteraction('sharing_snapshot_publish_clicked', properties); }, diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts index 4d4104f100d..30d4808e302 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -50,8 +50,8 @@ export function getDashboardUrl(options: DashboardUrlOptions) { options.updateQuery = { ...options.updateQuery, - width: 1000, - height: 500, + width: options.updateQuery?.width || 1000, + height: options.updateQuery?.height || 500, tz: options.timeZone, }; } diff --git a/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx b/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx index 31bdbc6a894..5101db70c7b 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx @@ -1,8 +1,7 @@ import { FormEvent, useEffect, useState } from 'react'; -import { useEffectOnce } from 'react-use'; import { RawTimeRange, TimeRange } from '@grafana/data'; -import { config, reportInteraction } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { Button, ClipboardButton, Field, Label, Modal, Stack, Switch, TextArea } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; @@ -24,10 +23,6 @@ export function ShareEmbed({ panel, dashboard, range, onCancelClick, buildIframe const [selectedTheme, setSelectedTheme] = useState('current'); const [iframeHtml, setIframeHtml] = useState(''); - useEffectOnce(() => { - reportInteraction('grafana_dashboards_embed_share_viewed', { shareResource: getTrackingSource(panel) }); - }); - useEffect(() => { const newIframeHtml = buildIframe(useCurrentTimeRange, dashboard.uid, selectedTheme, panel, range); setIframeHtml(newIframeHtml); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 92f6777debd..db81976f539 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1715,7 +1715,10 @@ }, "share-panel": { "config-description": "Create a personalized, direct link to share your panel within your organization, with the following customization settings:", - "render-image": "Render image" + "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" } }, "login": { @@ -3033,6 +3036,27 @@ "create-button": "Create library panel" } }, + "share-panel-image": { + "preview": { + "title": "Panel preview" + }, + "settings": { + "height-label": "Height", + "height-min": "Height must be equal or greater than 1", + "height-placeholder": "500", + "height-required": "Height is required", + "max-warning": "Setting maximums are limited by the image renderer service", + "scale-factor-label": "Scale factor", + "scale-factor-min": "Scale factor must be equal or greater than 1", + "scale-factor-placeholder": "1", + "scale-factor-required": "Scale factor is required", + "title": "Image settings", + "width-label": "Width", + "width-min": "Width must be equal or greater than 1", + "width-placeholder": "1000", + "width-required": "Width is required" + } + }, "share-playlist": { "checkbox-description": "Panel heights will be adjusted to fit screen size", "checkbox-label": "Autofit", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 5dde76ff509..cff6f710dd9 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1715,7 +1715,10 @@ }, "share-panel": { "config-description": "Cřęäŧę ä pęřşőʼnäľįžęđ, đįřęčŧ ľįʼnĸ ŧő şĥäřę yőūř päʼnęľ ŵįŧĥįʼn yőūř őřģäʼnįžäŧįőʼn, ŵįŧĥ ŧĥę ƒőľľőŵįʼnģ čūşŧőmįžäŧįőʼn şęŧŧįʼnģş:", - "render-image": "Ŗęʼnđęř įmäģę" + "download-image": "Đőŵʼnľőäđ įmäģę", + "render-image": "Ğęʼnęřäŧę įmäģę", + "render-image-error": "Fäįľęđ ŧő řęʼnđęř päʼnęľ įmäģę", + "render-image-error-description": "Åʼn ęřřőř őččūřřęđ ŵĥęʼn ģęʼnęřäŧįʼnģ ŧĥę įmäģę" } }, "login": { @@ -3033,6 +3036,27 @@ "create-button": "Cřęäŧę ľįþřäřy päʼnęľ" } }, + "share-panel-image": { + "preview": { + "title": "Päʼnęľ přęvįęŵ" + }, + "settings": { + "height-label": "Ħęįģĥŧ", + "height-min": "Ħęįģĥŧ mūşŧ þę ęqūäľ őř ģřęäŧęř ŧĥäʼn 1", + "height-placeholder": "500", + "height-required": "Ħęįģĥŧ įş řęqūįřęđ", + "max-warning": "Ŝęŧŧįʼnģ mäχįmūmş äřę ľįmįŧęđ þy ŧĥę įmäģę řęʼnđęřęř şęřvįčę", + "scale-factor-label": "Ŝčäľę ƒäčŧőř", + "scale-factor-min": "Ŝčäľę ƒäčŧőř mūşŧ þę ęqūäľ őř ģřęäŧęř ŧĥäʼn 1", + "scale-factor-placeholder": "1", + "scale-factor-required": "Ŝčäľę ƒäčŧőř įş řęqūįřęđ", + "title": "Ĩmäģę şęŧŧįʼnģş", + "width-label": "Ŵįđŧĥ", + "width-min": "Ŵįđŧĥ mūşŧ þę ęqūäľ őř ģřęäŧęř ŧĥäʼn 1", + "width-placeholder": "1000", + "width-required": "Ŵįđŧĥ įş řęqūįřęđ" + } + }, "share-playlist": { "checkbox-description": "Päʼnęľ ĥęįģĥŧş ŵįľľ þę äđĵūşŧęđ ŧő ƒįŧ şčřęęʼn şįžę", "checkbox-label": "Åūŧőƒįŧ",