mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
ShareModal: Share externally (#88259)
This commit is contained in:
@@ -65,6 +65,7 @@ export const Pages = {
|
||||
menu: {
|
||||
container: 'data-testid new share button menu',
|
||||
shareInternally: 'data-testid new share button share internally',
|
||||
shareExternally: 'data-testid new share button share externally',
|
||||
},
|
||||
},
|
||||
playlistControls: {
|
||||
@@ -280,6 +281,13 @@ export const Pages = {
|
||||
CopyUrlInput: 'data-testid snapshot copy url input',
|
||||
},
|
||||
},
|
||||
ShareDashboardDrawer: {
|
||||
ShareExternally: {
|
||||
container: 'data-testid share externally drawer container',
|
||||
copyUrlButton: 'data-testid share externally copy url button',
|
||||
shareTypeSelect: 'data-testid share externally share type select',
|
||||
},
|
||||
},
|
||||
PublicDashboard: {
|
||||
page: 'public-dashboard-page',
|
||||
NotAvailable: {
|
||||
|
||||
@@ -3,11 +3,11 @@ import React, { useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config, featureEnabled } from '@grafana/runtime';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { useStyles2, TabsBar, Tab } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { isEmailSharingEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
|
||||
import { Page } from '../../core/components/Page/Page';
|
||||
import { AccessControlAction } from '../../types';
|
||||
@@ -47,10 +47,6 @@ export default function UserListPage() {
|
||||
|
||||
const hasAccessToAdminUsers = contextSrv.hasPermission(AccessControlAction.UsersRead);
|
||||
const hasAccessToOrgUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead);
|
||||
const hasEmailSharingEnabled =
|
||||
isPublicDashboardsEnabled() &&
|
||||
Boolean(config.featureToggles.publicDashboardsEmailSharing) &&
|
||||
featureEnabled('publicDashboardsEmailSharing');
|
||||
|
||||
const [view, setView] = useState(() => {
|
||||
if (hasAccessToAdminUsers) {
|
||||
@@ -87,10 +83,10 @@ export default function UserListPage() {
|
||||
data-testid={selectors.tabs.anonUserDevices}
|
||||
/>
|
||||
)}
|
||||
{hasEmailSharingEnabled && <PublicDashboardsTab view={view} setView={setView} />}
|
||||
{isEmailSharingEnabled() && <PublicDashboardsTab view={view} setView={setView} />}
|
||||
</TabsBar>
|
||||
) : (
|
||||
hasEmailSharingEnabled && (
|
||||
isEmailSharingEnabled() && (
|
||||
<TabsBar className={styles.tabsMargin}>
|
||||
<Tab
|
||||
label="Users"
|
||||
|
||||
@@ -2,21 +2,22 @@ import React, { useCallback, useState } from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
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';
|
||||
import { buildShareUrl } from './utils';
|
||||
|
||||
const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton;
|
||||
|
||||
export default function ShareButton({ dashboard }: { dashboard: DashboardScene }) {
|
||||
export default function ShareButton({ dashboard, panel }: { dashboard: DashboardScene; panel?: VizPanel }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [_, buildUrl] = useAsyncFn(async () => {
|
||||
return await createAndCopyDashboardShortLink(dashboard, { useAbsoluteTimeRange: true, theme: 'current' });
|
||||
return await buildShareUrl(dashboard, panel);
|
||||
}, [dashboard]);
|
||||
|
||||
const onMenuClick = useCallback((isOpen: boolean) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from 'react';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { config } from '../../../../core/config';
|
||||
import { DashboardGridItem } from '../../scene/DashboardGridItem';
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
|
||||
@@ -18,6 +19,21 @@ jest.mock('app/core/utils/shortLinks', () => ({
|
||||
|
||||
const selector = e2eSelectors.pages.Dashboard.DashNav.newShareButton.menu;
|
||||
describe('ShareMenu', () => {
|
||||
it('should render menu items', async () => {
|
||||
config.featureToggles.publicDashboards = true;
|
||||
config.publicDashboardsEnabled = true;
|
||||
setup();
|
||||
|
||||
expect(await screen.findByTestId(selector.shareInternally)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId(selector.shareExternally)).toBeInTheDocument();
|
||||
});
|
||||
it('should no share externally when public dashboard is disabled', async () => {
|
||||
config.featureToggles.publicDashboards = false;
|
||||
config.publicDashboardsEnabled = false;
|
||||
setup();
|
||||
|
||||
expect(await screen.queryByTestId(selector.shareExternally)).not.toBeInTheDocument();
|
||||
});
|
||||
it('should call createAndCopyDashboardShortLink when share internally clicked', async () => {
|
||||
setup();
|
||||
|
||||
|
||||
@@ -2,18 +2,32 @@ import React from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { Menu } from '@grafana/ui';
|
||||
|
||||
import { createAndCopyDashboardShortLink } from '../../../../core/utils/shortLinks';
|
||||
import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
import { ShareDrawer } from '../ShareDrawer/ShareDrawer';
|
||||
|
||||
import { ShareExternally } from './share-externally/ShareExternally';
|
||||
import { buildShareUrl } from './utils';
|
||||
|
||||
const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton.menu;
|
||||
|
||||
export default function ShareMenu({ dashboard }: { dashboard: DashboardScene }) {
|
||||
export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardScene; panel?: VizPanel }) {
|
||||
const [_, buildUrl] = useAsyncFn(async () => {
|
||||
return await createAndCopyDashboardShortLink(dashboard, { useAbsoluteTimeRange: true, theme: 'current' });
|
||||
return await buildShareUrl(dashboard, panel);
|
||||
}, [dashboard]);
|
||||
|
||||
const onShareExternallyClick = () => {
|
||||
const drawer = new ShareDrawer({
|
||||
title: 'Share externally',
|
||||
body: new ShareExternally({}),
|
||||
});
|
||||
|
||||
dashboard.showModal(drawer);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu data-testid={newShareButtonSelector.container}>
|
||||
<Menu.Item
|
||||
@@ -23,6 +37,14 @@ export default function ShareMenu({ dashboard }: { dashboard: DashboardScene })
|
||||
icon="building"
|
||||
onClick={buildUrl}
|
||||
/>
|
||||
{isPublicDashboardsEnabled() && (
|
||||
<Menu.Item
|
||||
testId={newShareButtonSelector.shareExternally}
|
||||
label="Share externally"
|
||||
icon="share-alt"
|
||||
onClick={onShareExternallyClick}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { Button, Divider, Field, FieldSet, Icon, Stack, Tooltip } from '@grafana/ui';
|
||||
import { Input } from '@grafana/ui/src/components/Input/Input';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { publicDashboardApi, useAddRecipientMutation } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { validEmailRegex } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { useShareDrawerContext } from '../../../../ShareDrawer/ShareDrawerContext';
|
||||
import ShareConfiguration from '../../ShareConfiguration';
|
||||
|
||||
import { EmailListConfiguration } from './EmailListConfiguration';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard.EmailSharingConfiguration;
|
||||
|
||||
type EmailSharingForm = { email: string };
|
||||
|
||||
export const ConfigEmailSharing = () => {
|
||||
const { dashboard } = useShareDrawerContext();
|
||||
|
||||
const { data: publicDashboard, isError } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
|
||||
dashboard.state.uid!
|
||||
);
|
||||
const [addEmail, { isLoading: isAddEmailLoading }] = useAddRecipientMutation();
|
||||
|
||||
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
reset,
|
||||
} = useForm<EmailSharingForm>({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
});
|
||||
|
||||
const onSubmit = async (data: EmailSharingForm) => {
|
||||
DashboardInteractions.publicDashboardEmailInviteClicked();
|
||||
await addEmail({ recipient: data.email, uid: publicDashboard!.uid, dashboardUid: dashboard.state.uid! }).unwrap();
|
||||
reset({ email: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldSet disabled={!hasWritePermissions || isError}>
|
||||
<Field
|
||||
label={
|
||||
<Stack gap={1} alignItems="center">
|
||||
<Trans i18nKey="public-dashboard.email-sharing.recipient-invitation-button">Invite</Trans>
|
||||
<Tooltip
|
||||
placement="right"
|
||||
content={t(
|
||||
'public-dashboard.email-sharing.recipient-invitation-tooltip',
|
||||
'This dashboard contains sensitive data. By using this feature you will be sharing with external people.'
|
||||
)}
|
||||
>
|
||||
<Icon name="info-circle" size="sm" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
}
|
||||
description={t(
|
||||
'public-dashboard.email-sharing.recipient-invitation-description',
|
||||
'Invite someone by email'
|
||||
)}
|
||||
error={errors.email?.message}
|
||||
invalid={!!errors.email?.message}
|
||||
>
|
||||
<Stack direction="row">
|
||||
<Input
|
||||
placeholder={t(
|
||||
'public-dashboard.email-sharing.recipient-email-placeholder',
|
||||
'Type in the recipient email address and press Enter'
|
||||
)}
|
||||
autoCapitalize="none"
|
||||
loading={isAddEmailLoading}
|
||||
{...register('email', {
|
||||
required: t('public-dashboard.email-sharing.recipient-required-email-text', 'Email is required'),
|
||||
pattern: {
|
||||
value: validEmailRegex,
|
||||
message: t('public-dashboard.email-sharing.recipient-invalid-email-text', 'Invalid email'),
|
||||
},
|
||||
})}
|
||||
data-testid={selectors.EmailSharingInput}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isAddEmailLoading || !isValid}
|
||||
data-testid={selectors.EmailSharingInviteButton}
|
||||
>
|
||||
<Trans i18nKey="public-dashboard.email-sharing.recipient-invitation-button">Invite</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</form>
|
||||
<EmailListConfiguration dashboard={dashboard} />
|
||||
<Divider />
|
||||
<ShareConfiguration />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { Dropdown, Field, Icon, Menu, Spinner, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { IconButton } from '@grafana/ui/';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import {
|
||||
useReshareAccessToRecipientMutation,
|
||||
useDeleteRecipientMutation,
|
||||
publicDashboardApi,
|
||||
} from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { PublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard.EmailSharingConfiguration;
|
||||
|
||||
const RecipientMenu = ({ onDelete, onReshare }: { onDelete: () => void; onReshare: () => void }) => {
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Item label={t('public-dashboard.email-sharing.resend-invite-label', 'Resend invite')} onClick={onReshare} />
|
||||
<Menu.Item
|
||||
label={t('public-dashboard.email-sharing.revoke-access-label', 'Revoke access')}
|
||||
destructive
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailList = ({
|
||||
recipients,
|
||||
dashboardUid,
|
||||
publicDashboard,
|
||||
}: {
|
||||
recipients: PublicDashboard['recipients'];
|
||||
dashboardUid: string;
|
||||
publicDashboard: PublicDashboard;
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [deleteEmail, { isLoading: isDeleteLoading }] = useDeleteRecipientMutation();
|
||||
const [reshareAccess, { isLoading: isReshareLoading }] = useReshareAccessToRecipientMutation();
|
||||
|
||||
const isLoading = isDeleteLoading || isReshareLoading;
|
||||
|
||||
const onDeleteEmail = (recipientUid: string, recipientEmail: string) => {
|
||||
DashboardInteractions.revokePublicDashboardEmailClicked();
|
||||
deleteEmail({ recipientUid, recipientEmail, dashboardUid: dashboardUid, uid: publicDashboard.uid });
|
||||
};
|
||||
|
||||
const onReshare = (recipientUid: string) => {
|
||||
DashboardInteractions.resendPublicDashboardEmailClicked();
|
||||
reshareAccess({ recipientUid, uid: publicDashboard.uid });
|
||||
};
|
||||
|
||||
return (
|
||||
<table data-testid={selectors.EmailSharingList} className={styles.table}>
|
||||
<tbody>
|
||||
{recipients!.map((recipient, idx) => (
|
||||
<tr key={recipient.uid} className={styles.listItem}>
|
||||
<td className={styles.user}>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<div className={styles.icon}>
|
||||
<Icon name="user" />
|
||||
</div>
|
||||
<Text>{recipient.recipient}</Text>
|
||||
</Stack>
|
||||
</td>
|
||||
<td>{isLoading && <Spinner />}</td>
|
||||
<td>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<RecipientMenu
|
||||
onDelete={() => onDeleteEmail(recipient.uid, recipient.recipient)}
|
||||
onReshare={() => onReshare(recipient.uid)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IconButton name="ellipsis-v" aria-label="email-menu" variant="secondary" size="lg" />
|
||||
</Dropdown>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmailListConfiguration = ({ dashboard }: { dashboard: DashboardScene }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { data: publicDashboard } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
|
||||
dashboard.state.uid!
|
||||
);
|
||||
return (
|
||||
<Field
|
||||
label={t('public-dashboard.email-sharing.recipient-list-title', 'People with access')}
|
||||
description={t(
|
||||
'public-dashboard.email-sharing.recipient-list-description',
|
||||
"Only people you've directly invited can access this dashboard"
|
||||
)}
|
||||
className={styles.listField}
|
||||
>
|
||||
{!!publicDashboard?.recipients?.length ? (
|
||||
<div className={styles.listContainer}>
|
||||
<EmailList
|
||||
recipients={publicDashboard.recipients}
|
||||
dashboardUid={dashboard.state.uid!}
|
||||
publicDashboard={publicDashboard}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
listField: css({
|
||||
marginBottom: 0,
|
||||
}),
|
||||
listContainer: css({
|
||||
maxHeight: '140px',
|
||||
overflowY: 'auto',
|
||||
}),
|
||||
table: css({
|
||||
width: '100%',
|
||||
}),
|
||||
listItem: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.5),
|
||||
padding: theme.spacing(0.75, 1),
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
user: css({
|
||||
flex: 1,
|
||||
}),
|
||||
icon: css({
|
||||
border: `${theme.spacing(0.25)} solid ${theme.colors.text.secondary}`,
|
||||
padding: theme.spacing(0.125, 0.5),
|
||||
borderRadius: theme.shape.radius.circle,
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, Checkbox, FieldSet, Spinner, Stack } from '@grafana/ui';
|
||||
import { useStyles2 } from '@grafana/ui/';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { useCreatePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { PublicDashboardShareType } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { EmailSharingPricingAlert } from '../../../../../dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/EmailSharingPricingAlert';
|
||||
import { useShareDrawerContext } from '../../../ShareDrawer/ShareDrawerContext';
|
||||
|
||||
export const CreateEmailSharing = ({ hasError }: { hasError: boolean }) => {
|
||||
const { dashboard } = useShareDrawerContext();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [createPublicDashboard, { isLoading, isError }] = useCreatePublicDashboardMutation();
|
||||
|
||||
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
|
||||
const disableInputs = !hasWritePermissions || isLoading || isError || hasError;
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isValid },
|
||||
} = useForm<{ billAcknowledgment: boolean }>({ mode: 'onChange' });
|
||||
|
||||
const onCreate = () => {
|
||||
DashboardInteractions.generatePublicDashboardUrlClicked({ share: PublicDashboardShareType.EMAIL });
|
||||
createPublicDashboard({ dashboard, payload: { share: PublicDashboardShareType.EMAIL, isEnabled: true } });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasWritePermissions && <EmailSharingPricingAlert />}
|
||||
<form onSubmit={handleSubmit(onCreate)}>
|
||||
<FieldSet disabled={disableInputs}>
|
||||
<div className={styles.checkbox}>
|
||||
<Checkbox
|
||||
{...register('billAcknowledgment', { required: true })}
|
||||
label={t('public-dashboard.email-sharing.bill-ack', 'I understand that adding users requires payment.*')}
|
||||
/>
|
||||
</div>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Button type="submit" disabled={!isValid}>
|
||||
<Trans i18nKey="public-dashboard.email-sharing.accept-button">Accept</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => dashboard.closeModal()}>
|
||||
<Trans i18nKey="public-dashboard.email-sharing.cancel-button">Cancel</Trans>
|
||||
</Button>
|
||||
{isLoading && <Spinner />}
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
checkbox: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
|
||||
import { useShareDrawerContext } from '../../../ShareDrawer/ShareDrawerContext';
|
||||
|
||||
import { ConfigEmailSharing } from './ConfigEmailSharing/ConfigEmailSharing';
|
||||
import { CreateEmailSharing } from './CreateEmailSharing';
|
||||
|
||||
export const EmailSharing = () => {
|
||||
const { dashboard } = useShareDrawerContext();
|
||||
const { data: publicDashboard, isError } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
|
||||
dashboard.state.uid!
|
||||
);
|
||||
|
||||
return <>{!publicDashboard ? <CreateEmailSharing hasError={isError} /> : <ConfigEmailSharing />}</>;
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, Checkbox, FieldSet, Spinner, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { useCreatePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { PublicDashboardShareType } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { PublicDashboardAlert } from '../../../../../dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/PublicDashboardAlert';
|
||||
import { useShareDrawerContext } from '../../../ShareDrawer/ShareDrawerContext';
|
||||
|
||||
export default function CreatePublicSharing({ hasError }: { hasError: boolean }) {
|
||||
const { dashboard } = useShareDrawerContext();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isValid },
|
||||
} = useForm<{ publicAcknowledgment: boolean }>({ mode: 'onChange' });
|
||||
|
||||
const [createPublicDashboard, { isLoading, isError }] = useCreatePublicDashboardMutation();
|
||||
const onCreate = () => {
|
||||
DashboardInteractions.generatePublicDashboardUrlClicked({ share: PublicDashboardShareType.PUBLIC });
|
||||
createPublicDashboard({ dashboard, payload: { share: PublicDashboardShareType.PUBLIC, isEnabled: true } });
|
||||
};
|
||||
|
||||
const disableInputs = !hasWritePermissions || isLoading || isError || hasError;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasWritePermissions && <PublicDashboardAlert />}
|
||||
<form onSubmit={handleSubmit(onCreate)}>
|
||||
<FieldSet disabled={disableInputs}>
|
||||
<div className={styles.checkbox}>
|
||||
<Checkbox
|
||||
{...register('publicAcknowledgment', { required: true })}
|
||||
label={t(
|
||||
'public-dashboard.public-sharing.public-ack',
|
||||
'I understand that this entire dashboard will be public.*'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Button type="submit" disabled={!isValid}>
|
||||
<Trans i18nKey="public-dashboard.public-sharing.accept-button">Accept</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => dashboard.closeModal()}>
|
||||
<Trans i18nKey="public-dashboard.public-sharing.cancel-button">Cancel</Trans>
|
||||
</Button>
|
||||
{isLoading && <Spinner />}
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
checkbox: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
|
||||
import { useShareDrawerContext } from '../../../ShareDrawer/ShareDrawerContext';
|
||||
import ShareConfiguration from '../ShareConfiguration';
|
||||
|
||||
import CreatePublicSharing from './CreatePublicSharing';
|
||||
|
||||
export function PublicSharing() {
|
||||
const { dashboard } = useShareDrawerContext();
|
||||
const { data: publicDashboard, isError } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
|
||||
dashboard.state.uid!
|
||||
);
|
||||
|
||||
return <>{!publicDashboard ? <CreatePublicSharing hasError={isError} /> : <ShareConfiguration />}</>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import {
|
||||
CustomVariable,
|
||||
SceneDataTransformer,
|
||||
SceneGridLayout,
|
||||
SceneQueryRunner,
|
||||
SceneTimeRange,
|
||||
SceneVariableSet,
|
||||
VizPanel,
|
||||
VizPanelState,
|
||||
} from '@grafana/scenes';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { DashboardGridItem } from 'app/features/dashboard-scene/scene/DashboardGridItem';
|
||||
import { DashboardScene, DashboardSceneState } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||
|
||||
import { ShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
|
||||
|
||||
import ShareAlerts from './ShareAlerts';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('ShareAlerts', () => {
|
||||
describe('UnsupportedTemplateVariablesAlert', () => {
|
||||
it('should render alert when hasPermission and the dashboard has template vars', async () => {
|
||||
await setup(undefined, {
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [
|
||||
new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
text: 'test',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId(selectors.TemplateVariablesWarningAlert)).toBeInTheDocument();
|
||||
});
|
||||
it('should not render alert when hasPermission but the dashboard has no template vars', async () => {
|
||||
await setup();
|
||||
|
||||
expect(screen.queryByTestId(selectors.TemplateVariablesWarningAlert)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('UnsupportedDataSourcesAlert', () => {
|
||||
it('should render alert when hasPermission and the dashboard has unsupported ds', async () => {
|
||||
await setup({
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'abcdef' },
|
||||
queries: [{ refId: 'A', datasource: { type: 'abcdef' } }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId(selectors.UnsupportedDataSourcesWarningAlert)).toBeInTheDocument();
|
||||
});
|
||||
it('should not render alert when hasPermission but the dashboard has no unsupported ds', async () => {
|
||||
await setup({
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'prometheus' },
|
||||
queries: [{ refId: 'A', datasource: { type: 'prometheus' } }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId(selectors.UnsupportedDataSourcesWarningAlert)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function setup(panelState?: Partial<VizPanelState>, dashboardState?: Partial<DashboardSceneState>) {
|
||||
const panel = new VizPanel({
|
||||
title: 'Panel A',
|
||||
pluginId: 'table',
|
||||
key: 'panel-12',
|
||||
...panelState,
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
...dashboardState,
|
||||
});
|
||||
|
||||
await act(async () =>
|
||||
render(
|
||||
<ShareDrawerContext.Provider value={{ dashboard }}>
|
||||
<ShareAlerts />
|
||||
</ShareDrawerContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { EmailSharingPricingAlert } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/EmailSharingPricingAlert';
|
||||
import { UnsupportedDataSourcesAlert } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert';
|
||||
import { UnsupportedTemplateVariablesAlert } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert';
|
||||
import {
|
||||
isEmailSharingEnabled,
|
||||
PublicDashboard,
|
||||
PublicDashboardShareType,
|
||||
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { NoUpsertPermissionsAlert } from '../../../../dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert';
|
||||
import { useShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
|
||||
import { useUnsupportedDatasources } from '../../public-dashboards/hooks';
|
||||
|
||||
export default function ShareAlerts({ publicDashboard }: { publicDashboard?: PublicDashboard }) {
|
||||
const { dashboard } = useShareDrawerContext();
|
||||
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
|
||||
const unsupportedDataSources = useUnsupportedDatasources(dashboard);
|
||||
const hasTemplateVariables = (dashboard.state.$variables?.state.variables.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasWritePermissions && hasTemplateVariables && <UnsupportedTemplateVariablesAlert showDescription={false} />}
|
||||
{!hasWritePermissions && <NoUpsertPermissionsAlert mode={publicDashboard ? 'edit' : 'create'} />}
|
||||
{hasWritePermissions && !!unsupportedDataSources?.length && (
|
||||
<UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDataSources.join(', ')} />
|
||||
)}
|
||||
{publicDashboard?.share === PublicDashboardShareType.EMAIL && isEmailSharingEnabled() && (
|
||||
<EmailSharingPricingAlert />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { sceneGraph } from '@grafana/scenes';
|
||||
import { FieldSet, Icon, Label, Spinner, Stack, Text, TimeRangeInput, Tooltip } from '@grafana/ui';
|
||||
import { Switch } from '@grafana/ui/src/components/Switch/Switch';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { publicDashboardApi, useUpdatePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { ConfigPublicDashboardForm } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { useShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
|
||||
|
||||
type FormInput = Omit<ConfigPublicDashboardForm, 'isPaused'>;
|
||||
|
||||
export default function ShareConfiguration() {
|
||||
const { dashboard } = useShareDrawerContext();
|
||||
const [update, { isLoading }] = useUpdatePublicDashboardMutation();
|
||||
|
||||
const { data: publicDashboard } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
|
||||
dashboard.state.uid!
|
||||
);
|
||||
|
||||
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
|
||||
const disableForm = isLoading || !hasWritePermissions;
|
||||
const timeRangeState = sceneGraph.getTimeRange(dashboard);
|
||||
const timeRange = timeRangeState.useState();
|
||||
|
||||
const { handleSubmit, setValue, control } = useForm<FormInput>({
|
||||
defaultValues: {
|
||||
isAnnotationsEnabled: publicDashboard?.annotationsEnabled,
|
||||
isTimeSelectionEnabled: publicDashboard?.timeSelectionEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
const onChange = async (name: keyof FormInput, value: boolean) => {
|
||||
setValue(name, value);
|
||||
await handleSubmit((data) => onUpdate({ ...data, [name]: value }))();
|
||||
};
|
||||
|
||||
const onUpdate = (data: FormInput) => {
|
||||
const { isAnnotationsEnabled, isTimeSelectionEnabled } = data;
|
||||
|
||||
update({
|
||||
dashboard: dashboard,
|
||||
payload: {
|
||||
...publicDashboard!,
|
||||
annotationsEnabled: isAnnotationsEnabled,
|
||||
timeSelectionEnabled: isTimeSelectionEnabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Text element="p">
|
||||
<Trans i18nKey="public-dashboard.configuration.settings-label">Settings</Trans>
|
||||
</Text>
|
||||
<Stack justifyContent="space-between">
|
||||
<form onSubmit={handleSubmit(onUpdate)}>
|
||||
<FieldSet disabled={disableForm}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<Stack gap={1}>
|
||||
<Controller
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
data-testid={selectors.EnableTimeRangeSwitch}
|
||||
onChange={(e) => {
|
||||
DashboardInteractions.publicDashboardTimeSelectionChanged({
|
||||
enabled: e.currentTarget.checked,
|
||||
});
|
||||
onChange('isTimeSelectionEnabled', e.currentTarget.checked);
|
||||
}}
|
||||
label={t('public-dashboard.configuration.enable-time-range-label', 'Enable time range')}
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="isTimeSelectionEnabled"
|
||||
/>
|
||||
<Label description="Allow people to change time range">
|
||||
<Trans i18nKey="public-dashboard.configuration.enable-time-range-label">Enable time range</Trans>
|
||||
</Label>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
<Controller
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
data-testid={selectors.EnableAnnotationsSwitch}
|
||||
onChange={(e) => {
|
||||
DashboardInteractions.publicDashboardAnnotationsSelectionChanged({
|
||||
enabled: e.currentTarget.checked,
|
||||
});
|
||||
onChange('isAnnotationsEnabled', e.currentTarget.checked);
|
||||
}}
|
||||
label={t('public-dashboard.configuration.display-annotations-label', 'Display annotations')}
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="isAnnotationsEnabled"
|
||||
/>
|
||||
<Label style={{ flex: 1 }} description="Present annotations on this Dashboard">
|
||||
<Trans i18nKey="public-dashboard.configuration.display-annotations-label">Display annotations</Trans>
|
||||
</Label>
|
||||
</Stack>
|
||||
<Stack gap={1} alignItems="center">
|
||||
<TimeRangeInput value={timeRange.value} showIcon disabled onChange={() => {}} />
|
||||
<Tooltip
|
||||
placement="right"
|
||||
content={t(
|
||||
'public-dashboard.configuration.time-range-tooltip',
|
||||
'The shared dashboard uses the default time range settings of the dashboard'
|
||||
)}
|
||||
>
|
||||
<Icon name="info-circle" size="sm" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
</form>
|
||||
{isLoading && <Spinner />}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
|
||||
import { Button, ClipboardButton, Divider, Spinner, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import {
|
||||
useDeletePublicDashboardMutation,
|
||||
useGetPublicDashboardQuery,
|
||||
usePauseOrResumePublicDashboardMutation,
|
||||
} from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { Loader } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard';
|
||||
import {
|
||||
generatePublicDashboardUrl,
|
||||
isEmailSharingEnabled,
|
||||
PublicDashboard,
|
||||
PublicDashboardShareType,
|
||||
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { getDashboardSceneFor } from '../../../utils/utils';
|
||||
import { useShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
|
||||
|
||||
import { EmailSharing } from './EmailShare/EmailSharing';
|
||||
import { PublicSharing } from './PublicShare/PublicSharing';
|
||||
import ShareAlerts from './ShareAlerts';
|
||||
import ShareTypeSelect from './ShareTypeSelect';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardDrawer.ShareExternally;
|
||||
|
||||
export const getAnyOneWithTheLinkShareOption = () => {
|
||||
return {
|
||||
label: t('public-dashboard.share-externally.public-share-type-option-label', 'Anyone with the link'),
|
||||
description: t(
|
||||
'public-dashboard.share-externally.public-share-type-option-description',
|
||||
'Anyone with the link can access'
|
||||
),
|
||||
value: PublicDashboardShareType.PUBLIC,
|
||||
icon: 'globe',
|
||||
};
|
||||
};
|
||||
|
||||
const getOnlySpecificPeopleShareOption = () => ({
|
||||
label: t('public-dashboard.share-externally.email-share-type-option-label', 'Only specific people'),
|
||||
description: t(
|
||||
'public-dashboard.share-externally.email-share-type-option-description',
|
||||
'Only people with access can open with the link'
|
||||
),
|
||||
value: PublicDashboardShareType.EMAIL,
|
||||
icon: 'users-alt',
|
||||
});
|
||||
|
||||
const getShareExternallyOptions = () => {
|
||||
return isEmailSharingEnabled()
|
||||
? [getOnlySpecificPeopleShareOption(), getAnyOneWithTheLinkShareOption()]
|
||||
: [getAnyOneWithTheLinkShareOption()];
|
||||
};
|
||||
|
||||
export class ShareExternally extends SceneObjectBase {
|
||||
static Component = ShareExternallyRenderer;
|
||||
}
|
||||
|
||||
function ShareExternallyRenderer({ model }: SceneComponentProps<ShareExternally>) {
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { data: publicDashboard, isLoading } = useGetPublicDashboardQuery(dashboard.state.uid!);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<ShareExternallyBase publicDashboard={publicDashboard} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareExternallyBase({ publicDashboard }: { publicDashboard?: PublicDashboard }) {
|
||||
const options = getShareExternallyOptions();
|
||||
const getShareType = useMemo(() => {
|
||||
if (publicDashboard && isEmailSharingEnabled()) {
|
||||
const opt = options.find((opt) => opt.value === publicDashboard?.share)!;
|
||||
return opt ?? options[0];
|
||||
}
|
||||
|
||||
return options[0];
|
||||
}, [publicDashboard, options]);
|
||||
|
||||
const [shareType, setShareType] = useState<SelectableValue<PublicDashboardShareType>>(getShareType);
|
||||
|
||||
const Config = useMemo(() => {
|
||||
if (shareType.value === PublicDashboardShareType.EMAIL && isEmailSharingEnabled()) {
|
||||
return <EmailSharing />;
|
||||
}
|
||||
|
||||
return <PublicSharing />;
|
||||
}, [shareType]);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2} data-testid={selectors.container}>
|
||||
<ShareAlerts publicDashboard={publicDashboard} />
|
||||
<ShareTypeSelect setShareType={setShareType} value={shareType} options={options} />
|
||||
|
||||
{Config}
|
||||
{publicDashboard && (
|
||||
<>
|
||||
<Divider spacing={0} />
|
||||
<Actions publicDashboard={publicDashboard} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
function Actions({ publicDashboard }: { publicDashboard: PublicDashboard }) {
|
||||
const { dashboard } = useShareDrawerContext();
|
||||
const [update, { isLoading: isUpdateLoading }] = usePauseOrResumePublicDashboardMutation();
|
||||
const [deletePublicDashboard, { isLoading: isDeleteLoading }] = useDeletePublicDashboardMutation();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const isLoading = isUpdateLoading || isDeleteLoading;
|
||||
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
|
||||
|
||||
function onCopyURL() {
|
||||
DashboardInteractions.publicDashboardUrlCopied();
|
||||
}
|
||||
|
||||
const onPauseOrResumeClick = async () => {
|
||||
DashboardInteractions.publicDashboardPauseSharingClicked({
|
||||
paused: !publicDashboard.isEnabled,
|
||||
});
|
||||
update({
|
||||
dashboard: dashboard,
|
||||
payload: {
|
||||
...publicDashboard!,
|
||||
isEnabled: !publicDashboard.isEnabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteClick = () => {
|
||||
DashboardInteractions.revokePublicDashboardClicked();
|
||||
deletePublicDashboard({
|
||||
dashboard,
|
||||
uid: publicDashboard!.uid,
|
||||
dashboardUid: dashboard.state.uid!,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" direction={{ xs: 'column', sm: 'row' }}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Stack gap={1} flex={1} direction={{ xs: 'column', sm: 'row' }}>
|
||||
<ClipboardButton
|
||||
data-testid={selectors.copyUrlButton}
|
||||
variant="primary"
|
||||
fill="outline"
|
||||
icon="link"
|
||||
getText={() => generatePublicDashboardUrl(publicDashboard!.accessToken!)}
|
||||
onClipboardCopy={onCopyURL}
|
||||
>
|
||||
<Trans i18nKey="public-dashboard.share-externally.copy-link-button">Copy external link</Trans>
|
||||
</ClipboardButton>
|
||||
<Button
|
||||
icon="trash-alt"
|
||||
variant="destructive"
|
||||
fill="outline"
|
||||
disabled={isLoading || !hasWritePermissions}
|
||||
onClick={onDeleteClick}
|
||||
>
|
||||
<Trans i18nKey="public-dashboard.share-externally.revoke-access-button">Revoke access</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
icon={publicDashboard.isEnabled ? 'pause' : 'play'}
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
tooltip={
|
||||
publicDashboard.isEnabled
|
||||
? t(
|
||||
'public-dashboard.share-externally.pause-access-tooltip',
|
||||
'Pausing will temporarily disable access to this dashboard for all users'
|
||||
)
|
||||
: ''
|
||||
}
|
||||
onClick={onPauseOrResumeClick}
|
||||
disabled={isLoading || !hasWritePermissions}
|
||||
>
|
||||
{publicDashboard.isEnabled ? (
|
||||
<Trans i18nKey="public-dashboard.share-externally.pause-access-button">Pause access</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="public-dashboard.share-externally.resume-access-button">Resume access</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
{isLoading && <Spinner />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
paddingBottom: theme.spacing(2),
|
||||
}),
|
||||
actionsContainer: css({
|
||||
width: '100%',
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { SelectableValue, toIconName } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { Icon, Label, Select, Spinner, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import {
|
||||
publicDashboardApi,
|
||||
useUpdatePublicDashboardAccessMutation,
|
||||
} from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import {
|
||||
isEmailSharingEnabled,
|
||||
PublicDashboardShareType,
|
||||
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { useShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
|
||||
|
||||
import { getAnyOneWithTheLinkShareOption } from './ShareExternally';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardDrawer.ShareExternally;
|
||||
export default function ShareTypeSelect({
|
||||
setShareType,
|
||||
options,
|
||||
value,
|
||||
}: {
|
||||
setShareType: (v: SelectableValue<PublicDashboardShareType>) => void;
|
||||
value: SelectableValue<PublicDashboardShareType>;
|
||||
options: Array<SelectableValue<PublicDashboardShareType>>;
|
||||
}) {
|
||||
const { dashboard } = useShareDrawerContext();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { data: publicDashboard } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
|
||||
dashboard.state.uid!
|
||||
);
|
||||
const [updateAccess, { isLoading }] = useUpdatePublicDashboardAccessMutation();
|
||||
|
||||
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
|
||||
const anyOneWithTheLinkOpt = getAnyOneWithTheLinkShareOption();
|
||||
|
||||
const onUpdateShareType = (shareType: PublicDashboardShareType) => {
|
||||
if (!publicDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
DashboardInteractions.publicDashboardShareTypeChange({
|
||||
shareType: shareType === PublicDashboardShareType.EMAIL ? 'email' : 'public',
|
||||
});
|
||||
|
||||
const req = {
|
||||
dashboard,
|
||||
payload: {
|
||||
...publicDashboard!,
|
||||
share: shareType,
|
||||
},
|
||||
};
|
||||
|
||||
updateAccess(req);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack justifyContent="space-between">
|
||||
<Label description={value.description}>
|
||||
<Trans i18nKey="public-dashboard.share-configuration.share-type-label">Link access</Trans>
|
||||
</Label>
|
||||
{isLoading && <Spinner />}
|
||||
</Stack>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
{isEmailSharingEnabled() ? (
|
||||
<Select
|
||||
data-testid={selectors.shareTypeSelect}
|
||||
options={options}
|
||||
value={value}
|
||||
disabled={!hasWritePermissions}
|
||||
onChange={(v) => {
|
||||
setShareType(v);
|
||||
onUpdateShareType(v.value!);
|
||||
}}
|
||||
className={styles.select}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{toIconName(anyOneWithTheLinkOpt.icon) && <Icon name={toIconName(anyOneWithTheLinkOpt.icon)!} />}
|
||||
<Text>{anyOneWithTheLinkOpt.label}</Text>
|
||||
</>
|
||||
)}
|
||||
<Text element="p" variant="bodySmall" color="disabled">
|
||||
<Trans i18nKey="public-dashboard.share-configuration.access-label">can access</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
select: css({
|
||||
flex: 1,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { createAndCopyDashboardShortLink } from 'app/core/utils/shortLinks';
|
||||
import { getTrackingSource } from 'app/features/dashboard/components/ShareModal/utils';
|
||||
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
import { DashboardInteractions } from '../../utils/interactions';
|
||||
|
||||
export const buildShareUrl = async (dashboard: DashboardScene, panel?: VizPanel) => {
|
||||
DashboardInteractions.shareLinkCopied({
|
||||
currentTimeRange: true,
|
||||
theme: 'current',
|
||||
shortenURL: true,
|
||||
shareResource: getTrackingSource(panel?.getRef()),
|
||||
});
|
||||
return await createAndCopyDashboardShortLink(dashboard, { useAbsoluteTimeRange: true, theme: 'current' });
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
SceneComponentProps,
|
||||
SceneObject,
|
||||
SceneObjectBase,
|
||||
SceneObjectRef,
|
||||
SceneObjectState,
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { Drawer } from '@grafana/ui';
|
||||
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
import { ModalSceneObjectLike } from '../types';
|
||||
|
||||
import { ShareDrawerContext } from './ShareDrawerContext';
|
||||
|
||||
export interface ShareDrawerState extends SceneObjectState {
|
||||
title: string;
|
||||
panelRef?: SceneObjectRef<VizPanel>;
|
||||
body: SceneObject;
|
||||
}
|
||||
|
||||
export class ShareDrawer extends SceneObjectBase<ShareDrawerState> implements ModalSceneObjectLike {
|
||||
static Component = ShareDrawerRenderer;
|
||||
|
||||
onDismiss = () => {
|
||||
const dashboard = getDashboardSceneFor(this);
|
||||
dashboard.closeModal();
|
||||
};
|
||||
}
|
||||
|
||||
function ShareDrawerRenderer({ model }: SceneComponentProps<ShareDrawer>) {
|
||||
const { title, body } = model.useState();
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
|
||||
return (
|
||||
<Drawer title={title} onClose={model.onDismiss} size="md">
|
||||
<ShareDrawerContext.Provider value={{ dashboard }}>{<body.Component model={body} />}</ShareDrawerContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
|
||||
interface Context {
|
||||
dashboard: DashboardScene;
|
||||
}
|
||||
|
||||
export const ShareDrawerContext = React.createContext<Context | undefined>(undefined);
|
||||
|
||||
const useShareDrawerContext = () => {
|
||||
const context = React.useContext(ShareDrawerContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useShareDrawerContext must be used within a DrawerContext');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export { useShareDrawerContext };
|
||||
@@ -1,12 +1,14 @@
|
||||
import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { BackendSrvRequest, FetchError, getBackendSrv, isFetchError } from '@grafana/runtime/src';
|
||||
import { BackendSrvRequest, config, FetchError, getBackendSrv, isFetchError } from '@grafana/runtime/src';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import {
|
||||
PublicDashboard,
|
||||
PublicDashboardSettings,
|
||||
PublicDashboardShareType,
|
||||
SessionDashboard,
|
||||
SessionUser,
|
||||
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
@@ -43,7 +45,9 @@ const backendSrvBaseQuery =
|
||||
}
|
||||
};
|
||||
|
||||
const getConfigError = (err: unknown) => ({ error: isFetchError(err) && err.status !== 404 ? err : null });
|
||||
const getConfigError = (err: unknown) => ({
|
||||
error: isFetchError(err) && err.data.messageId !== 'publicdashboards.notFound' ? err : null,
|
||||
});
|
||||
|
||||
export const publicDashboardApi = createApi({
|
||||
reducerPath: 'publicDashboardApi',
|
||||
@@ -80,16 +84,22 @@ export const publicDashboardApi = createApi({
|
||||
data: params.payload,
|
||||
};
|
||||
},
|
||||
async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) {
|
||||
async onQueryStarted({ dashboard, payload: { share } }, { dispatch, queryFulfilled }) {
|
||||
const { data } = await queryFulfilled;
|
||||
dispatch(notifyApp(createSuccessNotification('Dashboard is public!')));
|
||||
let message = t('public-dashboard.sharing.success-creation', 'Dashboard is public!');
|
||||
if (config.featureToggles.newDashboardSharingComponent) {
|
||||
message =
|
||||
share === PublicDashboardShareType.PUBLIC
|
||||
? t('public-dashboard.public-sharing.success-creation', 'Your dashboard is now publicly accessible')
|
||||
: t('public-dashboard.email-sharing.success-creation', 'Your dashboard is ready for external sharing');
|
||||
}
|
||||
dispatch(notifyApp(createSuccessNotification(message)));
|
||||
|
||||
if (dashboard instanceof DashboardScene) {
|
||||
dashboard.setState({
|
||||
meta: { ...dashboard.state.meta, publicDashboardEnabled: data.isEnabled },
|
||||
});
|
||||
} else {
|
||||
// Update runtime meta flag
|
||||
dashboard.updateMeta({
|
||||
publicDashboardEnabled: data.isEnabled,
|
||||
});
|
||||
@@ -115,8 +125,46 @@ export const publicDashboardApi = createApi({
|
||||
};
|
||||
},
|
||||
async onQueryStarted({ dashboard }, { dispatch, queryFulfilled }) {
|
||||
await queryFulfilled;
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createSuccessNotification(
|
||||
config.featureToggles.newDashboardSharingComponent
|
||||
? t('public-dashboard.configuration.success-update', 'Settings have been successfully updated')
|
||||
: t('public-dashboard.configuration.success-update-old', 'Public dashboard updated!')
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
invalidatesTags: (result, error, { payload }) => [
|
||||
{ type: 'PublicDashboard', id: payload.dashboardUid },
|
||||
'AuditTablePublicDashboard',
|
||||
],
|
||||
}),
|
||||
pauseOrResumePublicDashboard: builder.mutation<
|
||||
PublicDashboard,
|
||||
{
|
||||
dashboard: (Pick<DashboardModel, 'uid'> & Partial<Pick<DashboardModel, 'updateMeta'>>) | DashboardScene;
|
||||
payload: Partial<PublicDashboard>;
|
||||
}
|
||||
>({
|
||||
query: ({ payload, dashboard }) => {
|
||||
const dashUid = dashboard instanceof DashboardScene ? dashboard.state.uid : dashboard.uid;
|
||||
return {
|
||||
url: `/dashboards/uid/${dashUid}/public-dashboards/${payload.uid}`,
|
||||
method: 'PATCH',
|
||||
data: payload,
|
||||
};
|
||||
},
|
||||
async onQueryStarted({ dashboard, payload: { isEnabled } }, { dispatch, queryFulfilled }) {
|
||||
const { data } = await queryFulfilled;
|
||||
dispatch(notifyApp(createSuccessNotification('Public dashboard updated!')));
|
||||
let message = t('public-dashboard.configuration.success-update-old', 'Public dashboard updated!');
|
||||
if (config.featureToggles.newDashboardSharingComponent) {
|
||||
message = isEnabled
|
||||
? t('public-dashboard.configuration.success-resume', 'Your dashboard access has been resumed')
|
||||
: t('public-dashboard.configuration.success-pause', 'Your dashboard access has been paused');
|
||||
}
|
||||
dispatch(notifyApp(createSuccessNotification(message)));
|
||||
|
||||
if (dashboard instanceof DashboardScene) {
|
||||
dashboard.setState({
|
||||
@@ -133,6 +181,44 @@ export const publicDashboardApi = createApi({
|
||||
'AuditTablePublicDashboard',
|
||||
],
|
||||
}),
|
||||
updatePublicDashboardAccess: builder.mutation<
|
||||
PublicDashboard,
|
||||
{
|
||||
dashboard: (Pick<DashboardModel, 'uid'> & Partial<Pick<DashboardModel, 'updateMeta'>>) | DashboardScene;
|
||||
payload: Partial<PublicDashboard>;
|
||||
}
|
||||
>({
|
||||
query: ({ payload, dashboard }) => {
|
||||
const dashUid = dashboard instanceof DashboardScene ? dashboard.state.uid : dashboard.uid;
|
||||
return {
|
||||
url: `/dashboards/uid/${dashUid}/public-dashboards/${payload.uid}`,
|
||||
method: 'PATCH',
|
||||
data: payload,
|
||||
};
|
||||
},
|
||||
async onQueryStarted({ dashboard, payload: { share } }, { dispatch, queryFulfilled }) {
|
||||
await queryFulfilled;
|
||||
let message = t('public-dashboard.configuration.success-update-old', 'Public dashboard updated!');
|
||||
|
||||
if (config.featureToggles.newDashboardSharingComponent) {
|
||||
message =
|
||||
share === PublicDashboardShareType.PUBLIC
|
||||
? t(
|
||||
'public-dashboard.public-sharing.success-share-type-change',
|
||||
'Dashboard access updated: Anyone with the link can now access'
|
||||
)
|
||||
: t(
|
||||
'public-dashboard.email-sharing.success-share-type-change',
|
||||
'Dashboard access restricted: Only specific people can now access with the link'
|
||||
);
|
||||
}
|
||||
dispatch(notifyApp(createSuccessNotification(message)));
|
||||
},
|
||||
invalidatesTags: (result, error, { payload }) => [
|
||||
{ type: 'PublicDashboard', id: payload.dashboardUid },
|
||||
'AuditTablePublicDashboard',
|
||||
],
|
||||
}),
|
||||
addRecipient: builder.mutation<void, { recipient: string; dashboardUid: string; uid: string }>({
|
||||
query: () => ({
|
||||
url: '',
|
||||
@@ -183,7 +269,16 @@ export const publicDashboardApi = createApi({
|
||||
}),
|
||||
async onQueryStarted({ dashboard }, { dispatch, queryFulfilled }) {
|
||||
await queryFulfilled;
|
||||
dispatch(notifyApp(createSuccessNotification('Public dashboard deleted!')));
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createSuccessNotification(
|
||||
config.featureToggles.newDashboardSharingComponent
|
||||
? t('public-dashboard.share.success-delete', 'Your dashboard is no longer shareable')
|
||||
: t('public-dashboard.share.success-delete-old', 'Public dashboard deleted!')
|
||||
)
|
||||
)
|
||||
);
|
||||
dispatch(publicDashboardApi.util?.resetApiState());
|
||||
|
||||
if (dashboard instanceof DashboardScene) {
|
||||
dashboard.setState({
|
||||
@@ -222,4 +317,6 @@ export const {
|
||||
useGetActiveUsersQuery,
|
||||
useGetActiveUserDashboardsQuery,
|
||||
useRevokeAllAccessMutation,
|
||||
usePauseOrResumePublicDashboardMutation,
|
||||
useUpdatePublicDashboardAccessMutation,
|
||||
} = publicDashboardApi;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2, TimeRange } from '@grafana/data/src';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { config, featureEnabled } from '@grafana/runtime/src';
|
||||
import {
|
||||
Button,
|
||||
ClipboardButton,
|
||||
@@ -20,6 +19,7 @@ import { Layout } from '@grafana/ui/src/components/Layout/Layout';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import {
|
||||
useDeletePublicDashboardMutation,
|
||||
usePauseOrResumePublicDashboardMutation,
|
||||
useUpdatePublicDashboardMutation,
|
||||
} from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { DashboardModel } from 'app/features/dashboard/state';
|
||||
@@ -40,6 +40,7 @@ import { UnsupportedTemplateVariablesAlert } from '../ModalAlerts/UnsupportedTem
|
||||
import {
|
||||
dashboardHasTemplateVariables,
|
||||
generatePublicDashboardUrl,
|
||||
isEmailSharingEnabled,
|
||||
PublicDashboard,
|
||||
} from '../SharePublicDashboardUtils';
|
||||
|
||||
@@ -79,10 +80,9 @@ export function ConfigPublicDashboardBase({
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
const [update, { isLoading }] = useUpdatePublicDashboardMutation();
|
||||
const [pauseOrResume, { isLoading: isPauseOrResumeLoading }] = usePauseOrResumePublicDashboardMutation();
|
||||
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
|
||||
const disableInputs = !hasWritePermissions || isLoading;
|
||||
const hasEmailSharingEnabled =
|
||||
!!config.featureToggles.publicDashboardsEmailSharing && featureEnabled('publicDashboardsEmailSharing');
|
||||
const disableInputs = !hasWritePermissions || isLoading || isPauseOrResumeLoading;
|
||||
|
||||
const { handleSubmit, setValue, register } = useForm<ConfigPublicDashboardForm>({
|
||||
defaultValues: {
|
||||
@@ -105,12 +105,30 @@ export function ConfigPublicDashboardBase({
|
||||
},
|
||||
});
|
||||
};
|
||||
const onPauseOrResume = async (values: ConfigPublicDashboardForm) => {
|
||||
const { isAnnotationsEnabled, isTimeSelectionEnabled, isPaused } = values;
|
||||
|
||||
pauseOrResume({
|
||||
dashboard: dashboard,
|
||||
payload: {
|
||||
...publicDashboard!,
|
||||
annotationsEnabled: isAnnotationsEnabled,
|
||||
timeSelectionEnabled: isTimeSelectionEnabled,
|
||||
isEnabled: !isPaused,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChange = async (name: keyof ConfigPublicDashboardForm, value: boolean) => {
|
||||
setValue(name, value);
|
||||
await handleSubmit((data) => onPublicDashboardUpdate(data))();
|
||||
};
|
||||
|
||||
const onTogglePause = async (value: boolean) => {
|
||||
setValue('isPaused', value);
|
||||
await handleSubmit((data) => onPauseOrResume(data))();
|
||||
};
|
||||
|
||||
function onCopyURL() {
|
||||
DashboardInteractions.publicDashboardUrlCopied();
|
||||
}
|
||||
@@ -124,7 +142,7 @@ export function ConfigPublicDashboardBase({
|
||||
<UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDatasources.join(', ')} />
|
||||
)}
|
||||
|
||||
{hasEmailSharingEnabled && <EmailSharingConfiguration dashboard={dashboard} />}
|
||||
{isEmailSharingEnabled() && <EmailSharingConfiguration dashboard={dashboard} />}
|
||||
|
||||
<Field
|
||||
label={t('public-dashboard.config.dashboard-url-field-label', 'Dashboard URL')}
|
||||
@@ -158,7 +176,7 @@ export function ConfigPublicDashboardBase({
|
||||
DashboardInteractions.publicDashboardPauseSharingClicked({
|
||||
paused: e.currentTarget.checked,
|
||||
});
|
||||
onChange('isPaused', e.currentTarget.checked);
|
||||
onTogglePause(e.currentTarget.checked);
|
||||
}}
|
||||
data-testid={selectors.PauseSwitch}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useDeleteRecipientMutation,
|
||||
useGetPublicDashboardQuery,
|
||||
useReshareAccessToRecipientMutation,
|
||||
useUpdatePublicDashboardMutation,
|
||||
useUpdatePublicDashboardAccessMutation,
|
||||
} from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||
@@ -101,7 +101,7 @@ export const EmailSharingConfiguration = ({ dashboard }: { dashboard: DashboardM
|
||||
|
||||
const dashboardUid = dashboard instanceof DashboardScene ? dashboard.state.uid : dashboard.uid;
|
||||
const { data: publicDashboard } = useGetPublicDashboardQuery(dashboardUid);
|
||||
const [updateShareType] = useUpdatePublicDashboardMutation();
|
||||
const [updateShareType] = useUpdatePublicDashboardAccessMutation();
|
||||
const [addEmail, { isLoading: isAddEmailLoading }] = useAddRecipientMutation();
|
||||
|
||||
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Alert, Button, Stack } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
const EMAIL_SHARING_URL = 'https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/#email-sharing';
|
||||
|
||||
export function EmailSharingPricingAlert() {
|
||||
return (
|
||||
<Alert title="" severity="info" bottomSpacing={0}>
|
||||
<Stack justifyContent="space-between" gap={2} alignItems="center">
|
||||
<Trans i18nKey="public-dashboard.email-sharing.alert-text">
|
||||
Effective immediately, sharing public dashboards by email incurs a cost per active user. Going forward, you’ll
|
||||
be prompted for payment whenever you add new users to your dashboard.
|
||||
</Trans>
|
||||
<Button variant="secondary" onClick={() => window.open(EMAIL_SHARING_URL, '_blank')} type="button">
|
||||
<Trans i18nKey="public-dashboard.email-sharing.learn-more-button">Learn more</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
|
||||
|
||||
export const NoUpsertPermissionsAlert = ({ mode }: { mode: 'create' | 'edit' }) => (
|
||||
<Alert
|
||||
severity="info"
|
||||
severity="warning"
|
||||
title={t(
|
||||
'public-dashboard.modal-alerts.no-upsert-perm-alert-title',
|
||||
'You don’t have permission to {{ mode }} a public dashboard',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Alert, Button, Stack } from '@grafana/ui';
|
||||
|
||||
import { Trans } from '../../../../../../core/internationalization';
|
||||
|
||||
const PUBLIC_DASHBOARD_URL = 'https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/';
|
||||
export const PublicDashboardAlert = () => (
|
||||
<Alert title="" severity="info" bottomSpacing={0}>
|
||||
<Stack justifyContent="space-between" gap={2} alignItems="center">
|
||||
<Trans i18nKey="public-dashboard.public-sharing.alert-text">
|
||||
Sharing this dashboard externally makes it entirely accessible to anyone with the link.
|
||||
</Trans>
|
||||
<Button variant="secondary" onClick={() => window.open(PUBLIC_DASHBOARD_URL, '_blank')} type="button">
|
||||
<Trans i18nKey="public-dashboard.public-sharing.learn-more-button">Learn more</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
@@ -6,7 +6,7 @@ import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
|
||||
|
||||
export const UnsupportedTemplateVariablesAlert = () => (
|
||||
export const UnsupportedTemplateVariablesAlert = ({ showDescription = true }: { showDescription?: boolean }) => (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t(
|
||||
@@ -16,8 +16,10 @@ export const UnsupportedTemplateVariablesAlert = () => (
|
||||
data-testid={selectors.TemplateVariablesWarningAlert}
|
||||
bottomSpacing={0}
|
||||
>
|
||||
<Trans i18nKey="public-dashboard.modal-alerts.unsupported-template-variable-alert-desc">
|
||||
This public dashboard may not work since it uses template variables
|
||||
</Trans>
|
||||
{showDescription && (
|
||||
<Trans i18nKey="public-dashboard.modal-alerts.unsupported-template-variable-alert-desc">
|
||||
This public dashboard may not work since it uses template variables
|
||||
</Trans>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TypedVariableModel } from '@grafana/data';
|
||||
import { config, DataSourceWithBackend } from '@grafana/runtime';
|
||||
import { config, DataSourceWithBackend, featureEnabled } from '@grafana/runtime';
|
||||
import { getConfig } from 'app/core/config';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface PublicDashboardSettings {
|
||||
annotationsEnabled: boolean;
|
||||
isEnabled: boolean;
|
||||
timeSelectionEnabled: boolean;
|
||||
share: PublicDashboardShareType;
|
||||
}
|
||||
|
||||
export interface PublicDashboard extends PublicDashboardSettings {
|
||||
@@ -24,7 +25,6 @@ export interface PublicDashboard extends PublicDashboardSettings {
|
||||
uid: string;
|
||||
dashboardUid: string;
|
||||
timeSettings?: object;
|
||||
share: PublicDashboardShareType;
|
||||
recipients?: Array<{ uid: string; recipient: string }>;
|
||||
}
|
||||
|
||||
@@ -97,3 +97,8 @@ export const validEmailRegex = /^[A-Z\d._%+-]+@[A-Z\d.-]+\.[A-Z]{2,}$/i;
|
||||
export const isPublicDashboardsEnabled = () => {
|
||||
return Boolean(config.featureToggles.publicDashboards) && config.publicDashboardsEnabled;
|
||||
};
|
||||
|
||||
export const isEmailSharingEnabled = () =>
|
||||
isPublicDashboardsEnabled() &&
|
||||
!!config.featureToggles.publicDashboardsEmailSharing &&
|
||||
featureEnabled('publicDashboardsEmailSharing');
|
||||
|
||||
@@ -1384,6 +1384,16 @@
|
||||
"revoke-public-URL-button-title": "Revoke public URL",
|
||||
"settings-title": "Settings"
|
||||
},
|
||||
"configuration": {
|
||||
"display-annotations-label": "Display annotations",
|
||||
"enable-time-range-label": "Enable time range",
|
||||
"settings-label": "Settings",
|
||||
"success-pause": "Your dashboard access has been paused",
|
||||
"success-resume": "Your dashboard access has been resumed",
|
||||
"success-update": "Settings have been successfully updated",
|
||||
"success-update-old": "Public dashboard updated!",
|
||||
"time-range-tooltip": "The shared dashboard uses the default time range settings of the dashboard"
|
||||
},
|
||||
"create-page": {
|
||||
"generate-public-url-button": "Generate public URL",
|
||||
"unsupported-features-desc": "Currently, we don’t support template variables or frontend data sources",
|
||||
@@ -1395,15 +1405,32 @@
|
||||
"revoke-title": "Revoke public URL"
|
||||
},
|
||||
"email-sharing": {
|
||||
"accept-button": "Accept",
|
||||
"alert-text": "Effective immediately, sharing public dashboards by email incurs a cost per active user. Going forward, you’ll be prompted for payment whenever you add new users to your dashboard.",
|
||||
"bill-ack": "I understand that adding users requires payment.*",
|
||||
"cancel-button": "Cancel",
|
||||
"input-invalid-email-text": "Invalid email",
|
||||
"input-required-email-text": "Email is required",
|
||||
"invite-button": "Invite",
|
||||
"invite-field-desc": "Invite people by email",
|
||||
"invite-field-label": "Invite",
|
||||
"learn-more-button": "Learn more",
|
||||
"recipient-email-placeholder": "Type in the recipient email address and press Enter",
|
||||
"recipient-invalid-email-text": "Invalid email",
|
||||
"recipient-invitation-button": "Invite",
|
||||
"recipient-invitation-description": "Invite someone by email",
|
||||
"recipient-invitation-tooltip": "This dashboard contains sensitive data. By using this feature you will be sharing with external people.",
|
||||
"recipient-list-description": "Only people you've directly invited can access this dashboard",
|
||||
"recipient-list-title": "People with access",
|
||||
"recipient-required-email-text": "Email is required",
|
||||
"resend-button": "Resend",
|
||||
"resend-button-title": "Resend",
|
||||
"resend-invite-label": "Resend invite",
|
||||
"revoke-access-label": "Revoke access",
|
||||
"revoke-button": "Revoke",
|
||||
"revoke-button-title": "Revoke"
|
||||
"revoke-button-title": "Revoke",
|
||||
"success-creation": "Your dashboard is ready for external sharing",
|
||||
"success-share-type-change": "Dashboard access restricted: Only specific people can now access with the link"
|
||||
},
|
||||
"modal-alerts": {
|
||||
"no-upsert-perm-alert-desc": "Contact your admin to get permission to {{mode}} public dashboards",
|
||||
@@ -1415,6 +1442,15 @@
|
||||
"unsupported-template-variable-alert-desc": "This public dashboard may not work since it uses template variables",
|
||||
"unsupported-template-variable-alert-title": "Template variables are not supported"
|
||||
},
|
||||
"public-sharing": {
|
||||
"accept-button": "Accept",
|
||||
"alert-text": "Sharing this dashboard externally makes it entirely accessible to anyone with the link.",
|
||||
"cancel-button": "Cancel",
|
||||
"learn-more-button": "Learn more",
|
||||
"public-ack": "I understand that this entire dashboard will be public.*",
|
||||
"success-creation": "Your dashboard is now publicly accessible",
|
||||
"success-share-type-change": "Dashboard access updated: Anyone with the link can now access"
|
||||
},
|
||||
"settings-bar-header": {
|
||||
"collapse-settings-tooltip": "Collapse settings",
|
||||
"expand-settings-tooltip": "Expand settings"
|
||||
@@ -1433,6 +1469,28 @@
|
||||
"time-range-picker-disabled-text": "Time range picker = disabled",
|
||||
"time-range-picker-enabled-text": "Time range picker = enabled",
|
||||
"time-range-text": "Time range = "
|
||||
},
|
||||
"share": {
|
||||
"success-delete": "Your dashboard is no longer shareable",
|
||||
"success-delete-old": "Public dashboard deleted!"
|
||||
},
|
||||
"share-configuration": {
|
||||
"access-label": "can access",
|
||||
"share-type-label": "Link access"
|
||||
},
|
||||
"share-externally": {
|
||||
"copy-link-button": "Copy external link",
|
||||
"email-share-type-option-description": "Only people with access can open with the link",
|
||||
"email-share-type-option-label": "Only specific people",
|
||||
"pause-access-button": "Pause access",
|
||||
"pause-access-tooltip": "Pausing will temporarily disable access to this dashboard for all users",
|
||||
"public-share-type-option-description": "Anyone with the link can access",
|
||||
"public-share-type-option-label": "Anyone with the link",
|
||||
"resume-access-button": "Resume access",
|
||||
"revoke-access-button": "Revoke access"
|
||||
},
|
||||
"sharing": {
|
||||
"success-creation": "Dashboard is public!"
|
||||
}
|
||||
},
|
||||
"public-dashboard-list": {
|
||||
|
||||
@@ -1384,6 +1384,16 @@
|
||||
"revoke-public-URL-button-title": "Ŗęvőĸę pūþľįč ŮŖĿ",
|
||||
"settings-title": "Ŝęŧŧįʼnģş"
|
||||
},
|
||||
"configuration": {
|
||||
"display-annotations-label": "Đįşpľäy äʼnʼnőŧäŧįőʼnş",
|
||||
"enable-time-range-label": "Ēʼnäþľę ŧįmę řäʼnģę",
|
||||
"settings-label": "Ŝęŧŧįʼnģş",
|
||||
"success-pause": "Ÿőūř đäşĥþőäřđ äččęşş ĥäş þęęʼn päūşęđ",
|
||||
"success-resume": "Ÿőūř đäşĥþőäřđ äččęşş ĥäş þęęʼn řęşūmęđ",
|
||||
"success-update": "Ŝęŧŧįʼnģş ĥävę þęęʼn şūččęşşƒūľľy ūpđäŧęđ",
|
||||
"success-update-old": "Pūþľįč đäşĥþőäřđ ūpđäŧęđ!",
|
||||
"time-range-tooltip": "Ŧĥę şĥäřęđ đäşĥþőäřđ ūşęş ŧĥę đęƒäūľŧ ŧįmę řäʼnģę şęŧŧįʼnģş őƒ ŧĥę đäşĥþőäřđ"
|
||||
},
|
||||
"create-page": {
|
||||
"generate-public-url-button": "Ğęʼnęřäŧę pūþľįč ŮŖĿ",
|
||||
"unsupported-features-desc": "Cūřřęʼnŧľy, ŵę đőʼn’ŧ şūppőřŧ ŧęmpľäŧę väřįäþľęş őř ƒřőʼnŧęʼnđ đäŧä şőūřčęş",
|
||||
@@ -1395,15 +1405,32 @@
|
||||
"revoke-title": "Ŗęvőĸę pūþľįč ŮŖĿ"
|
||||
},
|
||||
"email-sharing": {
|
||||
"accept-button": "Åččępŧ",
|
||||
"alert-text": "Ēƒƒęčŧįvę įmmęđįäŧęľy, şĥäřįʼnģ pūþľįč đäşĥþőäřđş þy ęmäįľ įʼnčūřş ä čőşŧ pęř äčŧįvę ūşęř. Ğőįʼnģ ƒőřŵäřđ, yőū’ľľ þę přőmpŧęđ ƒőř päymęʼnŧ ŵĥęʼnęvęř yőū äđđ ʼnęŵ ūşęřş ŧő yőūř đäşĥþőäřđ.",
|
||||
"bill-ack": "Ĩ ūʼnđęřşŧäʼnđ ŧĥäŧ äđđįʼnģ ūşęřş řęqūįřęş päymęʼnŧ.*",
|
||||
"cancel-button": "Cäʼnčęľ",
|
||||
"input-invalid-email-text": "Ĩʼnväľįđ ęmäįľ",
|
||||
"input-required-email-text": "Ēmäįľ įş řęqūįřęđ",
|
||||
"invite-button": "Ĩʼnvįŧę",
|
||||
"invite-field-desc": "Ĩʼnvįŧę pęőpľę þy ęmäįľ",
|
||||
"invite-field-label": "Ĩʼnvįŧę",
|
||||
"learn-more-button": "Ŀęäřʼn mőřę",
|
||||
"recipient-email-placeholder": "Ŧypę įʼn ŧĥę řęčįpįęʼnŧ ęmäįľ äđđřęşş äʼnđ přęşş Ēʼnŧęř",
|
||||
"recipient-invalid-email-text": "Ĩʼnväľįđ ęmäįľ",
|
||||
"recipient-invitation-button": "Ĩʼnvįŧę",
|
||||
"recipient-invitation-description": "Ĩʼnvįŧę şőmęőʼnę þy ęmäįľ",
|
||||
"recipient-invitation-tooltip": "Ŧĥįş đäşĥþőäřđ čőʼnŧäįʼnş şęʼnşįŧįvę đäŧä. ßy ūşįʼnģ ŧĥįş ƒęäŧūřę yőū ŵįľľ þę şĥäřįʼnģ ŵįŧĥ ęχŧęřʼnäľ pęőpľę.",
|
||||
"recipient-list-description": "Øʼnľy pęőpľę yőū'vę đįřęčŧľy įʼnvįŧęđ čäʼn äččęşş ŧĥįş đäşĥþőäřđ",
|
||||
"recipient-list-title": "Pęőpľę ŵįŧĥ äččęşş",
|
||||
"recipient-required-email-text": "Ēmäįľ įş řęqūįřęđ",
|
||||
"resend-button": "Ŗęşęʼnđ",
|
||||
"resend-button-title": "Ŗęşęʼnđ",
|
||||
"resend-invite-label": "Ŗęşęʼnđ įʼnvįŧę",
|
||||
"revoke-access-label": "Ŗęvőĸę äččęşş",
|
||||
"revoke-button": "Ŗęvőĸę",
|
||||
"revoke-button-title": "Ŗęvőĸę"
|
||||
"revoke-button-title": "Ŗęvőĸę",
|
||||
"success-creation": "Ÿőūř đäşĥþőäřđ įş řęäđy ƒőř ęχŧęřʼnäľ şĥäřįʼnģ",
|
||||
"success-share-type-change": "Đäşĥþőäřđ äččęşş řęşŧřįčŧęđ: Øʼnľy şpęčįƒįč pęőpľę čäʼn ʼnőŵ äččęşş ŵįŧĥ ŧĥę ľįʼnĸ"
|
||||
},
|
||||
"modal-alerts": {
|
||||
"no-upsert-perm-alert-desc": "Cőʼnŧäčŧ yőūř äđmįʼn ŧő ģęŧ pęřmįşşįőʼn ŧő {{mode}} pūþľįč đäşĥþőäřđş",
|
||||
@@ -1415,6 +1442,15 @@
|
||||
"unsupported-template-variable-alert-desc": "Ŧĥįş pūþľįč đäşĥþőäřđ mäy ʼnőŧ ŵőřĸ şįʼnčę įŧ ūşęş ŧęmpľäŧę väřįäþľęş",
|
||||
"unsupported-template-variable-alert-title": "Ŧęmpľäŧę väřįäþľęş äřę ʼnőŧ şūppőřŧęđ"
|
||||
},
|
||||
"public-sharing": {
|
||||
"accept-button": "Åččępŧ",
|
||||
"alert-text": "Ŝĥäřįʼnģ ŧĥįş đäşĥþőäřđ ęχŧęřʼnäľľy mäĸęş įŧ ęʼnŧįřęľy äččęşşįþľę ŧő äʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ.",
|
||||
"cancel-button": "Cäʼnčęľ",
|
||||
"learn-more-button": "Ŀęäřʼn mőřę",
|
||||
"public-ack": "Ĩ ūʼnđęřşŧäʼnđ ŧĥäŧ ŧĥįş ęʼnŧįřę đäşĥþőäřđ ŵįľľ þę pūþľįč.*",
|
||||
"success-creation": "Ÿőūř đäşĥþőäřđ įş ʼnőŵ pūþľįčľy äččęşşįþľę",
|
||||
"success-share-type-change": "Đäşĥþőäřđ äččęşş ūpđäŧęđ: Åʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ čäʼn ʼnőŵ äččęşş"
|
||||
},
|
||||
"settings-bar-header": {
|
||||
"collapse-settings-tooltip": "Cőľľäpşę şęŧŧįʼnģş",
|
||||
"expand-settings-tooltip": "Ēχpäʼnđ şęŧŧįʼnģş"
|
||||
@@ -1433,6 +1469,28 @@
|
||||
"time-range-picker-disabled-text": "Ŧįmę řäʼnģę pįčĸęř = đįşäþľęđ",
|
||||
"time-range-picker-enabled-text": "Ŧįmę řäʼnģę pįčĸęř = ęʼnäþľęđ",
|
||||
"time-range-text": "Ŧįmę řäʼnģę = "
|
||||
},
|
||||
"share": {
|
||||
"success-delete": "Ÿőūř đäşĥþőäřđ įş ʼnő ľőʼnģęř şĥäřęäþľę",
|
||||
"success-delete-old": "Pūþľįč đäşĥþőäřđ đęľęŧęđ!"
|
||||
},
|
||||
"share-configuration": {
|
||||
"access-label": "čäʼn äččęşş",
|
||||
"share-type-label": "Ŀįʼnĸ äččęşş"
|
||||
},
|
||||
"share-externally": {
|
||||
"copy-link-button": "Cőpy ęχŧęřʼnäľ ľįʼnĸ",
|
||||
"email-share-type-option-description": "Øʼnľy pęőpľę ŵįŧĥ äččęşş čäʼn őpęʼn ŵįŧĥ ŧĥę ľįʼnĸ",
|
||||
"email-share-type-option-label": "Øʼnľy şpęčįƒįč pęőpľę",
|
||||
"pause-access-button": "Päūşę äččęşş",
|
||||
"pause-access-tooltip": "Päūşįʼnģ ŵįľľ ŧęmpőřäřįľy đįşäþľę äččęşş ŧő ŧĥįş đäşĥþőäřđ ƒőř äľľ ūşęřş",
|
||||
"public-share-type-option-description": "Åʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ čäʼn äččęşş",
|
||||
"public-share-type-option-label": "Åʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ",
|
||||
"resume-access-button": "Ŗęşūmę äččęşş",
|
||||
"revoke-access-button": "Ŗęvőĸę äččęşş"
|
||||
},
|
||||
"sharing": {
|
||||
"success-creation": "Đäşĥþőäřđ įş pūþľįč!"
|
||||
}
|
||||
},
|
||||
"public-dashboard-list": {
|
||||
|
||||
Reference in New Issue
Block a user