mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
6a37a56d68
commit
1d1bdaab37
@ -208,7 +208,7 @@ export const Pages = {
|
|||||||
linkToRenderedImage: 'Link to rendered image',
|
linkToRenderedImage: 'Link to rendered image',
|
||||||
},
|
},
|
||||||
ShareDashboardModal: {
|
ShareDashboardModal: {
|
||||||
shareButton: 'Share dashboard or panel',
|
shareButton: 'Share dashboard',
|
||||||
PublicDashboard: {
|
PublicDashboard: {
|
||||||
Tab: 'Tab Public dashboard',
|
Tab: 'Tab Public dashboard',
|
||||||
WillBePublicCheckbox: 'data-testid public dashboard will be public checkbox',
|
WillBePublicCheckbox: 'data-testid public dashboard will be public checkbox',
|
||||||
|
@ -37,8 +37,8 @@ export interface DashboardSceneState extends SceneObjectState {
|
|||||||
inspectPanelKey?: string;
|
inspectPanelKey?: string;
|
||||||
/** Panel to view in full screen */
|
/** Panel to view in full screen */
|
||||||
viewPanelKey?: string;
|
viewPanelKey?: string;
|
||||||
/** Scene object that handles the current drawer */
|
/** Scene object that handles the current drawer or modal */
|
||||||
drawer?: SceneObject;
|
overlay?: SceneObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||||
@ -129,7 +129,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public onSave = () => {
|
public onSave = () => {
|
||||||
this.setState({ drawer: new SaveDashboardDrawer({ dashboardRef: new SceneObjectRef(this) }) });
|
this.setState({ overlay: new SaveDashboardDrawer({ dashboardRef: new SceneObjectRef(this) }) });
|
||||||
};
|
};
|
||||||
|
|
||||||
public getPageNav(location: H.Location) {
|
public getPageNav(location: H.Location) {
|
||||||
@ -184,4 +184,12 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
public getInitialState(): DashboardSceneState | undefined {
|
public getInitialState(): DashboardSceneState | undefined {
|
||||||
return this._initialState;
|
return this._initialState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public showModal(modal: SceneObject) {
|
||||||
|
this.setState({ overlay: modal });
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeModal() {
|
||||||
|
this.setState({ overlay: undefined });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ import { DashboardScene } from './DashboardScene';
|
|||||||
import { NavToolbarActions } from './NavToolbarActions';
|
import { NavToolbarActions } from './NavToolbarActions';
|
||||||
|
|
||||||
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
|
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
|
||||||
const { controls, viewPanelKey: viewPanelId, drawer } = model.useState();
|
const { controls, viewPanelKey: viewPanelId, overlay } = model.useState();
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const pageNav = model.getPageNav(location);
|
const pageNav = model.getPageNav(location);
|
||||||
@ -35,7 +35,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CustomScrollbar>
|
</CustomScrollbar>
|
||||||
{drawer && <drawer.Component model={drawer} />}
|
{overlay && <overlay.Component model={overlay} />}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -34,10 +34,10 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update.inspectPanelKey = values.inspect;
|
update.inspectPanelKey = values.inspect;
|
||||||
update.drawer = new PanelInspectDrawer({ panelRef: new SceneObjectRef(panel) });
|
update.overlay = new PanelInspectDrawer({ panelRef: new SceneObjectRef(panel) });
|
||||||
} else if (inspectPanelId) {
|
} else if (inspectPanelId) {
|
||||||
update.inspectPanelKey = undefined;
|
update.inspectPanelKey = undefined;
|
||||||
update.drawer = undefined;
|
update.overlay = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle view panel state
|
// Handle view panel state
|
||||||
|
@ -4,8 +4,11 @@ import { locationService } from '@grafana/runtime';
|
|||||||
import { Button } from '@grafana/ui';
|
import { Button } from '@grafana/ui';
|
||||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||||
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
|
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
|
||||||
|
import { t } from 'app/core/internationalization';
|
||||||
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
|
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
|
||||||
|
|
||||||
|
import { ShareModal } from '../sharing/ShareModal';
|
||||||
|
|
||||||
import { DashboardScene } from './DashboardScene';
|
import { DashboardScene } from './DashboardScene';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -17,6 +20,17 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
|||||||
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
|
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
|
||||||
|
|
||||||
if (uid) {
|
if (uid) {
|
||||||
|
toolbarActions.push(
|
||||||
|
<DashNavButton
|
||||||
|
tooltip={t('dashboard.toolbar.share', 'Share dashboard')}
|
||||||
|
icon="share-alt"
|
||||||
|
iconSize="lg"
|
||||||
|
onClick={() => {
|
||||||
|
dashboard.showModal(new ShareModal({ dashboardRef: dashboard.getRef() }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
toolbarActions.push(
|
toolbarActions.push(
|
||||||
<DashNavButton
|
<DashNavButton
|
||||||
key="button-scenes"
|
key="button-scenes"
|
||||||
|
@ -38,13 +38,15 @@ describe('panelMenuBehavior', () => {
|
|||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 1));
|
await new Promise((r) => setTimeout(r, 1));
|
||||||
|
|
||||||
expect(menu.state.items?.length).toBe(4);
|
expect(menu.state.items?.length).toBe(5);
|
||||||
// verify view panel url keeps url params and adds viewPanel=<panel-key>
|
// verify view panel url keeps url params and adds viewPanel=<panel-key>
|
||||||
expect(menu.state.items?.[0].href).toBe('/scenes/dashboard/dash-1?from=now-5m&to=now&viewPanel=panel-12');
|
expect(menu.state.items?.[0].href).toBe('/scenes/dashboard/dash-1?from=now-5m&to=now&viewPanel=panel-12');
|
||||||
// verify edit url keeps url time range
|
// verify edit url keeps url time range
|
||||||
expect(menu.state.items?.[1].href).toBe('/scenes/dashboard/dash-1/panel-edit/12?from=now-5m&to=now');
|
expect(menu.state.items?.[1].href).toBe('/scenes/dashboard/dash-1/panel-edit/12?from=now-5m&to=now');
|
||||||
|
// verify share
|
||||||
|
expect(menu.state.items?.[2].text).toBe('Share');
|
||||||
// verify explore url
|
// verify explore url
|
||||||
expect(menu.state.items?.[2].href).toBe('/explore');
|
expect(menu.state.items?.[3].href).toBe('/explore');
|
||||||
|
|
||||||
// Verify explore url is called with correct arguments
|
// Verify explore url is called with correct arguments
|
||||||
const getExploreArgs: GetExploreUrlArguments = mocks.getExploreUrl.mock.calls[0][0];
|
const getExploreArgs: GetExploreUrlArguments = mocks.getExploreUrl.mock.calls[0][0];
|
||||||
@ -53,7 +55,7 @@ describe('panelMenuBehavior', () => {
|
|||||||
expect(getExploreArgs.scopedVars?.__sceneObject?.value).toBe(panel);
|
expect(getExploreArgs.scopedVars?.__sceneObject?.value).toBe(panel);
|
||||||
|
|
||||||
// verify inspect url keeps url params and adds inspect=<panel-key>
|
// verify inspect url keeps url params and adds inspect=<panel-key>
|
||||||
expect(menu.state.items?.[3].href).toBe('/scenes/dashboard/dash-1?from=now-5m&to=now&inspect=panel-12');
|
expect(menu.state.items?.[4].href).toBe('/scenes/dashboard/dash-1?from=now-5m&to=now&inspect=panel-12');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import { t } from 'app/core/internationalization';
|
|||||||
import { getExploreUrl } from 'app/core/utils/explore';
|
import { getExploreUrl } from 'app/core/utils/explore';
|
||||||
import { InspectTab } from 'app/features/inspector/types';
|
import { InspectTab } from 'app/features/inspector/types';
|
||||||
|
|
||||||
|
import { ShareModal } from '../sharing/ShareModal';
|
||||||
import { getDashboardUrl, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
|
import { getDashboardUrl, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
|
||||||
|
|
||||||
import { DashboardScene } from './DashboardScene';
|
import { DashboardScene } from './DashboardScene';
|
||||||
@ -47,6 +48,16 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
|||||||
currentQueryParams: location.search,
|
currentQueryParams: location.search,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
text: t('panel.header-menu.share', `Share`),
|
||||||
|
iconClassName: 'share-alt',
|
||||||
|
onClick: () => {
|
||||||
|
reportInteraction('dashboards_panelheader_menu', { item: 'share' });
|
||||||
|
dashboard.showModal(new ShareModal({ panelRef: panel.getRef(), dashboardRef: dashboard.getRef() }));
|
||||||
|
},
|
||||||
|
shortcut: 'p s',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contextSrv.hasAccessToExplore() && !panelPlugin?.meta.skipDataQuery && queryRunner) {
|
if (contextSrv.hasAccessToExplore() && !panelPlugin?.meta.skipDataQuery && queryRunner) {
|
||||||
|
@ -15,7 +15,7 @@ interface SaveDashboardDrawerState extends SceneObjectState {
|
|||||||
|
|
||||||
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
|
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
|
||||||
onClose = () => {
|
onClose = () => {
|
||||||
this.state.dashboardRef.resolve().setState({ drawer: undefined });
|
this.state.dashboardRef.resolve().setState({ overlay: undefined });
|
||||||
};
|
};
|
||||||
|
|
||||||
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
|
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
|
||||||
|
@ -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;
|
||||||
|
}
|
255
public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx
Normal file
255
public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx
Normal file
@ -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;
|
||||||
|
}
|
117
public/app/features/dashboard-scene/sharing/ShareModal.tsx
Normal file
117
public/app/features/dashboard-scene/sharing/ShareModal.tsx
Normal file
@ -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>;
|
||||||
|
};
|
||||||
|
}
|
5
public/app/features/dashboard-scene/sharing/types.ts
Normal file
5
public/app/features/dashboard-scene/sharing/types.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SceneObject, SceneObjectState } from '@grafana/scenes';
|
||||||
|
|
||||||
|
export interface SceneShareTab<T extends SceneObjectState = SceneObjectState> extends SceneObject<T> {
|
||||||
|
getTabLabel(): string;
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { UrlQueryMap, urlUtil } from '@grafana/data';
|
import { UrlQueryMap, urlUtil } from '@grafana/data';
|
||||||
import { locationSearchToObject } from '@grafana/runtime';
|
import { config, locationSearchToObject } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
MultiValueVariable,
|
MultiValueVariable,
|
||||||
SceneDataTransformer,
|
SceneDataTransformer,
|
||||||
@ -82,14 +82,35 @@ export interface DashboardUrlOptions {
|
|||||||
uid?: string;
|
uid?: string;
|
||||||
subPath?: string;
|
subPath?: string;
|
||||||
updateQuery?: UrlQueryMap;
|
updateQuery?: UrlQueryMap;
|
||||||
/**
|
/** Set to location.search to preserve current params */
|
||||||
* Set to location.search to preserve current params
|
|
||||||
*/
|
|
||||||
currentQueryParams: string;
|
currentQueryParams: string;
|
||||||
|
/** * Returns solo panel route instead */
|
||||||
|
soloRoute?: boolean;
|
||||||
|
/** return render url */
|
||||||
|
render?: boolean;
|
||||||
|
/** Return an absolute URL */
|
||||||
|
absolute?: boolean;
|
||||||
|
// Add tz to query params
|
||||||
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDashboardUrl(options: DashboardUrlOptions) {
|
export function getDashboardUrl(options: DashboardUrlOptions) {
|
||||||
const url = `/scenes/dashboard/${options.uid}${options.subPath ?? ''}`;
|
let path = `/scenes/dashboard/${options.uid}${options.subPath ?? ''}`;
|
||||||
|
|
||||||
|
if (options.soloRoute) {
|
||||||
|
path = `/d-solo/${options.uid}${options.subPath ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.render) {
|
||||||
|
path = '/render' + path;
|
||||||
|
|
||||||
|
options.updateQuery = {
|
||||||
|
...options.updateQuery,
|
||||||
|
width: 1000,
|
||||||
|
height: 500,
|
||||||
|
tz: options.timeZone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const params = options.currentQueryParams ? locationSearchToObject(options.currentQueryParams) : {};
|
const params = options.currentQueryParams ? locationSearchToObject(options.currentQueryParams) : {};
|
||||||
|
|
||||||
@ -104,7 +125,13 @@ export function getDashboardUrl(options: DashboardUrlOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return urlUtil.renderUrl(url, params);
|
const relativeUrl = urlUtil.renderUrl(path, params);
|
||||||
|
|
||||||
|
if (options.absolute) {
|
||||||
|
return config.appUrl + relativeUrl.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativeUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMultiVariableValues(variable: MultiValueVariable) {
|
export function getMultiVariableValues(variable: MultiValueVariable) {
|
||||||
@ -123,15 +150,6 @@ export function getMultiVariableValues(variable: MultiValueVariable) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDashboardSceneFor(sceneObject: SceneObject): DashboardScene {
|
|
||||||
const root = sceneObject.getRoot();
|
|
||||||
if (root instanceof DashboardScene) {
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('SceneObject root is not a DashboardScene');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQueryRunner | undefined {
|
export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQueryRunner | undefined {
|
||||||
if (!sceneObject) {
|
if (!sceneObject) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -147,3 +165,12 @@ export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQu
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDashboardSceneFor(sceneObject: SceneObject): DashboardScene {
|
||||||
|
const root = sceneObject.getRoot();
|
||||||
|
if (root instanceof DashboardScene) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('SceneObject root is not a DashboardScene');
|
||||||
|
}
|
||||||
|
@ -28,7 +28,7 @@ export const ShareButton = ({ dashboard }: { dashboard: DashboardModel }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashNavButton
|
<DashNavButton
|
||||||
tooltip={t('dashboard.toolbar.share', 'Share dashboard or panel')}
|
tooltip={t('dashboard.toolbar.share', 'Share dashboard')}
|
||||||
icon="share-alt"
|
icon="share-alt"
|
||||||
iconSize="lg"
|
iconSize="lg"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { Modal, ModalTabsHeader, TabContent, Themeable2, withTheme2 } from '@grafana/ui';
|
import { Modal, ModalTabsHeader, TabContent, Themeable2, withTheme2 } from '@grafana/ui';
|
||||||
import { config } from 'app/core/config';
|
import { config } from 'app/core/config';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
@ -130,19 +128,12 @@ class UnthemedShareModal extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { dashboard, panel, theme } = this.props;
|
const { dashboard, panel } = this.props;
|
||||||
const styles = getStyles(theme);
|
|
||||||
const activeTabModel = this.getActiveTab();
|
const activeTabModel = this.getActiveTab();
|
||||||
const ActiveTab = activeTabModel.component;
|
const ActiveTab = activeTabModel.component;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal isOpen={true} title={this.renderTitle()} onDismiss={this.props.onDismiss}>
|
||||||
isOpen={true}
|
|
||||||
title={this.renderTitle()}
|
|
||||||
onDismiss={this.props.onDismiss}
|
|
||||||
className={styles.container}
|
|
||||||
contentClassName={styles.content}
|
|
||||||
>
|
|
||||||
<TabContent>
|
<TabContent>
|
||||||
<ActiveTab dashboard={dashboard} panel={panel} onDismiss={this.props.onDismiss} />
|
<ActiveTab dashboard={dashboard} panel={panel} onDismiss={this.props.onDismiss} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
@ -152,16 +143,3 @@ class UnthemedShareModal extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShareModal = withTheme2(UnthemedShareModal);
|
export const ShareModal = withTheme2(UnthemedShareModal);
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
|
||||||
return {
|
|
||||||
container: css({
|
|
||||||
label: 'shareModalContainer',
|
|
||||||
paddingTop: theme.spacing(1),
|
|
||||||
}),
|
|
||||||
content: css({
|
|
||||||
label: 'shareModalContent',
|
|
||||||
padding: theme.spacing(3, 2, 2, 2),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
@ -211,7 +211,7 @@
|
|||||||
"refresh": "Refresh dashboard",
|
"refresh": "Refresh dashboard",
|
||||||
"save": "Save dashboard",
|
"save": "Save dashboard",
|
||||||
"settings": "Dashboard settings",
|
"settings": "Dashboard settings",
|
||||||
"share": "Share dashboard or panel",
|
"share": "Share dashboard",
|
||||||
"unmark-favorite": "Unmark as favorite"
|
"unmark-favorite": "Unmark as favorite"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -211,7 +211,7 @@
|
|||||||
"refresh": "Ŗęƒřęşĥ đäşĥþőäřđ",
|
"refresh": "Ŗęƒřęşĥ đäşĥþőäřđ",
|
||||||
"save": "Ŝävę đäşĥþőäřđ",
|
"save": "Ŝävę đäşĥþőäřđ",
|
||||||
"settings": "Đäşĥþőäřđ şęŧŧįʼnģş",
|
"settings": "Đäşĥþőäřđ şęŧŧįʼnģş",
|
||||||
"share": "Ŝĥäřę đäşĥþőäřđ őř päʼnęľ",
|
"share": "Ŝĥäřę đäşĥþőäřđ",
|
||||||
"unmark-favorite": "Ůʼnmäřĸ äş ƒävőřįŧę"
|
"unmark-favorite": "Ůʼnmäřĸ äş ƒävőřįŧę"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user