Shorten url: Unification across Explore and Dashboards (#28434)

* WIP: Unify short url for dashboards and explore

* Add tests

* Update

* Address feedback, move createShortUrl to buildUrl
This commit is contained in:
Ivana Huckova 2020-10-22 10:31:58 +02:00 committed by GitHub
parent 8f4be08b00
commit a0932f4d2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 109 additions and 86 deletions

View File

@ -0,0 +1,29 @@
import { createShortLink, createAndCopyShortLink } from './shortLinks';
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => {
return {
post: () => {
return Promise.resolve({ url: 'www.short.com' });
},
};
},
config: {
appSubUrl: '',
},
}));
describe('createShortLink', () => {
it('creates short link', async () => {
const shortUrl = await createShortLink('www.verylonglinkwehavehere.com');
expect(shortUrl).toBe('www.short.com');
});
});
describe('createAndCopyShortLink', () => {
it('copies short link to clipboard', async () => {
document.execCommand = jest.fn();
await createAndCopyShortLink('www.verylonglinkwehavehere.com');
expect(document.execCommand).toHaveBeenCalledWith('copy');
});
});

View File

@ -0,0 +1,36 @@
import memoizeOne from 'memoize-one';
import { getBackendSrv, config } from '@grafana/runtime';
import { AppEvents } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { copyStringToClipboard } from './explore';
function buildHostUrl() {
return `${window.location.protocol}//${window.location.host}${config.appSubUrl}`;
}
function getRelativeURLPath(url: string) {
let path = url.replace(buildHostUrl(), '');
return path.startsWith('/') ? path.substring(1, path.length) : path;
}
export const createShortLink = memoizeOne(async function(path: string) {
try {
const shortLink = await getBackendSrv().post(`/api/short-urls`, {
path: getRelativeURLPath(path),
});
return shortLink.url;
} catch (err) {
console.error('Error when creating shortened link: ', err);
appEvents.emit(AppEvents.alertError, ['Error generating shortened link']);
}
});
export const createAndCopyShortLink = async (path: string) => {
const shortLink = await createShortLink(path);
if (shortLink) {
copyStringToClipboard(shortLink);
appEvents.emit(AppEvents.alertSuccess, ['Shortened link copied to clipboard']);
} else {
appEvents.emit(AppEvents.alertError, ['Error generating shortened link']);
}
};

View File

