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} + > + + + + Invite + + + + + + + + + + ); +}; 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 && } + + + + + + + + Accept + + dashboard.closeModal()}> + Cancel + + {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 && } + + + + + + + + Accept + + dashboard.closeModal()}> + Cancel + + {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" + /> + + Enable time range + + + + ( + { + 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" + /> + + Display annotations + + + + {}} /> + + + + + + + + {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 + + + Revoke access + + + {publicDashboard.isEnabled ? ( + Pause access + ) : ( + Resume access + )} + + + + {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 ( + + + + Link access + + {isLoading && } + + + {isEmailSharingEnabled() ? ( + { + setShareType(v); + onUpdateShareType(v.value!); + }} + className={styles.select} + /> + ) : ( + <> + {toIconName(anyOneWithTheLinkOpt.icon) && } + {anyOneWithTheLinkOpt.label} + > + )} + + can access + + + + ); +} + +const getStyles = () => { + return { + select: css({ + flex: 1, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/utils.ts b/public/app/features/dashboard-scene/sharing/ShareButton/utils.ts new file mode 100644 index 00000000000..ca63311f91a --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/utils.ts @@ -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' }); +}; diff --git a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx new file mode 100644 index 00000000000..282979a13c8 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx @@ -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; + body: SceneObject; +} + +export class ShareDrawer extends SceneObjectBase implements ModalSceneObjectLike { + static Component = ShareDrawerRenderer; + + onDismiss = () => { + const dashboard = getDashboardSceneFor(this); + dashboard.closeModal(); + }; +} + +function ShareDrawerRenderer({ model }: SceneComponentProps) { + const { title, body } = model.useState(); + const dashboard = getDashboardSceneFor(model); + + return ( + + {} + + ); +} diff --git a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawerContext.tsx b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawerContext.tsx new file mode 100644 index 00000000000..53006f154fa --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawerContext.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { DashboardScene } from '../../scene/DashboardScene'; + +interface Context { + dashboard: DashboardScene; +} + +export const ShareDrawerContext = React.createContext(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 }; diff --git a/public/app/features/dashboard/api/publicDashboardApi.ts b/public/app/features/dashboard/api/publicDashboardApi.ts index d6808335a7a..5ff1aff23f2 100644 --- a/public/app/features/dashboard/api/publicDashboardApi.ts +++ b/public/app/features/dashboard/api/publicDashboardApi.ts @@ -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 & Partial>) | DashboardScene; + payload: Partial; + } + >({ + 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 & Partial>) | DashboardScene; + payload: Partial; + } + >({ + 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({ 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; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx index 273fa5f9dd6..86e18772afd 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx @@ -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({ 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({ )} - {hasEmailSharingEnabled && } + {isEmailSharingEnabled() && } diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/EmailSharingConfiguration.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/EmailSharingConfiguration.tsx index 95d2280c41b..16d97cb2748 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/EmailSharingConfiguration.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/EmailSharingConfiguration.tsx @@ -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); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/EmailSharingPricingAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/EmailSharingPricingAlert.tsx new file mode 100644 index 00000000000..6b212096821 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/EmailSharingPricingAlert.tsx @@ -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 ( + + + + 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. + + window.open(EMAIL_SHARING_URL, '_blank')} type="button"> + Learn more + + + + ); +} diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx index 198a0fdbe63..f3894367a25 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx @@ -8,7 +8,7 @@ const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; export const NoUpsertPermissionsAlert = ({ mode }: { mode: 'create' | 'edit' }) => ( ( + + + + Sharing this dashboard externally makes it entirely accessible to anyone with the link. + + window.open(PUBLIC_DASHBOARD_URL, '_blank')} type="button"> + Learn more + + + +); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx index a8cc47ccc1f..c20a2d2693d 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx @@ -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 }) => ( ( data-testid={selectors.TemplateVariablesWarningAlert} bottomSpacing={0} > - - This public dashboard may not work since it uses template variables - + {showDescription && ( + + This public dashboard may not work since it uses template variables + + )} ); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.ts b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.ts index 621fde2b0f6..dde37ece294 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.ts +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.ts @@ -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'); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 8d9fd95fbf6..331632b831a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index c9b676d7374..59286371b22 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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": {