diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx index 4d2366ce2b2..05d5f7263a2 100644 --- a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx +++ b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx @@ -7,8 +7,9 @@ import { locationUtil, SelectableValue } from '@grafana/data'; import { config, locationService, reportInteraction } from '@grafana/runtime'; import { Alert, Button, Field, InputControl, Modal, RadioButtonGroup } from '@grafana/ui'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; +import { contextSrv } from 'app/core/services/context_srv'; import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard'; -import { ExploreId } from 'app/types'; +import { ExploreId, AccessControlAction } from 'app/types'; import { getExploreItemSelector } from '../state/selectors'; @@ -19,17 +20,6 @@ enum SaveTarget { ExistingDashboard = 'existing-dashboard', } -const SAVE_TARGETS: Array> = [ - { - label: 'New dashboard', - value: SaveTarget.NewDashboard, - }, - { - label: 'Existing dashboard', - value: SaveTarget.ExistingDashboard, - }, -]; - interface SaveTargetDTO { saveTarget: SaveTarget; } @@ -82,7 +72,27 @@ export const AddToDashboardModal = ({ onClose, exploreId }: Props) => { } = useForm({ defaultValues: { saveTarget: SaveTarget.NewDashboard }, }); - const saveTarget = watch('saveTarget'); + + const canCreateDashboard = contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor); + const canWriteDashboard = contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor); + + const saveTargets: Array> = []; + if (canCreateDashboard) { + saveTargets.push({ + label: 'New dashboard', + value: SaveTarget.NewDashboard, + }); + } + if (canWriteDashboard) { + saveTargets.push({ + label: 'Existing dashboard', + value: SaveTarget.ExistingDashboard, + }); + } + + const saveTarget = saveTargets.length > 1 ? watch('saveTarget') : saveTargets[0].value; + + const modalTitle = `Add panel to ${saveTargets.length > 1 ? 'dashboard' : saveTargets[0].label!.toLowerCase()}`; const onSubmit = async (openInNewTab: boolean, data: FormDTO) => { setSubmissionError(undefined); @@ -139,17 +149,19 @@ export const AddToDashboardModal = ({ onClose, exploreId }: Props) => { }, []); return ( - +
- ( - - - - )} - name="saveTarget" - /> + {saveTargets.length > 1 && ( + ( + + + + )} + name="saveTarget" + /> + )} {saveTarget === SaveTarget.ExistingDashboard && (() => { diff --git a/public/app/features/explore/AddToDashboard/index.test.tsx b/public/app/features/explore/AddToDashboard/index.test.tsx index a4f4275ceb3..e11ca1eae9a 100644 --- a/public/app/features/explore/AddToDashboard/index.test.tsx +++ b/public/app/features/explore/AddToDashboard/index.test.tsx @@ -6,6 +6,7 @@ import { Provider } from 'react-redux'; import { DataQuery } from '@grafana/data'; import { locationService, setEchoSrv } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; +import { contextSrv } from 'app/core/services/context_srv'; import { Echo } from 'app/core/services/echo/Echo'; import * as initDashboard from 'app/features/dashboard/state/initDashboard'; import { DashboardSearchItemType } from 'app/features/search/types'; @@ -31,10 +32,16 @@ const setup = (children: ReactNode, queries: DataQuery[] = [{ refId: 'A' }]) => return render({children}); }; -const openModal = async () => { +jest.mock('app/core/services/context_srv'); + +const mocks = { + contextSrv: jest.mocked(contextSrv), +}; + +const openModal = async (nameOverride?: string) => { await userEvent.click(screen.getByRole('button', { name: /add to dashboard/i })); - expect(await screen.findByRole('dialog', { name: 'Add panel to dashboard' })).toBeInTheDocument(); + expect(await screen.findByRole('dialog', { name: nameOverride || 'Add panel to dashboard' })).toBeInTheDocument(); }; describe('AddToDashboardButton', () => { @@ -64,6 +71,7 @@ describe('AddToDashboardButton', () => { beforeEach(() => { jest.spyOn(api, 'setDashboardInLocalStorage').mockReturnValue(addToDashboardResponse); + mocks.contextSrv.hasAccess.mockImplementation(() => true); }); afterEach(() => { @@ -271,7 +279,43 @@ describe('AddToDashboardButton', () => { }); }); + describe('Permissions', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('Should only show existing dashboard option with no access to create', async () => { + mocks.contextSrv.hasAccess.mockImplementation((action) => { + if (action === 'dashboards:create') { + return false; + } else { + return true; + } + }); + setup(); + await openModal('Add panel to existing dashboard'); + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + }); + + it('Should only show new dashboard option with no access to write', async () => { + mocks.contextSrv.hasAccess.mockImplementation((action) => { + if (action === 'dashboards:write') { + return false; + } else { + return true; + } + }); + setup(); + await openModal('Add panel to new dashboard'); + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + }); + }); + describe('Error handling', () => { + beforeEach(() => { + mocks.contextSrv.hasAccess.mockImplementation(() => true); + }); + afterEach(() => { jest.restoreAllMocks(); }); diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index d1c30e4d722..92d957855b0 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -97,6 +97,12 @@ jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { }; }); +jest.mock('app/core/core', () => ({ + contextSrv: { + hasAccess: () => true, + }, +})); + // for the AutoSizer component to have a width jest.mock('react-virtualized-auto-sizer', () => { return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 }); diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index f4e7b0468b3..52fb54f81b6 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -11,7 +11,9 @@ import { ToolbarButton, ToolbarButtonRow, } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; +import { AccessControlAction } from 'app/types'; import { ExploreId } from 'app/types/explore'; import { StoreState } from 'app/types/store'; @@ -120,6 +122,10 @@ class UnConnectedExploreToolbar extends PureComponent { const showSmallDataSourcePicker = (splitted ? containerWidth < 700 : containerWidth < 800) || false; const showSmallTimePicker = splitted || containerWidth < 1210; + const showExploreToDashboard = + contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor) || + contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor); + return (
{ )} - {config.featureToggles.explore2Dashboard && ( + {config.featureToggles.explore2Dashboard && showExploreToDashboard && ( diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 45882f370fa..fe72fe6acdf 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -50,6 +50,7 @@ jest.mock('app/core/core', () => { return { contextSrv: { hasPermission: () => true, + hasAccess: () => true, }, appEvents: { subscribe: () => {}, diff --git a/public/app/features/explore/spec/interpolation.test.tsx b/public/app/features/explore/spec/interpolation.test.tsx index 7137c6fa8b9..93f4171e861 100644 --- a/public/app/features/explore/spec/interpolation.test.tsx +++ b/public/app/features/explore/spec/interpolation.test.tsx @@ -14,6 +14,12 @@ jest.mock('@grafana/runtime', () => ({ getBackendSrv: () => ({ fetch }), })); +jest.mock('app/core/core', () => ({ + contextSrv: { + hasAccess: () => true, + }, +})); + jest.mock('react-virtualized-auto-sizer', () => { return { __esModule: true, diff --git a/public/app/features/explore/spec/queryHistory.test.tsx b/public/app/features/explore/spec/queryHistory.test.tsx index f9653aa5752..9bcc796adf7 100644 --- a/public/app/features/explore/spec/queryHistory.test.tsx +++ b/public/app/features/explore/spec/queryHistory.test.tsx @@ -45,6 +45,12 @@ jest.mock('@grafana/runtime', () => ({ getBackendSrv: () => ({ fetch: fetchMock, post: postMock, get: getMock }), })); +jest.mock('app/core/core', () => ({ + contextSrv: { + hasAccess: () => true, + }, +})); + jest.mock('app/core/services/PreferencesService', () => ({ PreferencesService: function () { return {