@ -111,64 +111,70 @@ describe('ShareModal', () => {
});
});
it('should generate share url absolute time', () => {
it('should generate share url absolute time', async () => {
await ctx.wrapper?.instance().buildUrl();
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&viewPanel=22');
});
it('should generate render url', () => {
it('should generate render url', async () => {
mockLocationHref('http://dashboards.grafana.com/d/abcdefghi/my-dash');
ctx.mount({
panel: { id: 22, options: {}, fieldConfig: { defaults: {}, overrides: [] } },
});
await ctx.wrapper?.instance().buildUrl();
const state = ctx.wrapper?.state();
const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(state?.imageUrl).toContain(base + params);
});
it('should generate render url for scripted dashboard', () => {
it('should generate render url for scripted dashboard', async () => {
mockLocationHref('http://dashboards.grafana.com/dashboard/script/my-dash.js');
ctx.mount({
panel: { id: 22, options: {}, fieldConfig: { defaults: {}, overrides: [] } },
});
await ctx.wrapper?.instance().buildUrl();
const state = ctx.wrapper?.state();
const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(state?.imageUrl).toContain(base + params);
});
it('should remove panel id when no panel in scope', () => {
it('should remove panel id when no panel in scope', async () => {
ctx.mount({
panel: undefined,
});
await ctx.wrapper?.instance().buildUrl();
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1');
});
it('should add theme when specified', () => {
it('should add theme when specified', async () => {
ctx.wrapper?.setProps({ panel: undefined });
ctx.wrapper?.setState({ selectedTheme: { label: 'light', value: 'light' } });
await ctx.wrapper?.instance().buildUrl();
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light');
});
it('should remove editPanel from image url when is first param in querystring', () => {
it('should remove editPanel from image url when is first param in querystring', async () => {
mockLocationHref('http://server/#!/test?editPanel=1');
ctx.mount({
panel: { id: 1, options: {}, fieldConfig: { defaults: {}, overrides: [] } },
});
await ctx.wrapper?.instance().buildUrl();
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toContain('?editPanel=1&from=1000&to=2000&orgId=1');
expect(state?.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC');
});
it('should include template variables in url', () => {
it('should include template variables in url', async () => {
mockLocationHref('http://server/#!/test');
fillVariableValuesForUrlMock = (params: any) => {
params['var-app'] = 'mupp';
@ -177,6 +183,7 @@ describe('ShareModal', () => {
ctx.mount();
ctx.wrapper?.setState({ includeTemplateVars: true });
await ctx.wrapper?.instance().buildUrl();
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toContain(
'http://server/#!/test?from=1000&to=2000&orgId=1&var-app=mupp&var-server=srv-01'

View File

@ -3,9 +3,8 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { LegacyForms, ClipboardButton, Icon, InfoBox, Input } from '@grafana/ui';
const { Select, Switch } = LegacyForms;
import { SelectableValue, PanelModel, AppEvents } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { DashboardModel } from 'app/features/dashboard/state';
import { buildImageUrl, buildShareUrl, getRelativeURLPath } from './utils';
import { buildImageUrl, buildShareUrl } from './utils';
import { appEvents } from 'app/core/core';
import config from 'app/core/config';
@ -58,22 +57,20 @@ export class ShareLink extends PureComponent<Props, State> {
}
}
buildUrl = () => {
buildUrl = async () => {
const { panel } = this.props;
const { useCurrentTimeRange, includeTemplateVars, useShortUrl, selectedTheme } = this.state;
const shareUrl = buildShareUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme.value, panel);
const shareUrl = await buildShareUrl(
useCurrentTimeRange,
includeTemplateVars,
selectedTheme.value,
panel,
useShortUrl
);
const imageUrl = buildImageUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme.value, panel);
if (useShortUrl) {
getBackendSrv()
.post(`/api/short-urls`, {
path: getRelativeURLPath(shareUrl),
})
.then(res => this.setState({ shareUrl: res.url, imageUrl }));
} else {
this.setState({ shareUrl, imageUrl });
}
this.setState({ shareUrl, imageUrl });
};
onUseCurrentTimeRangeChange = () => {
@ -126,11 +123,11 @@ export class ShareLink extends PureComponent<Props, State> {
checked={includeTemplateVars}
onChange={this.onIncludeTemplateVarsChange}
/>
<Switch labelClass="width-12" label="Shorten URL" checked={useShortUrl} onChange={this.onUrlShorten} />
<div className="gf-form">
<label className="gf-form-label width-12">Theme</label>
<Select width={10} options={themeOptions} value={selectedTheme} onChange={this.onThemeChange} />
</div>
<Switch labelClass="width-12" label="Shorten URL" checked={useShortUrl} onChange={this.onUrlShorten} />
</div>
<div>
<div className="gf-form-group">

View File

@ -1,6 +1,7 @@
import { config } from '@grafana/runtime';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getTemplateSrv } from 'app/features/templating/template_srv';
import { createShortLink } from 'app/core/utils/shortLinks';
import { PanelModel, dateTime, urlUtil } from '@grafana/data';
export function buildParams(
@ -38,15 +39,6 @@ export function buildParams(
return params;
}
export function buildHostUrl() {
return `${window.location.protocol}//${window.location.host}${config.appSubUrl}`;
}
export function getRelativeURLPath(url: string) {
let p = url.replace(buildHostUrl(), '');
return p.startsWith('/') ? p.substring(1, p.length) : p;
}
export function buildBaseUrl() {
let baseUrl = window.location.href;
const queryStart = baseUrl.indexOf('?');
@ -58,16 +50,20 @@ export function buildBaseUrl() {
return baseUrl;
}
export function buildShareUrl(
export async function buildShareUrl(
useCurrentTimeRange: boolean,
includeTemplateVars: boolean,
selectedTheme?: string,
panel?: PanelModel
panel?: PanelModel,
shortenUrl?: boolean
) {
const baseUrl = buildBaseUrl();
const params = buildParams(useCurrentTimeRange, includeTemplateVars, selectedTheme, panel);
return urlUtil.appendQueryToUrl(baseUrl, urlUtil.toUrlParams(params));
const shareUrl = urlUtil.appendQueryToUrl(baseUrl, urlUtil.toUrlParams(params));
if (shortenUrl) {
return await createShortLink(shareUrl);
}
return shareUrl;
}
export function buildSoloUrl(
@ -113,11 +109,6 @@ export function buildIframeHtml(
return '<iframe src="' + soloUrl + '" width="450" height="200" frameborder="0"></iframe>';
}
export function buildShortUrl(uid: string) {
const hostUrl = buildHostUrl();
return `${hostUrl}/goto/${uid}`;
}
export function getLocalTimeZone() {
const utcOffset = '&tz=UTC' + encodeURIComponent(dateTime().format('Z'));

View File

@ -7,11 +7,10 @@ import { css } from 'emotion';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { Icon, IconButton, LegacyForms, SetInterval, Tooltip } from '@grafana/ui';
import { DataQuery, RawTimeRange, TimeRange, TimeZone, AppEvents } from '@grafana/data';
import { DataQuery, RawTimeRange, TimeRange, TimeZone } from '@grafana/data';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { StoreState } from 'app/types/store';
import { copyStringToClipboard } from 'app/core/utils/explore';
import appEvents from 'app/core/app_events';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import {
cancelQueries,
changeDatasource,
@ -27,7 +26,6 @@ import { getTimeZone } from '../profile/state/selectors';
import { updateTimeZoneForSession } from '../profile/state/reducers';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
import kbn from '../../core/utils/kbn';
import { createShortLink } from './utils/links';
import { ExploreTimeControls } from './ExploreTimeControls';
import { LiveTailButton } from './LiveTailButton';
import { ResponsiveButton } from './ResponsiveButton';
@ -155,16 +153,6 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
return datasourceName ? exploreDatasources.find(datasource => datasource.name === datasourceName) : undefined;
};
copyAndSaveShortLink = async () => {
const shortLink = await createShortLink(window.location.href);
if (shortLink) {
copyStringToClipboard(shortLink);
appEvents.emit(AppEvents.alertSuccess, ['Shortened link copied to clipboard']);
} else {
appEvents.emit(AppEvents.alertError, ['Error generating shortened link']);
}
};
render() {
const {
datasourceMissing,
@ -277,7 +265,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
) : null}
<div className={'explore-toolbar-content-item'}>
<Tooltip content={'Copy shortened link'} placement="bottom">
<button className={'btn navbar-button'} onClick={this.copyAndSaveShortLink}>
<button className={'btn navbar-button'} onClick={() => createAndCopyShortLink(window.location.href)}>
<Icon name="share-alt" />
</button>
</Tooltip>

View File

@ -7,7 +7,7 @@ import { getDataSourceSrv } from '@grafana/runtime';
import { GrafanaTheme, AppEvents, DataSourceApi } from '@grafana/data';
import { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory';
import { createShortLink } from '../../explore/utils/links';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { copyStringToClipboard } from 'app/core/utils/explore';
import appEvents from 'app/core/app_events';
import { StoreState, CoreEvents } from 'app/types';
@ -178,15 +178,9 @@ export function RichHistoryCard(props: Props) {
appEvents.emit(AppEvents.alertSuccess, ['Query copied to clipboard']);
};
const onCreateLink = async () => {
const onCreateShortLink = async () => {
const link = createUrlFromRichHistory(query);
const shortLink = await createShortLink(link);
if (shortLink) {
copyStringToClipboard(shortLink);
appEvents.emit(AppEvents.alertSuccess, ['Shortened link copied to clipboard']);
} else {
appEvents.emit(AppEvents.alertError, ['Error generating shortened link']);
}
await createAndCopyShortLink(link);
};
const onDeleteQuery = () => {
@ -261,7 +255,9 @@ export function RichHistoryCard(props: Props) {
title={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'}
/>
<IconButton name="copy" onClick={onCopyQuery} title="Copy query to clipboard" />
{!isRemoved && <IconButton name="share-alt" onClick={onCreateLink} title="Copy shortened link to clipboard" />}
{!isRemoved && (
<IconButton name="share-alt" onClick={onCreateShortLink} title="Copy shortened link to clipboard" />
)}
<IconButton name="trash-alt" title={'Delete query'} onClick={onDeleteQuery} />
<IconButton
name={query.starred ? 'favorite' : 'star'}

View File

@ -1,8 +1,7 @@
import memoizeOne from 'memoize-one';
import { splitOpen } from '../state/actions';
import { Field, LinkModel, TimeRange, mapInternalLinkToExplore } from '@grafana/data';
import { getLinkSrv } from '../../panel/panellinks/link_srv';
import { getDataSourceSrv, getTemplateSrv, getBackendSrv, config } from '@grafana/runtime';
import { getDataSourceSrv, getTemplateSrv } from '@grafana/runtime';
/**
* Get links from the field of a dataframe and in addition check if there is associated
@ -60,23 +59,3 @@ function getTitleFromHref(href: string): string {
}
return title;
}
function buildHostUrl() {
return `${window.location.protocol}//${window.location.host}${config.appSubUrl}`;
}
function getRelativeURLPath(url: string) {
let path = url.replace(buildHostUrl(), '');
return path.startsWith('/') ? path.substring(1, path.length) : path;
}
export const createShortLink = memoizeOne(async function(path: string) {
try {
const shortUrl = await getBackendSrv().post(`/api/short-urls`, {
path: getRelativeURLPath(path),
});
return shortUrl.url;
} catch (err) {
console.error('Error when creating shortened link: ', err);
}
});