From 7664b8920974bbdd80fa916063ee13096028edd2 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Wed, 12 Jun 2024 17:02:06 -0300 Subject: [PATCH 1/5] ShareModal: Share externally (#88259) --- .../src/selectors/pages.ts | 8 + public/app/features/admin/UserListPage.tsx | 12 +- .../sharing/ShareButton/ShareButton.tsx | 7 +- .../sharing/ShareButton/ShareMenu.test.tsx | 16 ++ .../sharing/ShareButton/ShareMenu.tsx | 28 ++- .../ConfigEmailSharing/ConfigEmailSharing.tsx | 111 +++++++++ .../EmailListConfiguration.tsx | 148 ++++++++++++ .../EmailShare/CreateEmailSharing.tsx | 68 ++++++ .../EmailShare/EmailSharing.tsx | 17 ++ .../PublicShare/CreatePublicSharing.tsx | 70 ++++++ .../PublicShare/PublicSharing.tsx | 17 ++ .../share-externally/ShareAlerts.test.tsx | 118 ++++++++++ .../share-externally/ShareAlerts.tsx | 36 +++ .../share-externally/ShareConfiguration.tsx | 131 +++++++++++ .../share-externally/ShareExternally.tsx | 212 ++++++++++++++++++ .../share-externally/ShareTypeSelect.tsx | 106 +++++++++ .../sharing/ShareButton/utils.ts | 16 ++ .../sharing/ShareDrawer/ShareDrawer.tsx | 42 ++++ .../ShareDrawer/ShareDrawerContext.tsx | 21 ++ .../dashboard/api/publicDashboardApi.ts | 111 ++++++++- .../ConfigPublicDashboard.tsx | 30 ++- .../EmailSharingConfiguration.tsx | 4 +- .../ModalAlerts/EmailSharingPricingAlert.tsx | 22 ++ .../ModalAlerts/NoUpsertPermissionsAlert.tsx | 2 +- .../ModalAlerts/PublicDashboardAlert.tsx | 19 ++ .../UnsupportedTemplateVariablesAlert.tsx | 10 +- .../SharePublicDashboardUtils.ts | 9 +- public/locales/en-US/grafana.json | 60 ++++- public/locales/pseudo-LOCALE/grafana.json | 60 ++++- 29 files changed, 1473 insertions(+), 38 deletions(-) create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/ConfigEmailSharing/ConfigEmailSharing.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/ConfigEmailSharing/EmailListConfiguration.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/CreateEmailSharing.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/EmailSharing.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/PublicShare/CreatePublicSharing.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/PublicShare/PublicSharing.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareAlerts.test.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareAlerts.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareConfiguration.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareExternally.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareTypeSelect.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareButton/utils.ts create mode 100644 public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx create mode 100644 public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawerContext.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/EmailSharingPricingAlert.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/PublicDashboardAlert.tsx diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 574b8c3fd29..763c8b1bbb4 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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: { diff --git a/public/app/features/admin/UserListPage.tsx b/public/app/features/admin/UserListPage.tsx index 378740f95a6..d5bb089c62f 100644 --- a/public/app/features/admin/UserListPage.tsx +++ b/public/app/features/admin/UserListPage.tsx @@ -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 && } + {isEmailSharingEnabled() && } ) : ( - hasEmailSharingEnabled && ( + isEmailSharingEnabled() && ( { - return await createAndCopyDashboardShortLink(dashboard, { useAbsoluteTimeRange: true, theme: 'current' }); + return await buildShareUrl(dashboard, panel); }, [dashboard]); const onMenuClick = useCallback((isOpen: boolean) => { diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx index 687c391d7bd..e6dcc4a9eda 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx @@ -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(); diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx index 3e40282c551..19fdf21f199 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx @@ -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 ( + {isPublicDashboardsEnabled() && ( + + )} ); } diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/ConfigEmailSharing/ConfigEmailSharing.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/ConfigEmailSharing/ConfigEmailSharing.tsx new file mode 100644 index 00000000000..9f317ad0879 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/ConfigEmailSharing/ConfigEmailSharing.tsx @@ -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({ + 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 ( +
+
+
+ + Invite + + + + + } + description={t( + 'public-dashboard.email-sharing.recipient-invitation-description', + 'Invite someone by email' + )} + error={errors.email?.message} + invalid={!!errors.email?.message} + > + + + + + +
+
+ + + +
+ ); +}; diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/ConfigEmailSharing/EmailListConfiguration.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/ConfigEmailSharing/EmailListConfiguration.tsx new file mode 100644 index 00000000000..157f24ec448 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/ConfigEmailSharing/EmailListConfiguration.tsx @@ -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 ( + + + + + ); +}; + +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 ( + + + {recipients!.map((recipient, idx) => ( + + + + + + ))} + +
+ +
+ +
+ {recipient.recipient} +
+
{isLoading && } + onDeleteEmail(recipient.uid, recipient.recipient)} + onReshare={() => onReshare(recipient.uid)} + /> + } + > + + +
+ ); +}; + +export const EmailListConfiguration = ({ dashboard }: { dashboard: DashboardScene }) => { + const styles = useStyles2(getStyles); + const { data: publicDashboard } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState( + dashboard.state.uid! + ); + return ( + + {!!publicDashboard?.recipients?.length ? ( +
+ +
+ ) : ( + <> + )} +
+ ); +}; + +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, + }), +}); diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/CreateEmailSharing.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/CreateEmailSharing.tsx new file mode 100644 index 00000000000..8ade50db3eb --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/CreateEmailSharing.tsx @@ -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 && } +
+
+
+ +
+ + + + {isLoading && } + +
+
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + checkbox: css({ + marginBottom: theme.spacing(2), + }), +}); diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/EmailSharing.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/EmailSharing.tsx new file mode 100644 index 00000000000..41ca1fcd401 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/EmailSharing.tsx @@ -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 ? : }; +}; diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/PublicShare/CreatePublicSharing.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/PublicShare/CreatePublicSharing.tsx new file mode 100644 index 00000000000..ef6aa224680 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/PublicShare/CreatePublicSharing.tsx @@ -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 && } +
+
+
+ +
+ + + + {isLoading && } + +
+
+ + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + checkbox: css({ + marginBottom: theme.spacing(2), + }), +}); diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/PublicShare/PublicSharing.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/PublicShare/PublicSharing.tsx new file mode 100644 index 00000000000..ae2050e0d80 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/PublicShare/PublicSharing.tsx @@ -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 ? : }; +} diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareAlerts.test.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareAlerts.test.tsx new file mode 100644 index 00000000000..e51f9a44eb2 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareAlerts.test.tsx @@ -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, dashboardState?: Partial) { + 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( + + + + ) + ); +} diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareAlerts.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareAlerts.tsx new file mode 100644 index 00000000000..e1753bf046c --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareAlerts.tsx @@ -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 && } + {!hasWritePermissions && } + {hasWritePermissions && !!unsupportedDataSources?.length && ( + + )} + {publicDashboard?.share === PublicDashboardShareType.EMAIL && isEmailSharingEnabled() && ( + + )} + + ); +} diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareConfiguration.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareConfiguration.tsx new file mode 100644 index 00000000000..91a2345ed8e --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareConfiguration.tsx @@ -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; + +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({ + 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 ( + + + Settings + + +
+
+ + + ( + { + 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" + /> + + + + ( + { + 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" + /> + + + + {}} /> + + + + + +
+
+ {isLoading && } +
+
+ ); +} diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareExternally.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareExternally.tsx new file mode 100644 index 00000000000..3321d5074d2 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareExternally.tsx @@ -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) { + const dashboard = getDashboardSceneFor(model); + const { data: publicDashboard, isLoading } = useGetPublicDashboardQuery(dashboard.state.uid!); + const styles = useStyles2(getStyles); + + if (isLoading) { + return ; + } + + return ( +
+ +
+ ); +} + +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>(getShareType); + + const Config = useMemo(() => { + if (shareType.value === PublicDashboardShareType.EMAIL && isEmailSharingEnabled()) { + return ; + } + + return ; + }, [shareType]); + + return ( + + + + + {Config} + {publicDashboard && ( + <> + + + + )} + + ); +} +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 ( + +
+ + generatePublicDashboardUrl(publicDashboard!.accessToken!)} + onClipboardCopy={onCopyURL} + > + Copy external link + + + + +
+ {isLoading && } +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + paddingBottom: theme.spacing(2), + }), + actionsContainer: css({ + width: '100%', + }), +}); diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareTypeSelect.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareTypeSelect.tsx new file mode 100644 index 00000000000..ad378d3e65b --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareTypeSelect.tsx @@ -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) => void; + value: SelectableValue; + options: Array>; +}) { + 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 ( +
+ + + {isLoading && } + + + {isEmailSharingEnabled() ? ( +