mirror of https://github.com/grafana/grafana
DashboardScene: ShareModal + link sharing (#74955)
* DashboardScene: Panel menu updates, adding explore action * DashboardScene: Panel menu updates, adding explore action * Initial test * Update * share modal * Update * rename * Update tests * Fix test * update * Fix tooltip wording * Update translation file * fix e2e * Extract ShareLinkTab component * rename to overlay --------- Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>pull/75230/head
parent
6a37a56d68
commit
1d1bdaab37
@ -0,0 +1,121 @@ |
||||
import { act, render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import { advanceTo, clear } from 'jest-date-mock'; |
||||
import React from 'react'; |
||||
|
||||
import { dateTime } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { config, locationService } from '@grafana/runtime'; |
||||
import { SceneGridItem, SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes'; |
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene'; |
||||
|
||||
import { ShareLinkTab } from './ShareLinkTab'; |
||||
|
||||
jest.mock('app/core/utils/shortLinks', () => ({ |
||||
createShortLink: jest.fn().mockResolvedValue(`http://localhost:3000/goto/shortend-uid`), |
||||
})); |
||||
|
||||
describe('ShareLinkTab', () => { |
||||
const fakeCurrentDate = dateTime('2019-02-11T19:00:00.000Z').toDate(); |
||||
|
||||
afterAll(() => { |
||||
clear(); |
||||
}); |
||||
|
||||
beforeAll(() => { |
||||
advanceTo(fakeCurrentDate); |
||||
|
||||
config.appUrl = 'http://dashboards.grafana.com/grafana/'; |
||||
config.rendererAvailable = true; |
||||
config.bootData.user.orgId = 1; |
||||
locationService.push('/scenes/dashboard/dash-1?from=now-6h&to=now'); |
||||
}); |
||||
|
||||
describe('with locked time range (absolute) range', () => { |
||||
it('should generate share url absolute time', async () => { |
||||
buildAndRenderScenario({}); |
||||
|
||||
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( |
||||
'http://dashboards.grafana.com/grafana/scenes/dashboard/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12' |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('with disabled locked range range', () => { |
||||
it('should generate share url with relative time', async () => { |
||||
const tab = buildAndRenderScenario({}); |
||||
act(() => tab.onToggleLockedTime()); |
||||
|
||||
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( |
||||
'http://dashboards.grafana.com/grafana/scenes/dashboard/dash-1?from=now-6h&to=now&viewPanel=panel-12' |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
it('should add theme when specified', async () => { |
||||
const tab = buildAndRenderScenario({}); |
||||
act(() => tab.onThemeChange('light')); |
||||
|
||||
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( |
||||
'http://dashboards.grafana.com/grafana/scenes/dashboard/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12&theme=light' |
||||
); |
||||
}); |
||||
|
||||
it('should shorten url', async () => { |
||||
buildAndRenderScenario({}); |
||||
|
||||
await userEvent.click(await screen.findByLabelText('Shorten URL')); |
||||
|
||||
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( |
||||
`http://localhost:3000/goto/shortend-uid` |
||||
); |
||||
}); |
||||
|
||||
it('should generate render url', async () => { |
||||
buildAndRenderScenario({}); |
||||
|
||||
expect( |
||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) |
||||
).toHaveAttribute( |
||||
'href', |
||||
'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12&width=1000&height=500&tz=Pacific%2FEaster' |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
interface ScenarioOptions { |
||||
withPanel?: boolean; |
||||
} |
||||
|
||||
function buildAndRenderScenario(options: ScenarioOptions) { |
||||
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 SceneGridItem({ |
||||
key: 'griditem-1', |
||||
x: 0, |
||||
y: 0, |
||||
width: 10, |
||||
height: 12, |
||||
body: panel, |
||||
}), |
||||
], |
||||
}), |
||||
}); |
||||
|
||||
const tab = new ShareLinkTab({ dashboardRef: dashboard.getRef(), panelRef: panel.getRef() }); |
||||
|
||||
render(<tab.Component model={tab} />); |
||||
|
||||
return tab; |
||||
} |
@ -0,0 +1,255 @@ |
||||
import React from 'react'; |
||||
|
||||
import { dateTime, UrlQueryMap } from '@grafana/data'; |
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; |
||||
import { config, locationService } from '@grafana/runtime'; |
||||
import { |
||||
SceneComponentProps, |
||||
SceneObjectBase, |
||||
SceneObjectState, |
||||
SceneObjectRef, |
||||
VizPanel, |
||||
sceneGraph, |
||||
} 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'; |
||||
import { createShortLink } from 'app/core/utils/shortLinks'; |
||||
import { ThemePicker } from 'app/features/dashboard/components/ShareModal/ThemePicker'; |
||||
import { trackDashboardSharingActionPerType } from 'app/features/dashboard/components/ShareModal/analytics'; |
||||
import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; |
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene'; |
||||
import { getDashboardUrl } from '../utils/utils'; |
||||
|
||||
export interface ShareLinkTabState extends SceneObjectState, ShareOptions { |
||||
panelRef?: SceneObjectRef<VizPanel>; |
||||
dashboardRef: SceneObjectRef<DashboardScene>; |
||||
} |
||||
|
||||
interface ShareOptions { |
||||
useLockedTime: boolean; |
||||
useShortUrl: boolean; |
||||
selectedTheme: string; |
||||
shareUrl: string; |
||||
imageUrl: string; |
||||
} |
||||
|
||||
export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> { |
||||
static Component = ShareLinkTabRenderer; |
||||
|
||||
constructor(state: Omit<ShareLinkTabState, keyof ShareOptions>) { |
||||
super({ |
||||
...state, |
||||
useLockedTime: true, |
||||
useShortUrl: false, |
||||
selectedTheme: 'current', |
||||
shareUrl: '', |
||||
imageUrl: '', |
||||
}); |
||||
|
||||
this.addActivationHandler(() => { |
||||
this.buildUrl(); |
||||
}); |
||||
} |
||||
|
||||
async buildUrl() { |
||||
const { panelRef, dashboardRef, useLockedTime: useAbsoluteTimeRange, useShortUrl, selectedTheme } = this.state; |
||||
const dashboard = dashboardRef.resolve(); |
||||
const panel = panelRef?.resolve(); |
||||
const location = locationService.getLocation(); |
||||
const timeRange = sceneGraph.getTimeRange(panel ?? dashboard); |
||||
|
||||
const urlParamsUpdate: UrlQueryMap = {}; |
||||
|
||||
if (panel) { |
||||
urlParamsUpdate.viewPanel = panel.state.key; |
||||
} |
||||
|
||||
if (useAbsoluteTimeRange) { |
||||
urlParamsUpdate.from = timeRange.state.value.from.toISOString(); |
||||
urlParamsUpdate.to = timeRange.state.value.to.toISOString(); |
||||
} |
||||
|
||||
if (selectedTheme !== 'current') { |
||||
urlParamsUpdate.theme = selectedTheme!; |
||||
} |
||||
|
||||
let shareUrl = getDashboardUrl({ |
||||
uid: dashboard.state.uid, |
||||
currentQueryParams: location.search, |
||||
updateQuery: urlParamsUpdate, |
||||
absolute: true, |
||||
}); |
||||
|
||||
if (useShortUrl) { |
||||
shareUrl = await createShortLink(shareUrl); |
||||
} |
||||
|
||||
const imageUrl = getDashboardUrl({ |
||||
uid: dashboard.state.uid, |
||||
currentQueryParams: location.search, |
||||
updateQuery: urlParamsUpdate, |
||||
absolute: true, |
||||
|
||||
soloRoute: true, |
||||
render: true, |
||||
timeZone: getRenderTimeZone(timeRange.getTimeZone()), |
||||
}); |
||||
|
||||
this.setState({ shareUrl, imageUrl }); |
||||
} |
||||
|
||||
public getTabLabel() { |
||||
return t('share-modal.tab-title.link', 'Link'); |
||||
} |
||||
|
||||
onToggleLockedTime = () => { |
||||
this.setState({ useLockedTime: !this.state.useLockedTime }); |
||||
this.buildUrl(); |
||||
}; |
||||
|
||||
onUrlShorten = () => { |
||||
this.setState({ useShortUrl: !this.state.useShortUrl }); |
||||
this.buildUrl(); |
||||
}; |
||||
|
||||
onThemeChange = (value: string) => { |
||||
this.setState({ selectedTheme: value }); |
||||
this.buildUrl(); |
||||
}; |
||||
|
||||
getShareUrl = () => { |
||||
return this.state.shareUrl; |
||||
}; |
||||
|
||||
onCopy() { |
||||
trackDashboardSharingActionPerType('copy_link', shareDashboardType.link); |
||||
} |
||||
} |
||||
|
||||
function ShareLinkTabRenderer({ model }: SceneComponentProps<ShareLinkTab>) { |
||||
const state = model.useState(); |
||||
const { panelRef, dashboardRef } = state; |
||||
|
||||
const dashboard = dashboardRef.resolve(); |
||||
const panel = panelRef?.resolve(); |
||||
|
||||
const timeRange = sceneGraph.getTimeRange(panel ?? dashboard); |
||||
const isRelativeTime = timeRange.state.to === 'now' ? true : false; |
||||
|
||||
const { useLockedTime, useShortUrl, selectedTheme, shareUrl, imageUrl } = state; |
||||
|
||||
const selectors = e2eSelectors.pages.SharePanelModal; |
||||
const isDashboardSaved = Boolean(dashboard.state.uid); |
||||
|
||||
const lockTimeRangeLabel = t('share-modal.link.time-range-label', `Lock time range`); |
||||
|
||||
const lockTimeRangeDescription = t( |
||||
'share-modal.link.time-range-description', |
||||
`Transforms the current relative time range to an absolute time range` |
||||
); |
||||
|
||||
const shortenURLTranslation = t('share-modal.link.shorten-url', `Shorten URL`); |
||||
|
||||
const linkURLTranslation = t('share-modal.link.link-url', `Link URL`); |
||||
|
||||
return ( |
||||
<> |
||||
<p className="share-modal-info-text"> |
||||
<Trans i18nKey="share-modal.link.info-text"> |
||||
Create a direct link to this dashboard or panel, customized with the options below. |
||||
</Trans> |
||||
</p> |
||||
<FieldSet> |
||||
<Field label={lockTimeRangeLabel} description={isRelativeTime ? lockTimeRangeDescription : ''}> |
||||
<Switch id="share-current-time-range" value={useLockedTime} onChange={model.onToggleLockedTime} /> |
||||
</Field> |
||||
<ThemePicker selectedTheme={selectedTheme} onChange={model.onThemeChange} /> |
||||
<Field label={shortenURLTranslation}> |
||||
<Switch id="share-shorten-url" value={useShortUrl} onChange={model.onUrlShorten} /> |
||||
</Field> |
||||
|
||||
<Field label={linkURLTranslation}> |
||||
<Input |
||||
id="link-url-input" |
||||
value={shareUrl} |
||||
readOnly |
||||
addonAfter={ |
||||
<ClipboardButton icon="copy" variant="primary" getText={model.getShareUrl} onClipboardCopy={model.onCopy}> |
||||
<Trans i18nKey="share-modal.link.copy-link-button">Copy</Trans> |
||||
</ClipboardButton> |
||||
} |
||||
/> |
||||
</Field> |
||||
</FieldSet> |
||||
|
||||
{panel && config.rendererAvailable && ( |
||||
<> |
||||
{isDashboardSaved && ( |
||||
<div className="gf-form"> |
||||
<a href={imageUrl} target="_blank" rel="noreferrer" aria-label={selectors.linkToRenderedImage}> |
||||
<Icon name="camera" /> |
||||
|
||||
<Trans i18nKey="share-modal.link.rendered-image">Direct link rendered image</Trans> |
||||
</a> |
||||
</div> |
||||
)} |
||||
|
||||
{!isDashboardSaved && ( |
||||
<Alert severity="info" title={t('share-modal.link.save-alert', 'Dashboard is not saved')} bottomSpacing={0}> |
||||
<Trans i18nKey="share-modal.link.save-dashboard"> |
||||
To render a panel image, you must save the dashboard first. |
||||
</Trans> |
||||
</Alert> |
||||
)} |
||||
</> |
||||
)} |
||||
|
||||
{panel && !config.rendererAvailable && ( |
||||
<Alert |
||||
severity="info" |
||||
title={t('share-modal.link.render-alert', 'Image renderer plugin not installed')} |
||||
bottomSpacing={0} |
||||
> |
||||
<Trans i18nKey="share-modal.link.render-instructions"> |
||||
To render a panel 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> |
||||
</Alert> |
||||
)} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
function getRenderTimeZone(timeZone: TimeZone): string { |
||||
const utcOffset = 'UTC' + encodeURIComponent(dateTime().format('Z')); |
||||
|
||||
if (timeZone === 'utc') { |
||||
return 'UTC'; |
||||
} |
||||
|
||||
if (timeZone === 'browser') { |
||||
if (!window.Intl) { |
||||
return utcOffset; |
||||
} |
||||
|
||||
const dateFormat = window.Intl.DateTimeFormat(); |
||||
const options = dateFormat.resolvedOptions(); |
||||
if (!options.timeZone) { |
||||
return utcOffset; |
||||
} |
||||
|
||||
return options.timeZone; |
||||
} |
||||
|
||||
return timeZone; |
||||
} |
@ -0,0 +1,117 @@ |
||||
import React, { ComponentProps } from 'react'; |
||||
|
||||
import { config } from '@grafana/runtime'; |
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel, SceneObjectRef } from '@grafana/scenes'; |
||||
import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui'; |
||||
import { contextSrv } from 'app/core/core'; |
||||
import { t } from 'app/core/internationalization'; |
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene'; |
||||
import { getDashboardSceneFor } from '../utils/utils'; |
||||
|
||||
import { ShareLinkTab } from './ShareLinkTab'; |
||||
import { ShareSnapshotTab } from './ShareSnapshotTab'; |
||||
import { SceneShareTab } from './types'; |
||||
|
||||
interface ShareModalState extends SceneObjectState { |
||||
dashboardRef: SceneObjectRef<DashboardScene>; |
||||
panelRef?: SceneObjectRef<VizPanel>; |
||||
tabs?: SceneShareTab[]; |
||||
activeTab: string; |
||||
} |
||||
|
||||
/** |
||||
* Used for full dashboard share modal and the panel level share modal |
||||
*/ |
||||
export class ShareModal extends SceneObjectBase<ShareModalState> { |
||||
static Component = SharePanelModalRenderer; |
||||
|
||||
constructor(state: Omit<ShareModalState, 'activeTab'>) { |
||||
super({ |
||||
...state, |
||||
activeTab: 'Link', |
||||
}); |
||||
|
||||
this.addActivationHandler(() => this.buildTabs()); |
||||
} |
||||
|
||||
private buildTabs() { |
||||
const { dashboardRef, panelRef } = this.state; |
||||
|
||||
const tabs: SceneShareTab[] = [new ShareLinkTab({ dashboardRef, panelRef })]; |
||||
|
||||
if (contextSrv.isSignedIn && config.snapshotEnabled) { |
||||
tabs.push(new ShareSnapshotTab({ panelRef })); |
||||
} |
||||
|
||||
this.setState({ tabs }); |
||||
|
||||
// if (panel) {
|
||||
// const embedLabel = t('share-modal.tab-title.embed', 'Embed');
|
||||
// tabs.push({ label: embedLabel, value: shareDashboardType.embed, component: ShareEmbed });
|
||||
|
||||
// if (!isPanelModelLibraryPanel(panel)) {
|
||||
// const libraryPanelLabel = t('share-modal.tab-title.library-panel', 'Library panel');
|
||||
// tabs.push({ label: libraryPanelLabel, value: shareDashboardType.libraryPanel, component: ShareLibraryPanel });
|
||||
// }
|
||||
// tabs.push(...customPanelTabs);
|
||||
// } else {
|
||||
// const exportLabel = t('share-modal.tab-title.export', 'Export');
|
||||
// tabs.push({
|
||||
// label: exportLabel,
|
||||
// value: shareDashboardType.export,
|
||||
// component: ShareExport,
|
||||
// });
|
||||
// tabs.push(...customDashboardTabs);
|
||||
// }
|
||||
|
||||
// if (Boolean(config.featureToggles['publicDashboards'])) {
|
||||
// tabs.push({
|
||||
// label: 'Public dashboard',
|
||||
// value: shareDashboardType.publicDashboard,
|
||||
// component: SharePublicDashboard,
|
||||
// });
|
||||
// }
|
||||
} |
||||
|
||||
onClose = () => { |
||||
const dashboard = getDashboardSceneFor(this); |
||||
dashboard.closeModal(); |
||||
}; |
||||
|
||||
onChangeTab: ComponentProps<typeof ModalTabsHeader>['onChangeTab'] = (tab) => { |
||||
this.setState({ activeTab: tab.value }); |
||||
}; |
||||
} |
||||
|
||||
function SharePanelModalRenderer({ model }: SceneComponentProps<ShareModal>) { |
||||
const { panelRef, tabs, activeTab } = model.useState(); |
||||
const title = panelRef ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share'); |
||||
|
||||
if (!tabs) { |
||||
return; |
||||
} |
||||
|
||||
const modalTabs = tabs?.map((tab) => ({ |
||||
label: tab.getTabLabel(), |
||||
value: tab.getTabLabel(), |
||||
})); |
||||
|
||||
const header = ( |
||||
<ModalTabsHeader |
||||
title={title} |
||||
icon="share-alt" |
||||
tabs={modalTabs} |
||||
activeTab={activeTab} |
||||
onChangeTab={model.onChangeTab} |
||||
/> |
||||
); |
||||
|
||||
const currentTab = tabs.find((t) => t.getTabLabel() === activeTab); |
||||
|
||||
return ( |
||||
<Modal isOpen={true} title={header} onDismiss={model.onClose}> |
||||
<TabContent>{currentTab && <currentTab.Component model={currentTab} />}</TabContent> |
||||
</Modal> |
||||
); |
||||
} |
@ -0,0 +1,18 @@ |
||||
import React from 'react'; |
||||
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef, VizPanel } from '@grafana/scenes'; |
||||
import { t } from 'app/core/internationalization'; |
||||
|
||||
export interface ShareSnapshotTabState extends SceneObjectState { |
||||
panelRef?: SceneObjectRef<VizPanel>; |
||||
} |
||||
|
||||
export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> { |
||||
public getTabLabel() { |
||||
return t('share-modal.tab-title.snapshot', 'Snapshot'); |
||||
} |
||||
|
||||
static Component = ({ model }: SceneComponentProps<ShareSnapshotTab>) => { |
||||
return <div>Snapshot</div>; |
||||
}; |
||||
} |
@ -0,0 +1,5 @@ |
||||
import { SceneObject, SceneObjectState } from '@grafana/scenes'; |
||||
|
||||
export interface SceneShareTab<T extends SceneObjectState = SceneObjectState> extends SceneObject<T> { |
||||
getTabLabel(): string; |
||||
} |
Loading…
Reference in new issue