PublicDashboards: Email sharing (#63762)

Feature for sharing a public dashboard by email
This commit is contained in:
juanicabanas 2023-02-28 09:02:23 -03:00 committed by GitHub
parent 89ad81b15a
commit 4e74768530
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 406 additions and 105 deletions

View File

@ -43,21 +43,26 @@ If you are using Docker, use an environment variable to enable public dashboards
#### Make a dashboard public
- Click on the sharing icon to the right of the dashboard title.
- Click on the Public Dashboard tab.
- Click on the **Public dashboard** tab.
- Acknowledge the implications of making the dashboard public by checking all the checkboxes.
- Turn on the Enabled toggle.
- Click `Save Sharing Configuration` to make the dashboard public and make your link live.
- Click **Generate public URL** to make the dashboard public and make your link live.
- Copy the public dashboard link if you'd like to share it. You can always come back later for it.
#### Pause access
- Click on the sharing icon to the right of the dashboard title.
- Click on the **Public dashboard** tab.
- Enable the **Pause sharing dashboard** toggle.
- The dashboard is no longer accessible, even with the link, until you make it shareable again.
#### Revoke access
- Click on the sharing icon to the right of the dashboard title.
- Click on the Public Dashboard tab.
- Turn off the Enabled toggle.
- Click `Save Sharing Configuration` to save your changes.
- Anyone with the link will not be able to access the dashboard publicly anymore.
- Click on the **Public dashboard** tab.
- Click **Revoke public URL** to delete the public dashboard.
- The link no longer works. You must create a new public URL as in [Make a dashboard public](#make-a-dashboard-public).
#### Supported Datasources
#### Supported datasources
Public dashboards _should_ work with any datasource that has the properties `backend` and `alerting` both set to true in it's `package.json`. However, this cannot always be
guaranteed because plugin developers can override this functionality. The following lists include data sources confirmed to work with public dashboards and data sources that should work but have not been confirmed as compatible.

View File

@ -200,6 +200,12 @@ export const Pages = {
UnsupportedDataSourcesWarningAlert: 'data-testid public dashboard unsupported data sources alert',
NoUpsertPermissionsWarningAlert: 'data-testid public dashboard no upsert permissions alert',
EnableTimeRangeSwitch: 'data-testid public dashboard on off switch for time range',
EmailSharingConfiguration: {
ShareType: 'data-testid public dashboard share type',
EmailSharingInput: 'data-testid public dashboard email sharing input',
EmailSharingInviteButton: 'data-testid public dashboard email sharing invite button',
EmailSharingList: 'data-testid public dashboard email sharing list',
},
},
},
Explore: {

View File

@ -1,7 +1,7 @@
import { BaseQueryFn, createApi, retry } from '@reduxjs/toolkit/query/react';
import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react';
import { lastValueFrom } from 'rxjs';
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime/src';
import { BackendSrvRequest, FetchError, getBackendSrv, isFetchError } from '@grafana/runtime/src';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
import {
@ -16,6 +16,10 @@ type ReqOptions = {
showErrorAlert?: boolean;
};
function isFetchBaseQueryError(error: unknown): error is { error: FetchError } {
return typeof error === 'object' && error != null && 'error' in error;
}
const backendSrvBaseQuery =
({ baseUrl }: { baseUrl: string }): BaseQueryFn<BackendSrvRequest & ReqOptions> =>
async (requestOptions) => {
@ -33,17 +37,17 @@ const backendSrvBaseQuery =
}
};
const getConfigError = (err: { status: number }) => ({ error: err.status !== 404 ? err : null });
const getConfigError = (err: unknown) => ({ error: isFetchError(err) && err.status !== 404 ? err : null });
export const publicDashboardApi = createApi({
reducerPath: 'publicDashboardApi',
baseQuery: retry(backendSrvBaseQuery({ baseUrl: '/api/dashboards' }), { maxRetries: 0 }),
baseQuery: backendSrvBaseQuery({ baseUrl: '/api' }),
tagTypes: ['PublicDashboard', 'AuditTablePublicDashboard'],
refetchOnMountOrArgChange: true,
endpoints: (builder) => ({
getPublicDashboard: builder.query<PublicDashboard | undefined, string>({
query: (dashboardUid) => ({
url: `/uid/${dashboardUid}/public-dashboards`,
url: `/dashboards/uid/${dashboardUid}/public-dashboards`,
manageError: getConfigError,
showErrorAlert: false,
}),
@ -51,9 +55,9 @@ export const publicDashboardApi = createApi({
try {
await queryFulfilled;
} catch (e) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const customError = e as { error: { data: { message: string } } };
dispatch(notifyApp(createErrorNotification(customError?.error?.data?.message)));
if (isFetchBaseQueryError(e) && isFetchError(e.error)) {
dispatch(notifyApp(createErrorNotification(e.error.data.message)));
}
}
},
providesTags: (result, error, dashboardUid) => [{ type: 'PublicDashboard', id: dashboardUid }],
@ -63,7 +67,7 @@ export const publicDashboardApi = createApi({
{ dashboard: DashboardModel; payload: Partial<PublicDashboardSettings> }
>({
query: (params) => ({
url: `/uid/${params.dashboard.uid}/public-dashboards`,
url: `/dashboards/uid/${params.dashboard.uid}/public-dashboards`,
method: 'POST',
data: params.payload,
}),
@ -81,11 +85,10 @@ export const publicDashboardApi = createApi({
}),
updatePublicDashboard: builder.mutation<PublicDashboard, { dashboard: DashboardModel; payload: PublicDashboard }>({
query: (params) => ({
url: `/uid/${params.dashboard.uid}/public-dashboards/${params.payload.uid}`,
url: `/dashboards/uid/${params.dashboard.uid}/public-dashboards/${params.payload.uid}`,
method: 'PUT',
data: params.payload,
}),
extraOptions: { maxRetries: 0 },
async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) {
const { data } = await queryFulfilled;
dispatch(notifyApp(createSuccessNotification('Public dashboard updated!')));
@ -98,15 +101,38 @@ export const publicDashboardApi = createApi({
},
invalidatesTags: (result, error, { payload }) => [{ type: 'PublicDashboard', id: payload.dashboardUid }],
}),
addEmailSharing: builder.mutation<void, { recipient: string; dashboardUid: string; uid: string }>({
query: ({ recipient, uid }) => ({
url: `/public-dashboards/${uid}/share/recipients`,
method: 'POST',
data: { recipient },
}),
async onQueryStarted(_, { dispatch, queryFulfilled }) {
await queryFulfilled;
dispatch(notifyApp(createSuccessNotification('Invite sent!')));
},
invalidatesTags: (result, error, { dashboardUid }) => [{ type: 'PublicDashboard', id: dashboardUid }],
}),
deleteEmailSharing: builder.mutation<void, { recipient: string; dashboardUid: string; uid: string }>({
query: ({ uid, recipient }) => ({
url: `/public-dashboards/${uid}/share/recipients/${recipient}`,
method: 'DELETE',
}),
async onQueryStarted(_, { dispatch, queryFulfilled }) {
await queryFulfilled;
dispatch(notifyApp(createSuccessNotification('User revoked')));
},
invalidatesTags: (result, error, { dashboardUid }) => [{ type: 'PublicDashboard', id: dashboardUid }],
}),
listPublicDashboards: builder.query<ListPublicDashboardResponse[], void>({
query: () => ({
url: '/public-dashboards',
url: '/dashboards/public-dashboards',
}),
providesTags: ['AuditTablePublicDashboard'],
}),
deletePublicDashboard: builder.mutation<void, { dashboard?: DashboardModel; dashboardUid: string; uid: string }>({
query: (params) => ({
url: `/uid/${params.dashboardUid}/public-dashboards/${params.uid}`,
url: `/dashboards/uid/${params.dashboardUid}/public-dashboards/${params.uid}`,
method: 'DELETE',
}),
async onQueryStarted({ dashboard, uid }, { dispatch, queryFulfilled }) {
@ -132,4 +158,6 @@ export const {
useUpdatePublicDashboardMutation,
useDeletePublicDashboardMutation,
useListPublicDashboardsQuery,
useAddEmailSharingMutation,
useDeleteEmailSharingMutation,
} = publicDashboardApi;

View File

@ -4,8 +4,9 @@ import React, { useContext } from 'react';
import { useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data/src';
import { GrafanaEdition } from '@grafana/data/src/types/config';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { reportInteraction } from '@grafana/runtime/src';
import { config, reportInteraction } from '@grafana/runtime/src';
import {
ClipboardButton,
Field,
@ -37,6 +38,7 @@ import {
} from '../SharePublicDashboardUtils';
import { Configuration } from './Configuration';
import { EmailSharingConfiguration } from './EmailSharingConfiguration';
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
@ -52,6 +54,8 @@ const ConfigPublicDashboard = () => {
const isDesktop = useIsDesktop();
const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin());
const hasEmailSharingEnabled =
config.licenseInfo.edition === GrafanaEdition.Enterprise && !!config.featureToggles.publicDashboardsEmailSharing;
const dashboardState = useSelector((store) => store.dashboard);
const dashboard = dashboardState.getModel()!;
const dashboardVariables = dashboard.getVariables();
@ -114,6 +118,7 @@ const ConfigPublicDashboard = () => {
</div>
<Configuration disabled={disableInputs} onChange={onChange} register={register} />
<hr />
{hasEmailSharingEnabled && <EmailSharingConfiguration />}
<Field label="Dashboard URL" className={styles.publicUrl}>
<Input
value={generatePublicDashboardUrl(publicDashboard!)}

View File

@ -0,0 +1,228 @@
import { css } from '@emotion/css';
import React from 'react';
import { useForm } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data/src';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import {
Button,
ButtonGroup,
Field,
Input,
InputControl,
RadioButtonGroup,
Spinner,
useStyles2,
} from '@grafana/ui/src';
import {
useAddEmailSharingMutation,
useDeleteEmailSharingMutation,
useGetPublicDashboardQuery,
useUpdatePublicDashboardMutation,
} from 'app/features/dashboard/api/publicDashboardApi';
import { useSelector } from 'app/types';
import { PublicDashboardShareType, validEmailRegex } from '../SharePublicDashboardUtils';
interface EmailSharingConfigurationForm {
shareType: PublicDashboardShareType;
email: string;
}
const options: Array<SelectableValue<PublicDashboardShareType>> = [
{ label: 'Anyone with a link', value: PublicDashboardShareType.PUBLIC },
{ label: 'Only specified people', value: PublicDashboardShareType.EMAIL },
];
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard.EmailSharingConfiguration;
const EmailList = ({
recipients,
dashboardUid,
publicDashboardUid,
}: {
recipients: string[];
dashboardUid: string;
publicDashboardUid: string;
}) => {
const styles = useStyles2(getStyles);
const [deleteEmail, { isLoading: isDeleteLoading }] = useDeleteEmailSharingMutation();
const onDeleteEmail = (email: string) => {
deleteEmail({ recipient: email, dashboardUid: dashboardUid, uid: publicDashboardUid });
};
return (
<table className={styles.table} data-testid={selectors.EmailSharingList}>
<tbody>
{recipients.map((recipient) => (
<tr key={recipient}>
<td>{recipient}</td>
<td>
<ButtonGroup className={styles.tableButtonsContainer}>
<Button
type="button"
variant="destructive"
fill="text"
aria-label="Revoke"
title="Revoke"
size="sm"
disabled={isDeleteLoading}
onClick={() => onDeleteEmail(recipient)}
>
Revoke
</Button>
</ButtonGroup>
</td>
</tr>
))}
</tbody>
</table>
);
};
export const EmailSharingConfiguration = () => {
const styles = useStyles2(getStyles);
const dashboardState = useSelector((store) => store.dashboard);
const dashboard = dashboardState.getModel()!;
const { data: publicDashboard } = useGetPublicDashboardQuery(dashboard.uid);
const [updateShareType] = useUpdatePublicDashboardMutation();
const [addEmail, { isLoading: isAddEmailLoading }] = useAddEmailSharingMutation();
const {
register,
setValue,
control,
watch,
handleSubmit,
formState: { isValid, errors },
reset,
} = useForm<EmailSharingConfigurationForm>({
defaultValues: {
shareType: publicDashboard?.share || PublicDashboardShareType.PUBLIC,
email: '',
},
mode: 'onChange',
});
const onShareTypeChange = (shareType: PublicDashboardShareType) => {
const req = {
dashboard,
payload: {
...publicDashboard!,
share: shareType,
},
};
updateShareType(req);
};
const onSubmit = async (data: EmailSharingConfigurationForm) => {
await addEmail({ recipient: data.email, uid: publicDashboard!.uid, dashboardUid: dashboard.uid }).unwrap();
reset({ email: '', shareType: PublicDashboardShareType.EMAIL });
};
return (
<form className={styles.container} onSubmit={handleSubmit(onSubmit)}>
<Field label="Can view dashboard">
<InputControl
name="shareType"
control={control}
render={({ field }) => {
const { ref, ...rest } = field;
return (
<RadioButtonGroup
{...rest}
options={options}
onChange={(shareType: PublicDashboardShareType) => {
setValue('shareType', shareType);
onShareTypeChange(shareType);
}}
/>
);
}}
/>
</Field>
{watch('shareType') === PublicDashboardShareType.EMAIL && (
<>
<Field
label="Invite"
description="Invite people by email"
error={errors.email?.message}
invalid={!!errors.email?.message || undefined}
>
<div className={styles.emailContainer}>
<Input
className={styles.emailInput}
placeholder="email"
autoCapitalize="none"
{...register('email', {
required: 'Email is required',
pattern: { value: validEmailRegex, message: 'Invalid email' },
})}
data-testid={selectors.EmailSharingInput}
/>
<Button
type="submit"
variant="primary"
disabled={!isValid || isAddEmailLoading}
data-testid={selectors.EmailSharingInviteButton}
>
Invite {isAddEmailLoading && <Spinner />}
</Button>
</div>
</Field>
{!!publicDashboard?.recipients?.length && (
<EmailList
recipients={publicDashboard.recipients}
dashboardUid={dashboard.uid}
publicDashboardUid={publicDashboard.uid}
/>
)}
</>
)}
</form>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
margin-bottom: ${theme.spacing(2)};
`,
emailContainer: css`
display: flex;
gap: ${theme.spacing(1)};
`,
emailInput: css`
flex-grow: 1;
`,
table: css`
display: flex;
max-height: 220px;
overflow-y: scroll;
margin-bottom: ${theme.spacing(1)};
& tbody {
display: flex;
flex-direction: column;
flex-grow: 1;
}
& tr {
min-height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
padding: ${theme.spacing(0.5, 1)};
:nth-child(odd) {
background: ${theme.colors.background.secondary};
}
}
`,
tableButtonsContainer: css`
display: flex;
justify-content: end;
`,
});

View File

@ -1,8 +1,6 @@
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import { fireEvent, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import React from 'react';
import { Provider } from 'react-redux';
import 'whatwg-fetch';
import { BootData, DataQuery } from '@grafana/data/src';
@ -13,14 +11,15 @@ import config from 'app/core/config';
import { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv';
import { Echo } from 'app/core/services/echo/Echo';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { createDashboardModelFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures';
import { configureStore } from 'app/store/configureStore';
import { DashboardInitPhase } from '../../../../../types';
import { ShareModal } from '../ShareModal';
import * as sharePublicDashboardUtils from './SharePublicDashboardUtils';
import {
getExistentPublicDashboardResponse,
mockDashboard,
pubdashResponse,
renderSharePublicDashboard,
} from './utilsTest';
const server = setupServer();
@ -29,46 +28,9 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => backendSrv,
}));
const renderSharePublicDashboard = async (
props?: Partial<React.ComponentProps<typeof ShareModal>>,
isEnabled = true
) => {
const store = configureStore({
dashboard: {
getModel: () => props?.dashboard || mockDashboard,
permissions: [],
initError: null,
initPhase: DashboardInitPhase.Completed,
},
});
const newProps = Object.assign(
{
panel: mockPanel,
dashboard: mockDashboard,
onDismiss: () => {},
},
props
);
render(
<Provider store={store}>
<ShareModal {...newProps} />
</Provider>
);
await waitFor(() => screen.getByText('Link'));
if (isEnabled) {
fireEvent.click(screen.getByText('Public dashboard'));
await waitForElementToBeRemoved(screen.getByText('Loading configuration'));
}
};
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
let originalBootData: BootData;
let mockDashboard: DashboardModel;
let mockPanel: PanelModel;
beforeAll(() => {
setEchoSrv(new Echo());
@ -96,14 +58,6 @@ beforeAll(() => {
beforeEach(() => {
config.featureToggles.publicDashboards = true;
mockDashboard = createDashboardModelFixture({
uid: 'mockDashboardUid',
timezone: 'utc',
});
mockPanel = new PanelModel({
id: 'mockPanelId',
});
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true);
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
@ -120,25 +74,6 @@ afterEach(() => {
server.resetHandlers();
});
const pubdashResponse: sharePublicDashboardUtils.PublicDashboard = {
isEnabled: true,
annotationsEnabled: true,
timeSelectionEnabled: true,
uid: 'a-uid',
dashboardUid: '',
accessToken: 'an-access-token',
};
const getExistentPublicDashboardResponse = () =>
rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...pubdashResponse,
dashboardUid: req.params.dashboardUid,
})
);
});
const getNonExistentPublicDashboardResponse = () =>
rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => {
return res(
@ -360,5 +295,12 @@ describe('SharePublic - Already persisted', () => {
expect(screen.getByTestId(selectors.PauseSwitch)).toBeChecked();
});
it('does not render email sharing section', async () => {
await renderSharePublicDashboard();
expect(screen.queryByTestId(selectors.EmailSharingConfiguration.EmailSharingInput)).not.toBeInTheDocument();
expect(screen.queryByTestId(selectors.EmailSharingConfiguration.EmailSharingInviteButton)).not.toBeInTheDocument();
expect(screen.queryByTestId(selectors.EmailSharingConfiguration.EmailSharingList)).not.toBeInTheDocument();
});
alertTests();
});

View File

@ -5,6 +5,11 @@ import { PanelModel } from '../../../state';
import { supportedDatasources } from './SupportedPubdashDatasources';
export enum PublicDashboardShareType {
PUBLIC = 'public',
EMAIL = 'email',
}
export interface PublicDashboardSettings {
annotationsEnabled: boolean;
isEnabled: boolean;
@ -16,6 +21,8 @@ export interface PublicDashboard extends PublicDashboardSettings {
uid: string;
dashboardUid: string;
timeSettings?: object;
share: PublicDashboardShareType;
recipients?: string[];
}
// Instance methods
@ -56,3 +63,5 @@ export const getUnsupportedDashboardDatasources = (panels: PanelModel[]): string
export const generatePublicDashboardUrl = (publicDashboard: PublicDashboard): string => {
return `${getConfig().appUrl}public-dashboards/${publicDashboard.accessToken}`;
};
export const validEmailRegex = /^[A-Z\d._%+-]+@[A-Z\d.-]+\.[A-Z]{2,}$/i;

View File

@ -0,0 +1,81 @@
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import { rest } from 'msw';
import React from 'react';
import { Provider } from 'react-redux';
import { configureStore } from '../../../../../store/configureStore';
import { DashboardInitPhase } from '../../../../../types';
import { DashboardModel, PanelModel } from '../../../state';
import { createDashboardModelFixture } from '../../../state/__fixtures__/dashboardFixtures';
import { ShareModal } from '../ShareModal';
import * as sharePublicDashboardUtils from './SharePublicDashboardUtils';
import { PublicDashboard, PublicDashboardShareType } from './SharePublicDashboardUtils';
export const mockDashboard: DashboardModel = createDashboardModelFixture({
uid: 'mockDashboardUid',
timezone: 'utc',
});
export const mockPanel = new PanelModel({
id: 'mockPanelId',
});
export const pubdashResponse: sharePublicDashboardUtils.PublicDashboard = {
isEnabled: true,
annotationsEnabled: true,
timeSelectionEnabled: true,
uid: 'a-uid',
dashboardUid: '',
accessToken: 'an-access-token',
share: PublicDashboardShareType.PUBLIC,
};
export const getExistentPublicDashboardResponse = (publicDashboard?: Partial<PublicDashboard>) =>
rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...pubdashResponse,
...publicDashboard,
dashboardUid: req.params.dashboardUid,
})
);
});
export const renderSharePublicDashboard = async (
props?: Partial<React.ComponentProps<typeof ShareModal>>,
isEnabled = true
) => {
const store = configureStore({
dashboard: {
getModel: () => props?.dashboard || mockDashboard,
permissions: [],
initError: null,
initPhase: DashboardInitPhase.Completed,
},
});
const newProps = Object.assign(
{
panel: mockPanel,
dashboard: mockDashboard,
onDismiss: () => {},
},
props
);
const renderResult = render(
<Provider store={store}>
<ShareModal {...newProps} />
</Provider>
);
await waitFor(() => screen.getByText('Link'));
if (isEnabled) {
fireEvent.click(screen.getByText('Public dashboard'));
await waitForElementToBeRemoved(screen.getByText('Loading configuration'));
}
return renderResult;
};

View File

@ -8,14 +8,11 @@ const Body = ({ title }: { title?: string }) => {
const styles = useStyles2(getStyles);
return (
<>
<p className={styles.title}>Do you want to delete this public dashboard?</p>
<p className={styles.description}>
{title
? `This will delete the public dashboard for "${title}". Your dashboard will not be deleted.`
: 'Orphaned public dashboard will be deleted'}
</p>
</>
<p className={styles.description}>
{title
? 'Are you sure you want to revoke this URL? The dashboard will no longer be public.'
: 'Orphaned public dashboard will no longer be public.'}
</p>
);
};
@ -33,9 +30,9 @@ export const DeletePublicDashboardModal = ({
body={<Body title={dashboardTitle} />}
onConfirm={onConfirm}
onDismiss={onDismiss}
title="Delete"
title="Revoke public URL"
icon="trash-alt"
confirmText="Delete"
confirmText="Revoke public URL"
/>
);
@ -44,6 +41,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin-bottom: ${theme.spacing(1)};
`,
description: css`
font-size: ${theme.typography.bodySmall.fontSize};
font-size: ${theme.typography.body.fontSize};
`,
});