ShareModal: Share link redesign under newDashboardSharingComponent FF (#87011)

This commit is contained in:
Juan Cabanas
2024-05-03 12:02:18 -03:00
committed by GitHub
parent 196134b0b4
commit d1434fad3a
17 changed files with 334 additions and 36 deletions

View File

@@ -180,6 +180,7 @@ Experimental features might be changed or removed without prior notice.
| `queryLibrary` | Enables Query Library feature in Explore |
| `autofixDSUID` | Automatically migrates invalid datasource UIDs |
| `logsExploreTableDefaultVisualization` | Sets the logs table as default visualisation in logs explore |
| `newDashboardSharingComponent` | Enables the new sharing drawer design |
## Development feature toggles

View File

@@ -183,4 +183,5 @@ export interface FeatureToggles {
queryLibrary?: boolean;
autofixDSUID?: boolean;
logsExploreTableDefaultVisualization?: boolean;
newDashboardSharingComponent?: boolean;
}

View File

@@ -58,6 +58,15 @@ export const Pages = {
publicDashboardTag: 'data-testid public dashboard tag',
shareButton: 'data-testid share-button',
scrollContainer: 'data-testid Dashboard canvas scroll container',
newShareButton: {
container: 'data-testid new share button',
shareLink: 'data-testid new share link-button',
arrowMenu: 'data-testid new share button arrow menu',
menu: {
container: 'data-testid new share button menu',
shareInternally: 'data-testid new share button share internally',
},
},
playlistControls: {
prev: 'data-testid playlist previous dashboard button',
stop: 'data-testid playlist stop dashboard button',

View File

@@ -1233,6 +1233,13 @@ var (
Owner: grafanaObservabilityLogsSquad,
FrontendOnly: true,
},
{
Name: "newDashboardSharingComponent",
Description: "Enables the new sharing drawer design",
Stage: FeatureStageExperimental,
Owner: grafanaSharingSquad,
FrontendOnly: true,
},
}
)

View File

@@ -164,3 +164,4 @@ grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,fa
queryLibrary,experimental,@grafana/explore-squad,false,false,false
autofixDSUID,experimental,@grafana/plugins-platform-backend,false,false,false
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
newDashboardSharingComponent,experimental,@grafana/sharing-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
164 queryLibrary experimental @grafana/explore-squad false false false
165 autofixDSUID experimental @grafana/plugins-platform-backend false false false
166 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
167 newDashboardSharingComponent experimental @grafana/sharing-squad false false true

View File

@@ -666,4 +666,8 @@ const (
// FlagLogsExploreTableDefaultVisualization
// Sets the logs table as default visualisation in logs explore
FlagLogsExploreTableDefaultVisualization = "logsExploreTableDefaultVisualization"
// FlagNewDashboardSharingComponent
// Enables the new sharing drawer design
FlagNewDashboardSharingComponent = "newDashboardSharingComponent"
)

View File

@@ -2136,6 +2136,19 @@
"codeowner": "@grafana/observability-logs",
"frontend": true
}
},
{
"metadata": {
"name": "newDashboardSharingComponent",
"resourceVersion": "1713982966391",
"creationTimestamp": "2024-04-24T18:22:46Z"
},
"spec": {
"description": "Enables the new sharing drawer design",
"stage": "experimental",
"codeowner": "@grafana/sharing-squad",
"frontend": true
}
}
]
}
}

View File

