mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PublicDashboards: Email sharing (#63762)
Feature for sharing a public dashboard by email
This commit is contained in:
@@ -43,21 +43,26 @@ If you are using Docker, use an environment variable to enable public dashboards
|
|||||||
#### Make a dashboard public
|
#### Make a dashboard public
|
||||||
|
|
||||||
- Click on the sharing icon to the right of the dashboard title.
|
- 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.
|
- Acknowledge the implications of making the dashboard public by checking all the checkboxes.
|
||||||
- Turn on the Enabled toggle.
|
- Click **Generate public URL** to make the dashboard public and make your link live.
|
||||||
- Click `Save Sharing Configuration` 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.
|
- 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
|
#### Revoke access
|
||||||
|
|
||||||
- Click on the sharing icon to the right of the dashboard title.
|
- Click on the sharing icon to the right of the dashboard title.
|
||||||
- Click on the Public Dashboard tab.
|
- Click on the **Public dashboard** tab.
|
||||||
- Turn off the Enabled toggle.
|
- Click **Revoke public URL** to delete the public dashboard.
|
||||||
- Click `Save Sharing Configuration` to save your changes.
|
- The link no longer works. You must create a new public URL as in [Make a dashboard public](#make-a-dashboard-public).
|
||||||
- Anyone with the link will not be able to access the dashboard publicly anymore.
|
|
||||||
|
|
||||||
#### 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
|
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.
|
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.
|
||||||
|
|||||||
@@ -200,6 +200,12 @@ export const Pages = {
|
|||||||
UnsupportedDataSourcesWarningAlert: 'data-testid public dashboard unsupported data sources alert',
|
UnsupportedDataSourcesWarningAlert: 'data-testid public dashboard unsupported data sources alert',
|
||||||
NoUpsertPermissionsWarningAlert: 'data-testid public dashboard no upsert permissions alert',
|
NoUpsertPermissionsWarningAlert: 'data-testid public dashboard no upsert permissions alert',
|
||||||
EnableTimeRangeSwitch: 'data-testid public dashboard on off switch for time range',
|
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: {
|
Explore: {
|
||||||
|
|||||||
@@ -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 { 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 { notifyApp } from 'app/core/actions';
|
||||||
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,10 @@ type ReqOptions = {
|
|||||||
showErrorAlert?: boolean;
|
showErrorAlert?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isFetchBaseQueryError(error: unknown): error is { error: FetchError } {
|
||||||
|
return typeof error === 'object' && error != null && 'error' in error;
|
||||||
|
}
|
||||||
|
|
||||||
const backendSrvBaseQuery =
|
const backendSrvBaseQuery =
|
||||||
({ baseUrl }: { baseUrl: string }): BaseQueryFn<BackendSrvRequest & ReqOptions> =>
|
({ baseUrl }: { baseUrl: string }): BaseQueryFn<BackendSrvRequest & ReqOptions> =>
|
||||||
async (requestOptions) => {
|
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({
|
export const publicDashboardApi = createApi({
|
||||||
reducerPath: 'publicDashboardApi',
|
reducerPath: 'publicDashboardApi',
|
||||||
baseQuery: retry(backendSrvBaseQuery({ baseUrl: '/api/dashboards' }), { maxRetries: 0 }),
|
baseQuery: backendSrvBaseQuery({ baseUrl: '/api' }),
|
||||||
tagTypes: ['PublicDashboard', 'AuditTablePublicDashboard'],
|
tagTypes: ['PublicDashboard', 'AuditTablePublicDashboard'],
|
||||||
refetchOnMountOrArgChange: true,
|
refetchOnMountOrArgChange: true,
|
||||||
endpoints: (builder) => ({
|
endpoints: (builder) => ({
|
||||||
getPublicDashboard: builder.query<PublicDashboard | undefined, string>({
|
getPublicDashboard: builder.query<PublicDashboard | undefined, string>({
|
||||||
query: (dashboardUid) => ({
|
query: (dashboardUid) => ({
|
||||||
url: `/uid/${dashboardUid}/public-dashboards`,
|
url: `/dashboards/uid/${dashboardUid}/public-dashboards`,
|
||||||
manageError: getConfigError,
|
manageError: getConfigError,
|
||||||
showErrorAlert: false,
|
showErrorAlert: false,
|
||||||
}),
|
}),
|
||||||
@@ -51,9 +55,9 @@ export const publicDashboardApi = createApi({
|
|||||||
try {
|
try {
|
||||||
await queryFulfilled;
|
await queryFulfilled;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
if (isFetchBaseQueryError(e) && isFetchError(e.error)) {
|
||||||
const customError = e as { error: { data: { message: string } } };
|
dispatch(notifyApp(createErrorNotification(e.error.data.message)));
|
||||||
dispatch(notifyApp(createErrorNotification(customError?.error?.data?.message)));
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
providesTags: (result, error, dashboardUid) => [{ type: 'PublicDashboard', id: dashboardUid }],
|
providesTags: (result, error, dashboardUid) => [{ type: 'PublicDashboard', id: dashboardUid }],
|
||||||
@@ -63,7 +67,7 @@ export const publicDashboardApi = createApi({
|
|||||||
{ dashboard: DashboardModel; payload: Partial<PublicDashboardSettings> }
|
{ dashboard: DashboardModel; payload: Partial<PublicDashboardSettings> }
|
||||||
>({
|
>({
|
||||||
query: (params) => ({
|
query: (params) => ({
|
||||||
url: `/uid/${params.dashboard.uid}/public-dashboards`,
|
url: `/dashboards/uid/${params.dashboard.uid}/public-dashboards`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: params.payload,
|
data: params.payload,
|
||||||
}),
|
}),
|
||||||
@@ -81,11 +85,10 @@ export const publicDashboardApi = createApi({
|
|||||||
}),
|
}),
|
||||||
updatePublicDashboard: builder.mutation<PublicDashboard, { dashboard: DashboardModel; payload: PublicDashboard }>({
|
updatePublicDashboard: builder.mutation<PublicDashboard, { dashboard: DashboardModel; payload: PublicDashboard }>({
|
||||||
query: (params) => ({
|
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',
|
method: 'PUT',
|
||||||
data: params.payload,
|
data: params.payload,
|
||||||
}),
|
}),
|
||||||
extraOptions: { maxRetries: 0 },
|
|
||||||
async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) {
|
async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) {
|
||||||
const { data } = await queryFulfilled;
|
const { data } = await queryFulfilled;
|
||||||
dispatch(notifyApp(createSuccessNotification('Public dashboard updated!')));
|
dispatch(notifyApp(createSuccessNotification('Public dashboard updated!')));
|
||||||
@@ -98,15 +101,38 @@ export const publicDashboardApi = createApi({
|
|||||||
},
|
},
|
||||||
invalidatesTags: (result, error, { payload }) => [{ type: 'PublicDashboard', id: payload.dashboardUid }],
|
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>({
|
listPublicDashboards: builder.query<ListPublicDashboardResponse[], void>({
|
||||||
query: () => ({
|
query: () => ({
|
||||||
url: '/public-dashboards',
|
url: '/dashboards/public-dashboards',
|
||||||
}),
|
}),
|
||||||
providesTags: ['AuditTablePublicDashboard'],
|
providesTags: ['AuditTablePublicDashboard'],
|
||||||
}),
|
}),
|
||||||
deletePublicDashboard: builder.mutation<void, { dashboard?: DashboardModel; dashboardUid: string; uid: string }>({
|
deletePublicDashboard: builder.mutation<void, { dashboard?: DashboardModel; dashboardUid: string; uid: string }>({
|
||||||
query: (params) => ({
|
query: (params) => ({
|
||||||
url: `/uid/${params.dashboardUid}/public-dashboards/${params.uid}`,
|
url: `/dashboards/uid/${params.dashboardUid}/public-dashboards/${params.uid}`,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
async onQueryStarted({ dashboard, uid }, { dispatch, queryFulfilled }) {
|
async onQueryStarted({ dashboard, uid }, { dispatch, queryFulfilled }) {
|
||||||
@@ -132,4 +158,6 @@ export const {
|
|||||||
useUpdatePublicDashboardMutation,
|
useUpdatePublicDashboardMutation,
|
||||||
useDeletePublicDashboardMutation,
|
useDeletePublicDashboardMutation,
|
||||||
useListPublicDashboardsQuery,
|
useListPublicDashboardsQuery,
|
||||||
|
useAddEmailSharingMutation,
|
||||||
|
useDeleteEmailSharingMutation,
|
||||||
} = publicDashboardApi;
|
} = publicDashboardApi;
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import React, { useContext } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data/src';
|
import { GrafanaTheme2 } from '@grafana/data/src';
|
||||||
|
import { GrafanaEdition } from '@grafana/data/src/types/config';
|
||||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||||
import { reportInteraction } from '@grafana/runtime/src';
|
import { config, reportInteraction } from '@grafana/runtime/src';
|
||||||
import {
|
import {
|
||||||
ClipboardButton,
|
ClipboardButton,
|
||||||
Field,
|
Field,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
} from '../SharePublicDashboardUtils';
|
} from '../SharePublicDashboardUtils';
|
||||||
|
|
||||||
import { Configuration } from './Configuration';
|
import { Configuration } from './Configuration';
|
||||||
|
import { EmailSharingConfiguration } from './EmailSharingConfiguration';
|
||||||
|
|
||||||
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
|
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
|
||||||
|
|
||||||
@@ -52,6 +54,8 @@ const ConfigPublicDashboard = () => {
|
|||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin());
|
const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin());
|
||||||
|
const hasEmailSharingEnabled =
|
||||||
|
config.licenseInfo.edition === GrafanaEdition.Enterprise && !!config.featureToggles.publicDashboardsEmailSharing;
|
||||||
const dashboardState = useSelector((store) => store.dashboard);
|
const dashboardState = useSelector((store) => store.dashboard);
|
||||||
const dashboard = dashboardState.getModel()!;
|
const dashboard = dashboardState.getModel()!;
|
||||||
const dashboardVariables = dashboard.getVariables();
|
const dashboardVariables = dashboard.getVariables();
|
||||||
@@ -114,6 +118,7 @@ const ConfigPublicDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Configuration disabled={disableInputs} onChange={onChange} register={register} />
|
<Configuration disabled={disableInputs} onChange={onChange} register={register} />
|
||||||
<hr />
|
<hr />
|
||||||
|
{hasEmailSharingEnabled && <EmailSharingConfiguration />}
|
||||||
<Field label="Dashboard URL" className={styles.publicUrl}>
|
<Field label="Dashboard URL" className={styles.publicUrl}>
|
||||||
<Input
|
<Input
|
||||||
value={generatePublicDashboardUrl(publicDashboard!)}
|
value={generatePublicDashboardUrl(publicDashboard!)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`,
|
||||||
|
});
|
||||||
@@ -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 { rest } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
import React from 'react';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
|
|
||||||
import 'whatwg-fetch';
|
import 'whatwg-fetch';
|
||||||
import { BootData, DataQuery } from '@grafana/data/src';
|
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 { backendSrv } from 'app/core/services/backend_srv';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { Echo } from 'app/core/services/echo/Echo';
|
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 { 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 * as sharePublicDashboardUtils from './SharePublicDashboardUtils';
|
||||||
|
import {
|
||||||
|
getExistentPublicDashboardResponse,
|
||||||
|
mockDashboard,
|
||||||
|
pubdashResponse,
|
||||||
|
renderSharePublicDashboard,
|
||||||
|
} from './utilsTest';
|
||||||
|
|
||||||
const server = setupServer();
|
const server = setupServer();
|
||||||
|
|
||||||
@@ -29,46 +28,9 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
getBackendSrv: () => backendSrv,
|
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;
|
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
|
||||||
|
|
||||||
let originalBootData: BootData;
|
let originalBootData: BootData;
|
||||||
let mockDashboard: DashboardModel;
|
|
||||||
let mockPanel: PanelModel;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
setEchoSrv(new Echo());
|
setEchoSrv(new Echo());
|
||||||
@@ -96,14 +58,6 @@ beforeAll(() => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
config.featureToggles.publicDashboards = true;
|
config.featureToggles.publicDashboards = true;
|
||||||
mockDashboard = createDashboardModelFixture({
|
|
||||||
uid: 'mockDashboardUid',
|
|
||||||
timezone: 'utc',
|
|
||||||
});
|
|
||||||
|
|
||||||
mockPanel = new PanelModel({
|
|
||||||
id: 'mockPanelId',
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true);
|
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true);
|
||||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||||
@@ -120,25 +74,6 @@ afterEach(() => {
|
|||||||
server.resetHandlers();
|
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 = () =>
|
const getNonExistentPublicDashboardResponse = () =>
|
||||||
rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => {
|
rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => {
|
||||||
return res(
|
return res(
|
||||||
@@ -360,5 +295,12 @@ describe('SharePublic - Already persisted', () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId(selectors.PauseSwitch)).toBeChecked();
|
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();
|
alertTests();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { PanelModel } from '../../../state';
|
|||||||
|
|
||||||
import { supportedDatasources } from './SupportedPubdashDatasources';
|
import { supportedDatasources } from './SupportedPubdashDatasources';
|
||||||
|
|
||||||
|
export enum PublicDashboardShareType {
|
||||||
|
PUBLIC = 'public',
|
||||||
|
EMAIL = 'email',
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicDashboardSettings {
|
export interface PublicDashboardSettings {
|
||||||
annotationsEnabled: boolean;
|
annotationsEnabled: boolean;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@@ -16,6 +21,8 @@ export interface PublicDashboard extends PublicDashboardSettings {
|
|||||||
uid: string;
|
uid: string;
|
||||||
dashboardUid: string;
|
dashboardUid: string;
|
||||||
timeSettings?: object;
|
timeSettings?: object;
|
||||||
|
share: PublicDashboardShareType;
|
||||||
|
recipients?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
@@ -56,3 +63,5 @@ export const getUnsupportedDashboardDatasources = (panels: PanelModel[]): string
|
|||||||
export const generatePublicDashboardUrl = (publicDashboard: PublicDashboard): string => {
|
export const generatePublicDashboardUrl = (publicDashboard: PublicDashboard): string => {
|
||||||
return `${getConfig().appUrl}public-dashboards/${publicDashboard.accessToken}`;
|
return `${getConfig().appUrl}public-dashboards/${publicDashboard.accessToken}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const validEmailRegex = /^[A-Z\d._%+-]+@[A-Z\d.-]+\.[A-Z]{2,}$/i;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -8,14 +8,11 @@ const Body = ({ title }: { title?: string }) => {
|
|||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<p className={styles.description}>
|
||||||
<p className={styles.title}>Do you want to delete this public dashboard?</p>
|
{title
|
||||||
<p className={styles.description}>
|
? 'Are you sure you want to revoke this URL? The dashboard will no longer be public.'
|
||||||
{title
|
: 'Orphaned public dashboard will no longer be public.'}
|
||||||
? `This will delete the public dashboard for "${title}". Your dashboard will not be deleted.`
|
</p>
|
||||||
: 'Orphaned public dashboard will be deleted'}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,9 +30,9 @@ export const DeletePublicDashboardModal = ({
|
|||||||
body={<Body title={dashboardTitle} />}
|
body={<Body title={dashboardTitle} />}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
title="Delete"
|
title="Revoke public URL"
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
confirmText="Delete"
|
confirmText="Revoke public URL"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -44,6 +41,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
margin-bottom: ${theme.spacing(1)};
|
margin-bottom: ${theme.spacing(1)};
|
||||||
`,
|
`,
|
||||||
description: css`
|
description: css`
|
||||||
font-size: ${theme.typography.bodySmall.fontSize};
|
font-size: ${theme.typography.body.fontSize};
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user