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 bb4b51894bf82..0000000000000 --- 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 719bc329c626a..df0abcb88180b 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 c1e32613a2ffb..263b838ddcc54 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 @@ -23,7 +23,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'; @@ -113,14 +112,14 @@ export const MachineLearningFlyout: React.FC = ({ onClose }) => { true, hasMLJob.awaitingNodeAssignment ); - 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 15a537a49ccf3..34b08f375b60c 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 86ca94d5b6499..6816dea66c180 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 39033103820e5..b797cf1f3b63e 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 0000000000000..eb7742732931a --- /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 43678c1dcc677..2ca78d6411fda 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 6babaa702ab26..f160ae6d7ad49 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 ( @@ -185,10 +188,7 @@ export function render( url, }: RenderRouterOptions = {} ) { - const testState: AppState = { - ...mockState, - ...state, - }; + const testState: AppState = merge({}, mockState, state); if (url) { history = getHistoryFromUrl(url); @@ -233,3 +233,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 c0da9389f13af..54418c1558b6e 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -89,3 +89,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;