@@ -1,6 +1,7 @@
import { createShortLink, createAndCopyShortLink } from './shortLinks';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => {
return {
post: () => {
@@ -8,9 +9,6 @@ jest.mock('@grafana/runtime', () => ({
},
};
},
config: {
appSubUrl: '',
},
}));
describe('createShortLink', () => {

View File

@@ -1,8 +1,12 @@
import memoizeOne from 'memoize-one';
import { getBackendSrv, config } from '@grafana/runtime';
import { UrlQueryMap } from '@grafana/data';
import { getBackendSrv, config, locationService } from '@grafana/runtime';
import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/urlBuilders';
import { dispatch } from 'app/store/store';
import { copyStringToClipboard } from './explore';
@@ -37,3 +41,54 @@ export const createAndCopyShortLink = async (path: string) => {
dispatch(notifyApp(createErrorNotification('Error generating shortened link')));
}
};
export const createAndCopyDashboardShortLink = async (
dashboard: DashboardScene,
opts: { useAbsoluteTimeRange: boolean; theme: string },
panel?: VizPanel
) => {
const shareUrl = await createDashboardShareUrl(dashboard, opts, panel);
await createAndCopyShortLink(shareUrl);
};
export const createDashboardShareUrl = async (
dashboard: DashboardScene,
opts: { useAbsoluteTimeRange: boolean; theme: string },
panel?: VizPanel
) => {
const location = locationService.getLocation();
const timeRange = sceneGraph.getTimeRange(panel ?? dashboard);
const urlParamsUpdate = getShareUrlParams(opts, timeRange, panel);
return getDashboardUrl({
uid: dashboard.state.uid,
slug: dashboard.state.meta.slug,
currentQueryParams: location.search,
updateQuery: urlParamsUpdate,
absolute: true,
});
};
export const getShareUrlParams = (
opts: { useAbsoluteTimeRange: boolean; theme: string },
timeRange: SceneTimeRangeLike,
panel?: VizPanel
) => {
const urlParamsUpdate: UrlQueryMap = {};
if (panel) {
urlParamsUpdate.viewPanel = panel.state.key;
}
if (opts.useAbsoluteTimeRange) {
urlParamsUpdate.from = timeRange.state.value.from.toISOString();
urlParamsUpdate.to = timeRange.state.value.to.toISOString();
}
if (opts.theme !== 'current') {
urlParamsUpdate.theme = opts.theme;
}
return urlParamsUpdate;
};

View File

@@ -5,6 +5,7 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
@@ -25,7 +26,7 @@ jest.mock('app/features/playlist/PlaylistSrv', () => ({
}));
describe('NavToolbarActions', () => {
describe('Give an already saved dashboard', () => {
describe('Given an already saved dashboard', () => {
it('Should show correct buttons when not in editing', async () => {
setup();
@@ -35,7 +36,7 @@ describe('NavToolbarActions', () => {
expect(await screen.findByText('Share')).toBeInTheDocument();
});
it('Should the correct buttons when playing a playlist', async () => {
it('Should show the correct buttons when playing a playlist', async () => {
jest.mocked(playlistSrv).useState.mockReturnValueOnce({ isPlaying: true });
setup();
@@ -101,6 +102,24 @@ describe('NavToolbarActions', () => {
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.next)).not.toBeInTheDocument();
});
});
describe('Given new sharing button', () => {
it('Should show old share button when newDashboardSharingComponent FF is disabled', async () => {
setup();
expect(await screen.findByText('Share')).toBeInTheDocument();
const newShareButton = screen.queryByTestId(selectors.pages.Dashboard.DashNav.newShareButton.container);
expect(newShareButton).not.toBeInTheDocument();
});
it('Should show new share button when newDashboardSharingComponent FF is enabled', async () => {
config.featureToggles.newDashboardSharingComponent = true;
setup();
expect(screen.queryByTestId(selectors.pages.Dashboard.DashNav.shareButton)).not.toBeInTheDocument();
const newShareButton = screen.getByTestId(selectors.pages.Dashboard.DashNav.newShareButton.container);
expect(newShareButton).toBeInTheDocument();
});
});
});
let cleanUp = () => {};

View File

@@ -23,6 +23,7 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { PanelEditor } from '../panel-edit/PanelEditor';
import ShareButton from '../sharing/ShareButton/ShareButton';
import { ShareModal } from '../sharing/ShareModal';
import { DashboardInteractions } from '../utils/interactions';
import { DynamicDashNavButtonModel, dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
@@ -303,9 +304,10 @@ export function ToolbarActions({ dashboard }: Props) {
),
});
const showShareButton = uid && !isEditing && !meta.isSnapshot && !isPlaying;
toolbarActions.push({
group: 'main-buttons',
condition: uid && !isEditing && !meta.isSnapshot && !isPlaying,
condition: !config.featureToggles.newDashboardSharingComponent && showShareButton,
render: () => (
<Button
key="share-dashboard-button"
@@ -335,7 +337,7 @@ export function ToolbarActions({ dashboard }: Props) {
tooltip="Enter edit mode"
key="edit"
className={styles.buttonWithExtraMargin}
variant="primary"
variant={config.featureToggles.newDashboardSharingComponent ? 'secondary' : 'primary'}
size="sm"
data-testid={selectors.components.NavToolbar.editDashboard.editButton}
>
@@ -344,6 +346,12 @@ export function ToolbarActions({ dashboard }: Props) {
),
});
toolbarActions.push({
group: 'new-share-dashboard-button',
condition: config.featureToggles.newDashboardSharingComponent && showShareButton,
render: () => <ShareButton key="new-share-dashboard-button" dashboard={dashboard} />,
});
toolbarActions.push({
group: 'settings',
condition: isEditing && dashboard.canEditDashboard() && isShowingDashboard,

View File

@@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes';
import { DashboardGridItem } from '../../scene/DashboardGridItem';
import { DashboardScene } from '../../scene/DashboardScene';
import ShareButton from './ShareButton';
const createAndCopyDashboardShortLinkMock = jest.fn();
jest.mock('app/core/utils/shortLinks', () => ({
...jest.requireActual('app/core/utils/shortLinks'),
createAndCopyDashboardShortLink: () => createAndCopyDashboardShortLinkMock(),
}));
const selector = e2eSelectors.pages.Dashboard.DashNav.newShareButton;
describe('ShareButton', () => {
it('should render share link button and menu', async () => {
setup();
expect(await screen.findByTestId(selector.shareLink)).toBeInTheDocument();
expect(await screen.findByTestId(selector.arrowMenu)).toBeInTheDocument();
});
it('should call createAndCopyDashboardShortLink when share link clicked', async () => {
setup();
const shareLink = await screen.findByTestId(selector.shareLink);
await userEvent.click(shareLink);
expect(createAndCopyDashboardShortLinkMock).toHaveBeenCalled();
});
it('should render menu when arrow button clicked', async () => {
setup();
const arrowMenu = await screen.findByTestId(selector.arrowMenu);
await userEvent.click(arrowMenu);
expect(await screen.findByTestId(selector.menu.container)).toBeInTheDocument();
});
});
function setup() {
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 DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: panel,
}),
],
}),
});
render(<ShareButton dashboard={dashboard} />);
}

View File

@@ -0,0 +1,42 @@
import React, { useCallback, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Button, ButtonGroup, Dropdown } from '@grafana/ui';
import { createAndCopyDashboardShortLink } from 'app/core/utils/shortLinks';
import { DashboardScene } from '../../scene/DashboardScene';
import { DashboardInteractions } from '../../utils/interactions';
import ShareMenu from './ShareMenu';
const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton;
export default function ShareButton({ dashboard }: { dashboard: DashboardScene }) {
const [isOpen, setIsOpen] = useState(false);
const [_, buildUrl] = useAsyncFn(async () => {
return await createAndCopyDashboardShortLink(dashboard, { useAbsoluteTimeRange: true, theme: 'current' });
}, [dashboard]);
const onMenuClick = useCallback((isOpen: boolean) => {
if (isOpen) {
DashboardInteractions.toolbarShareClick();
}
setIsOpen(isOpen);
}, []);
const MenuActions = () => <ShareMenu dashboard={dashboard} />;
return (
<ButtonGroup data-testid={newShareButtonSelector.container}>
<Button data-testid={newShareButtonSelector.shareLink} size="sm" tooltip="Copy shortened URL" onClick={buildUrl}>
Share
</Button>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={onMenuClick}>
<Button data-testid={newShareButtonSelector.arrowMenu} size="sm" icon={isOpen ? 'angle-up' : 'angle-down'} />
</Dropdown>
</ButtonGroup>
);
}

View File

@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes';
import { DashboardGridItem } from '../../scene/DashboardGridItem';
import { DashboardScene } from '../../scene/DashboardScene';
import ShareMenu from './ShareMenu';
const createAndCopyDashboardShortLinkMock = jest.fn();
jest.mock('app/core/utils/shortLinks', () => ({
...jest.requireActual('app/core/utils/shortLinks'),
createAndCopyDashboardShortLink: () => createAndCopyDashboardShortLinkMock(),
}));
const selector = e2eSelectors.pages.Dashboard.DashNav.newShareButton.menu;
describe('ShareMenu', () => {
it('should call createAndCopyDashboardShortLink when share internally clicked', async () => {
setup();
const shareLink = await screen.findByTestId(selector.shareInternally);
await userEvent.click(shareLink);
expect(createAndCopyDashboardShortLinkMock).toHaveBeenCalled();
});
});
function setup() {
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 DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: panel,
}),
],
}),
});
render(<ShareMenu dashboard={dashboard} />);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { useAsyncFn } from 'react-use';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Menu } from '@grafana/ui';
import { createAndCopyDashboardShortLink } from '../../../../core/utils/shortLinks';
import { DashboardScene } from '../../scene/DashboardScene';
const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton.menu;
export default function ShareMenu({ dashboard }: { dashboard: DashboardScene }) {
const [_, buildUrl] = useAsyncFn(async () => {
return await createAndCopyDashboardShortLink(dashboard, { useAbsoluteTimeRange: true, theme: 'current' });
}, [dashboard]);
return (
<Menu data-testid={newShareButtonSelector.container}>
<Menu.Item
testId={newShareButtonSelector.shareInternally}
label="Share internally"
description="Copy link"
icon="building"
onClick={buildUrl}
/>
</Menu>
);
}

