From 7361df8398aab964c05fad3b59e031ceed9f3085 Mon Sep 17 00:00:00 2001 From: "Lucas F. da Costa" Date: Tue, 14 Dec 2021 09:25:29 +0000 Subject: [PATCH] [Uptime] Disable 'Create Rule' button when user doesn't have uptime write permissions [#118404] (#120379) * [Uptime] Disable 'Create Rule' button when user doesn't have uptime write permissions [#118404] Before this commit, users would be able to open the flyout to create an alert and would end-up seeing an error toast when they tried to save it. This commit will now disable the create alert button when the user doesn't have permissions to write to Uptime. It will also display a helpful tooltip. * [Uptime] Disable "Enable Anomaly Alert" when users can't write to uptime [#118404] This commit causes users not to be able to use the "Enable Anomaly Alert" button within the popover in the monitors screen. That button will now be disabled and contain an informative tooltip whenever users don't have permissions to write to Uptime. We've chosen to take this approach so that we don't have to modify the component which deals with the alert creation, which belongs to another team and that we plan on eventually replacing. Furthermore, this pattern is already used in the logs app. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/ml_manage_job.test.tsx.snap | 124 ------------------ .../components/monitor/ml/manage_ml_job.tsx | 9 ++ .../monitor/ml/ml_flyout_container.tsx | 15 +-- .../monitor/ml/ml_manage_job.test.tsx | 112 ++++++++++++---- .../components/monitor/ml/translations.tsx | 7 + .../waterfall_marker_trend.test.tsx | 1 - .../toggle_alert_flyout_button.test.tsx | 68 ++++++++++ .../alerts/toggle_alert_flyout_button.tsx | 13 ++ .../uptime/public/lib/helper/rtl_helpers.tsx | 43 ++++-- .../uptime/public/state/selectors/index.ts | 2 + 10 files changed, 226 insertions(+), 168 deletions(-) delete mode 100644 x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/ml_manage_job.test.tsx.snap create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.test.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/ml_manage_job.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/ml_manage_job.test.tsx.snap deleted file mode 100644 index bb4b51894bf82f3..000000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/ml_manage_job.test.tsx.snap +++ /dev/null @@ -1,124 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Manage ML Job renders without errors 1`] = ` -
-
- -
-
-`; - -exports[`Manage ML Job shallow renders without errors 1`] = ` - - - - - -`; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx index 719bc329c626a54..df0abcb88180b96 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx @@ -30,6 +30,7 @@ import { isAnomalyAlertDeleting, } from '../../../state/alerts/alerts'; import { UptimeEditAlertFlyoutComponent } from '../../common/alerts/uptime_edit_alert_flyout'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; interface Props { hasMLJob: boolean; @@ -38,6 +39,8 @@ interface Props { } export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Props) => { + const core = useKibana(); + const [isPopOverOpen, setIsPopOverOpen] = useState(false); const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); @@ -82,6 +85,8 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro ); + const hasUptimeWrite = core.services.application?.capabilities.uptime?.save ?? false; + const panels = [ { id: 0, @@ -110,6 +115,10 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro name: labels.ENABLE_ANOMALY_ALERT, 'data-test-subj': 'uptimeEnableAnomalyAlertBtn', icon: 'bell', + disabled: !hasUptimeWrite, + toolTipContent: !hasUptimeWrite + ? labels.ENABLE_ANOMALY_NO_PERMISSIONS_TOOLTIP + : null, onClick: () => { dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY)); dispatch(setAlertFlyoutVisible(true)); diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx index 3a6bc9a38c3e6a5..ef0d2857b6b5f82 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx @@ -25,7 +25,6 @@ import { import { MLJobLink } from './ml_job_link'; import * as labels from './translations'; import { MLFlyoutView } from './ml_flyout'; -import { ML_JOB_ID } from '../../../../common/constants'; import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; import { useGetUrlParams } from '../../../hooks'; import { getDynamicSettings } from '../../../state/actions/dynamic_settings'; @@ -120,14 +119,14 @@ export const MachineLearningFlyout: React.FC = ({ onClose }) => { hasMLJob.awaitingNodeAssignment, core.services.theme?.theme$ ); - const loadMLJob = (jobId: string) => - dispatch(getExistingMLJobAction.get({ monitorId: monitorId as string })); - - loadMLJob(ML_JOB_ID); - + dispatch(getExistingMLJobAction.get({ monitorId: monitorId as string })); refreshApp(); - dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY)); - dispatch(setAlertFlyoutVisible(true)); + + const hasUptimeWrite = core.services.application?.capabilities.uptime?.save ?? false; + if (hasUptimeWrite) { + dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY)); + dispatch(setAlertFlyoutVisible(true)); + } } else { showMLJobNotification( monitorId as string, diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_manage_job.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_manage_job.test.tsx index 15a537a49ccf388..34b08f375b60cb0 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_manage_job.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_manage_job.test.tsx @@ -6,35 +6,97 @@ */ import React from 'react'; -import { coreMock } from 'src/core/public/mocks'; +import userEvent from '@testing-library/user-event'; import { ManageMLJobComponent } from './manage_ml_job'; -import * as redux from 'react-redux'; -import { renderWithRouter, shallowWithRouter } from '../../../lib'; -import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { + render, + makeUptimePermissionsCore, + forNearestButton, +} from '../../../lib/helper/rtl_helpers'; +import * as labels from './translations'; -const core = coreMock.createStart(); describe('Manage ML Job', () => { - it('shallow renders without errors', () => { - jest.spyOn(redux, 'useSelector').mockReturnValue(true); - jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); - - const wrapper = shallowWithRouter( - - ); - expect(wrapper).toMatchSnapshot(); + const makeMlCapabilities = (mlCapabilities?: Partial<{ canDeleteJob: boolean }>) => { + return { + ml: { + mlCapabilities: { data: { capabilities: { canDeleteJob: true, ...mlCapabilities } } }, + }, + }; + }; + + describe('when users have write access to uptime', () => { + it('enables the button to create alerts', () => { + const { getByText } = render( + , + { + state: makeMlCapabilities(), + core: makeUptimePermissionsCore({ save: true }), + } + ); + + const anomalyDetectionBtn = forNearestButton(getByText)(labels.ANOMALY_DETECTION); + expect(anomalyDetectionBtn).toBeInTheDocument(); + userEvent.click(anomalyDetectionBtn as HTMLElement); + + expect(forNearestButton(getByText)(labels.ENABLE_ANOMALY_ALERT)).toBeEnabled(); + }); + + it('does not display an informative tooltip', async () => { + const { getByText, findByText } = render( + , + { + state: makeMlCapabilities(), + core: makeUptimePermissionsCore({ save: true }), + } + ); + + const anomalyDetectionBtn = forNearestButton(getByText)(labels.ANOMALY_DETECTION); + expect(anomalyDetectionBtn).toBeInTheDocument(); + userEvent.click(anomalyDetectionBtn as HTMLElement); + + userEvent.hover(getByText(labels.ENABLE_ANOMALY_ALERT)); + + await expect(() => + findByText('You need write access to Uptime to create anomaly alerts.') + ).rejects.toEqual(expect.anything()); + }); }); - it('renders without errors', () => { - jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); - jest.spyOn(redux, 'useSelector').mockReturnValue(true); - - const wrapper = renderWithRouter( - - - - ); - expect(wrapper).toMatchSnapshot(); + describe("when users don't have write access to uptime", () => { + it('disables the button to create alerts', () => { + const { getByText } = render( + , + { + state: makeMlCapabilities(), + core: makeUptimePermissionsCore({ save: false }), + } + ); + + const anomalyDetectionBtn = forNearestButton(getByText)(labels.ANOMALY_DETECTION); + expect(anomalyDetectionBtn).toBeInTheDocument(); + userEvent.click(anomalyDetectionBtn as HTMLElement); + + expect(forNearestButton(getByText)(labels.ENABLE_ANOMALY_ALERT)).toBeDisabled(); + }); + + it('displays an informative tooltip', async () => { + const { getByText, findByText } = render( + , + { + state: makeMlCapabilities(), + core: makeUptimePermissionsCore({ save: false }), + } + ); + + const anomalyDetectionBtn = forNearestButton(getByText)(labels.ANOMALY_DETECTION); + expect(anomalyDetectionBtn).toBeInTheDocument(); + userEvent.click(anomalyDetectionBtn as HTMLElement); + + userEvent.hover(getByText(labels.ENABLE_ANOMALY_ALERT)); + + expect( + await findByText('You need read-write access to Uptime to create anomaly alerts.') + ).toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx index 86ca94d5b6499b7..6816dea66c18096 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx @@ -105,6 +105,13 @@ export const ENABLE_ANOMALY_ALERT = i18n.translate( } ); +export const ENABLE_ANOMALY_NO_PERMISSIONS_TOOLTIP = i18n.translate( + 'xpack.uptime.ml.enableAnomalyDetectionPanel.noPermissionsTooltip', + { + defaultMessage: 'You need read-write access to Uptime to create anomaly alerts.', + } +); + export const DISABLE_ANOMALY_ALERT = i18n.translate( 'xpack.uptime.ml.enableAnomalyDetectionPanel.disableAnomalyAlert', { diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_marker_trend.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_marker_trend.test.tsx index 39033103820e556..b797cf1f3b63e21 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_marker_trend.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_marker_trend.test.tsx @@ -58,7 +58,6 @@ describe('', () => { { core: { http: { - // @ts-expect-error incomplete implementation for testing purposes basePath: { get: () => BASE_PATH, }, diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.test.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.test.tsx new file mode 100644 index 000000000000000..eb7742732931a06 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { + render, + forNearestButton, + makeUptimePermissionsCore, +} from '../../../lib/helper/rtl_helpers'; +import { ToggleAlertFlyoutButtonComponent } from './toggle_alert_flyout_button'; +import { ToggleFlyoutTranslations } from './translations'; + +describe('ToggleAlertFlyoutButtonComponent', () => { + describe('when users have write access to uptime', () => { + it('enables the button to create a rule', () => { + const { getByText } = render( + , + { core: makeUptimePermissionsCore({ save: true }) } + ); + userEvent.click(getByText('Alerts and rules')); + expect( + forNearestButton(getByText)(ToggleFlyoutTranslations.openAlertContextPanelLabel) + ).toBeEnabled(); + }); + + it("does not contain a tooltip explaining why the user can't create alerts", async () => { + const { getByText, findByText } = render( + , + { core: makeUptimePermissionsCore({ save: true }) } + ); + userEvent.click(getByText('Alerts and rules')); + userEvent.hover(getByText(ToggleFlyoutTranslations.openAlertContextPanelLabel)); + await expect(() => + findByText('You need read-write access to Uptime to create alerts in this app.') + ).rejects.toEqual(expect.anything()); + }); + }); + + describe("when users don't have write access to uptime", () => { + it('disables the button to create a rule', () => { + const { getByText } = render( + , + { core: makeUptimePermissionsCore({ save: false }) } + ); + userEvent.click(getByText('Alerts and rules')); + expect( + forNearestButton(getByText)(ToggleFlyoutTranslations.openAlertContextPanelLabel) + ).toBeDisabled(); + }); + + it("contains a tooltip explaining why users can't create rules", async () => { + const { getByText, findByText } = render( + , + { core: makeUptimePermissionsCore({ save: false }) } + ); + userEvent.click(getByText('Alerts and rules')); + userEvent.hover(getByText(ToggleFlyoutTranslations.openAlertContextPanelLabel)); + expect( + await findByText('You need read-write access to Uptime to create alerts in this app.') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx index 43678c1dcc67734..2ca78d6411fda09 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx @@ -14,6 +14,7 @@ import { EuiPopover, } from '@elastic/eui'; import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { CLIENT_ALERT_TYPES } from '../../../../common/constants/alerts'; @@ -29,12 +30,22 @@ type Props = ComponentProps & ToggleAlertFlyoutButtonProps; const ALERT_CONTEXT_MAIN_PANEL_ID = 0; const ALERT_CONTEXT_SELECT_TYPE_PANEL_ID = 1; +const noWritePermissionsTooltipContent = i18n.translate( + 'xpack.uptime.alertDropdown.noWritePermissions', + { + defaultMessage: 'You need read-write access to Uptime to create alerts in this app.', + } +); + export const ToggleAlertFlyoutButtonComponent: React.FC = ({ alertOptions, setAlertFlyoutVisible, }) => { const [isOpen, setIsOpen] = useState(false); const kibana = useKibana(); + + const hasUptimeWrite = kibana.services.application?.capabilities.uptime?.save ?? false; + const monitorStatusAlertContextMenuItem: EuiContextMenuPanelItemDescriptor = { 'aria-label': ToggleFlyoutTranslations.toggleMonitorStatusAriaLabel, 'data-test-subj': 'xpack.uptime.toggleAlertFlyout', @@ -108,6 +119,8 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ name: ToggleFlyoutTranslations.openAlertContextPanelLabel, icon: 'bell', panel: ALERT_CONTEXT_SELECT_TYPE_PANEL_ID, + toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null, + disabled: !hasUptimeWrite, }, managementContextItem, ], diff --git a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx index ed329c722043c59..e4a85d10c75e896 100644 --- a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx +++ b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx @@ -15,6 +15,7 @@ import { Nullish, } from '@testing-library/react'; import { Router } from 'react-router-dom'; +import { merge } from 'lodash'; import { createMemoryHistory, History } from 'history'; import { CoreStart } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n-react'; @@ -37,12 +38,16 @@ import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mo import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; import { UptimeRefreshContextProvider, UptimeStartupPluginsContextProvider } from '../../contexts'; +type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; + interface KibanaProps { services?: KibanaServices; } export interface KibanaProviderOptions { - core?: Partial & ExtraCore; + core?: DeepPartial & Partial; kibanaProps?: KibanaProps; } @@ -64,7 +69,7 @@ type Url = interface RenderRouterOptions extends KibanaProviderOptions { history?: History; renderOptions?: Omit; - state?: Partial; + state?: Partial | DeepPartial; url?: Url; } @@ -137,10 +142,8 @@ export function MockKibanaProvider({ core, kibanaProps, }: MockKibanaProviderProps) { - const coreOptions = { - ...mockCore(), - ...core, - }; + const coreOptions = merge({}, mockCore(), core); + return ( @@ -206,10 +209,7 @@ export function render( url, }: RenderRouterOptions = {} ) { - const testState: AppState = { - ...mockState, - ...state, - }; + const testState: AppState = merge({}, mockState, state); if (url) { history = getHistoryFromUrl(url); @@ -254,3 +254,26 @@ export const forNearestButton = noOtherButtonHasText && node.textContent === text && node.tagName.toLowerCase() === 'button' ); }); + +export const makeUptimePermissionsCore = ( + permissions: Partial<{ + 'alerting:save': boolean; + configureSettings: boolean; + save: boolean; + show: boolean; + }> +) => { + return { + application: { + capabilities: { + uptime: { + 'alerting:save': true, + configureSettings: true, + save: true, + show: true, + ...permissions, + }, + }, + }, + }; +}; diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index 0dc107ef1d52cc1..f14699bf73b69d5 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -92,3 +92,5 @@ export const journeySelector = ({ journeys }: AppState) => journeys; export const networkEventsSelector = ({ networkEvents }: AppState) => networkEvents; export const syntheticsSelector = ({ synthetics }: AppState) => synthetics; + +export const uptimeWriteSelector = (state: AppState) => state;