Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ShareModal: Share link redesign under newDashboardSharingComponent FF #87011

Merged
merged 15 commits into from May 3, 2024
Merged
Expand Up @@ -177,6 +177,7 @@ Experimental features might be changed or removed without prior notice.
| `accessActionSets` | Introduces action sets for resource permissions |
| `disableNumericMetricsSortingInExpressions` | In server-side expressions, disable the sorting of numeric-kind metrics by their metric name or labels. |
| `queryLibrary` | Enables Query Library feature in Explore |
| `newDashboardSharingComponent` | Enables the new sharing drawer design |

## Development feature toggles

Expand Down
1 change: 1 addition & 0 deletions packages/grafana-data/src/types/featureToggles.gen.ts
Expand Up @@ -181,4 +181,5 @@ export interface FeatureToggles {
disableNumericMetricsSortingInExpressions?: boolean;
grafanaManagedRecordingRules?: boolean;
queryLibrary?: boolean;
newDashboardSharingComponent?: boolean;
}
9 changes: 9 additions & 0 deletions packages/grafana-e2e-selectors/src/selectors/pages.ts
Expand Up @@ -57,6 +57,15 @@ export const Pages = {
navV2: 'data-testid Dashboard navigation',
publicDashboardTag: 'data-testid public dashboard tag',
shareButton: 'data-testid share-button',
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',
Expand Down
7 changes: 7 additions & 0 deletions pkg/services/featuremgmt/registry.go
Expand Up @@ -1219,6 +1219,13 @@ var (
FrontendOnly: false,
AllowSelfServe: false,
},
{
Name: "newDashboardSharingComponent",
Description: "Enables the new sharing drawer design",
Stage: FeatureStageExperimental,
Owner: grafanaSharingSquad,
FrontendOnly: true,
},
}
)

Expand Down
1 change: 1 addition & 0 deletions pkg/services/featuremgmt/toggles_gen.csv
Expand Up @@ -162,3 +162,4 @@ accessActionSets,experimental,@grafana/identity-access-team,false,false,false
disableNumericMetricsSortingInExpressions,experimental,@grafana/observability-metrics,false,true,false
grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,false
queryLibrary,experimental,@grafana/explore-squad,false,false,false
newDashboardSharingComponent,experimental,@grafana/sharing-squad,false,false,true
4 changes: 4 additions & 0 deletions pkg/services/featuremgmt/toggles_gen.go
Expand Up @@ -658,4 +658,8 @@ const (
// FlagQueryLibrary
// Enables Query Library feature in Explore
FlagQueryLibrary = "queryLibrary"

// FlagNewDashboardSharingComponent
// Enables the new sharing drawer design
FlagNewDashboardSharingComponent = "newDashboardSharingComponent"
)
13 changes: 13 additions & 0 deletions pkg/services/featuremgmt/toggles_gen.json
Expand Up @@ -2103,6 +2103,19 @@
"stage": "experimental",
"codeowner": "@grafana/explore-squad"
}
},
{
"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
}
}
]
}
4 changes: 1 addition & 3 deletions public/app/core/utils/shortLinks.test.ts
@@ -1,16 +1,14 @@
import { createShortLink, createAndCopyShortLink } from './shortLinks';

jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => {
return {
post: () => {
return Promise.resolve({ url: 'www.short.com' });
},
};
},
config: {
appSubUrl: '',
},
}));

describe('createShortLink', () => {
Expand Down
57 changes: 56 additions & 1 deletion public/app/core/utils/shortLinks.ts
@@ -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';
Expand Down Expand Up @@ -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;
};
Expand Up @@ -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';
Expand All @@ -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();

Expand All @@ -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();

Expand Down Expand Up @@ -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 = await screen.findByTestId(selectors.pages.Dashboard.DashNav.newShareButton.container);
expect(newShareButton).toBeInTheDocument();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity:

  • Why are we using screen.queryByTestId(selectors.pages.Dashboard.DashNav.shareButton) instead of screen.findByText('Share') as above to retrieve the old share button?
  • Why are we using findByTestId instead of queryByTestId as above to retrieve the new share button?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • It's common to change the button text (and in this test, we're not testing that the button has "Share" text). Therefore, the test would fail. Actually, in the middle of the PR I changed "Share Link" to "Share" and the test failed when I was testing another thing.
  • FindBy is async. However, the component should be already mounted. I can change it

});
});
});

let cleanUp = () => {};
Expand Down
14 changes: 12 additions & 2 deletions public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
Expand Up @@ -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';
Expand Down Expand Up @@ -302,12 +303,14 @@ export function ToolbarActions({ dashboard }: Props) {

toolbarActions.push({
group: 'main-buttons',
condition: uid && !isEditing && !meta.isSnapshot && !isPlaying,
condition:
!config.featureToggles.newDashboardSharingComponent && uid && !isEditing && !meta.isSnapshot && !isPlaying,
render: () => (
<Button
key="share-dashboard-button"
tooltip={t('dashboard.toolbar.share', 'Share dashboard')}
size="sm"
data-testid={selectors.pages.Dashboard.DashNav.shareButton}
className={styles.buttonWithExtraMargin}
fill="outline"
onClick={() => {
Expand All @@ -331,14 +334,21 @@ export function ToolbarActions({ dashboard }: Props) {
tooltip="Enter edit mode"
key="edit"
className={styles.buttonWithExtraMargin}
variant="primary"
variant={config.featureToggles.newDashboardSharingComponent ? 'secondary' : 'primary'}
juanicabanas marked this conversation as resolved.
Show resolved Hide resolved
size="sm"
>
Edit
</Button>
),
});

toolbarActions.push({
group: 'new-share-dashboard-button',
condition:
config.featureToggles.newDashboardSharingComponent && uid && !isEditing && !meta.isSnapshot && !isPlaying,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this uid && !isEditing && !meta.isSnapshot && !isPlaying can be extracted into a variable to not be duplicated between the old and the new share buttons.

render: () => <ShareButton key="new-share-dashboard-button" dashboard={dashboard} />,
});

toolbarActions.push({
group: 'settings',
condition: isEditing && dashboard.canEditDashboard() && isShowingDashboard,
Expand Down
@@ -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} />);
}
@@ -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>
);
}