View File

@@ -14,6 +14,7 @@ import { DashboardScene } from '../scene/DashboardScene';
import { ShareLinkTab } from './ShareLinkTab';
jest.mock('app/core/utils/shortLinks', () => ({
...jest.requireActual('app/core/utils/shortLinks'),
createShortLink: jest.fn().mockResolvedValue(`http://localhost:3000/goto/shortend-uid`),
}));

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { dateTime, UrlQueryMap } from '@grafana/data';
import { dateTime } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config, locationService } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, 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 { createDashboardShareUrl, createShortLink, getShareUrlParams } from 'app/core/utils/shortLinks';
import { ThemePicker } from 'app/features/dashboard/components/ShareModal/ThemePicker';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
@@ -51,36 +51,17 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> {
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,
slug: dashboard.state.meta.slug,
currentQueryParams: location.search,
updateQuery: urlParamsUpdate,
absolute: true,
});
const opts = { useAbsoluteTimeRange, theme: selectedTheme };
let shareUrl = await createDashboardShareUrl(dashboard, opts, panel);
if (useShortUrl) {
shareUrl = await createShortLink(shareUrl);
}
const timeRange = sceneGraph.getTimeRange(panel ?? dashboard);
const urlParamsUpdate = getShareUrlParams(opts, timeRange, panel);
// the image panel solo route uses panelId instead of viewPanel
let imageQueryParams = urlParamsUpdate;
if (panel) {