[Alerting] Showing confirmation modal on Alert Add/Edit when flyout closed without saving and changes made. (#86370)

* Adding hasChanged check and showing confirmation modal if something has changed

* Showing confirmation always on close

* Adding functional test

* Setting name and tags for APM alerts using initial values instead of setAlertProperty

* Checking for alert param changes separately

* Checking for alert param changes separately

* Fixing functional test

* Resetting initial alert params on alert type change

* Fixing duplicate import

* Cloning edited alert

* PR fixes

* PR fixes

* Updating modal wording

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
ymao1 2021-01-11 16:16:10 -05:00 committed by GitHub
parent 3ef7bd3197
commit 666af32be4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 408 additions and 44 deletions

View file

@ -4,10 +4,11 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { AlertType } from '../../../../common/alert_types'; import { AlertType } from '../../../../common/alert_types';
import { getInitialAlertValues } from '../get_initial_alert_values';
import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public';
interface Props { interface Props {
addFlyoutVisible: boolean; addFlyoutVisible: boolean;
setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
@ -20,10 +21,13 @@ interface KibanaDeps {
export function AlertingFlyout(props: Props) { export function AlertingFlyout(props: Props) {
const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props;
const { serviceName } = useParams<{ serviceName?: string }>();
const { const {
services: { triggersActionsUi }, services: { triggersActionsUi },
} = useKibana<KibanaDeps>(); } = useKibana<KibanaDeps>();
const initialValues = getInitialAlertValues(alertType, serviceName);
const onCloseAddFlyout = useCallback(() => setAddFlyoutVisibility(false), [ const onCloseAddFlyout = useCallback(() => setAddFlyoutVisibility(false), [
setAddFlyoutVisibility, setAddFlyoutVisibility,
]); ]);
@ -36,7 +40,9 @@ export function AlertingFlyout(props: Props) {
onClose: onCloseAddFlyout, onClose: onCloseAddFlyout,
alertTypeId: alertType, alertTypeId: alertType,
canChangeTrigger: false, canChangeTrigger: false,
initialValues,
}), }),
/* eslint-disable-next-line react-hooks/exhaustive-deps */
[alertType, onCloseAddFlyout, triggersActionsUi] [alertType, onCloseAddFlyout, triggersActionsUi]
); );
return <>{addFlyoutVisible && addAlertFlyout}</>; return <>{addFlyoutVisible && addAlertFlyout}</>;

View file

@ -8,7 +8,6 @@ import { i18n } from '@kbn/i18n';
import React from 'react'; import React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../../common/alert_types';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { asInteger } from '../../../../common/utils/formatters'; import { asInteger } from '../../../../common/utils/formatters';
import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useUrlParams } from '../../../context/url_params_context/use_url_params';
@ -110,7 +109,6 @@ export function ErrorCountAlertTrigger(props: Props) {
return ( return (
<ServiceAlertTrigger <ServiceAlertTrigger
alertTypeName={ALERT_TYPES_CONFIG[AlertType.ErrorCount].name}
defaults={defaults} defaults={defaults}
fields={fields} fields={fields}
setAlertParams={setAlertParams} setAlertParams={setAlertParams}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getInitialAlertValues } from './get_initial_alert_values';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
test('handles null alert type and undefined service name', () => {
expect(getInitialAlertValues(null, undefined)).toEqual({ tags: ['apm'] });
});
test('handles valid alert type', () => {
const alertType = AlertType.ErrorCount;
expect(getInitialAlertValues(alertType, undefined)).toEqual({
name: ALERT_TYPES_CONFIG[alertType].name,
tags: ['apm'],
});
expect(getInitialAlertValues(alertType, 'Service Name')).toEqual({
name: `${ALERT_TYPES_CONFIG[alertType].name} | Service Name`,
tags: ['apm', `service.name:service name`],
});
});

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
export function getInitialAlertValues(
alertType: AlertType | null,
serviceName: string | undefined
) {
const alertTypeName = alertType
? ALERT_TYPES_CONFIG[alertType].name
: undefined;
const alertName = alertTypeName
? serviceName
? `${alertTypeName} | ${serviceName}`
: alertTypeName
: undefined;
const tags = ['apm'];
if (serviceName) {
tags.push(`service.name:${serviceName}`.toLowerCase());
}
return {
tags,
...(alertName ? { name: alertName } : {}),
};
}

View file

@ -9,7 +9,6 @@ import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
interface Props { interface Props {
alertTypeName: string;
setAlertParams: (key: string, value: any) => void; setAlertParams: (key: string, value: any) => void;
setAlertProperty: (key: string, value: any) => void; setAlertProperty: (key: string, value: any) => void;
defaults: Record<string, any>; defaults: Record<string, any>;
@ -20,14 +19,7 @@ interface Props {
export function ServiceAlertTrigger(props: Props) { export function ServiceAlertTrigger(props: Props) {
const { serviceName } = useParams<{ serviceName?: string }>(); const { serviceName } = useParams<{ serviceName?: string }>();
const { const { fields, setAlertParams, defaults, chartPreview } = props;
fields,
setAlertParams,
setAlertProperty,
alertTypeName,
defaults,
chartPreview,
} = props;
const params: Record<string, any> = { const params: Record<string, any> = {
...defaults, ...defaults,
@ -36,17 +28,6 @@ export function ServiceAlertTrigger(props: Props) {
useEffect(() => { useEffect(() => {
// we only want to run this on mount to set default values // we only want to run this on mount to set default values
const alertName = params.serviceName
? `${alertTypeName} | ${params.serviceName}`
: alertTypeName;
setAlertProperty('name', alertName);
const tags = ['apm'];
if (params.serviceName) {
tags.push(`service.name:${params.serviceName}`.toLowerCase());
}
setAlertProperty('tags', tags);
Object.keys(params).forEach((key) => { Object.keys(params).forEach((key) => {
setAlertParams(key, params[key]); setAlertParams(key, params[key]);
}); });

View file

@ -18,7 +18,6 @@ describe('ServiceAlertTrigger', () => {
expect(() => expect(() =>
render( render(
<ServiceAlertTrigger <ServiceAlertTrigger
alertTypeName="test alert type name"
defaults={{}} defaults={{}}
fields={[null]} fields={[null]}
setAlertParams={() => {}} setAlertParams={() => {}}

View file

@ -10,7 +10,6 @@ import React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useFetcher } from '../../../../../observability/public'; import { useFetcher } from '../../../../../observability/public';
import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public';
import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { getDurationFormatter } from '../../../../common/utils/formatters'; import { getDurationFormatter } from '../../../../common/utils/formatters';
import { TimeSeries } from '../../../../typings/timeseries'; import { TimeSeries } from '../../../../typings/timeseries';
@ -203,7 +202,6 @@ export function TransactionDurationAlertTrigger(props: Props) {
return ( return (
<ServiceAlertTrigger <ServiceAlertTrigger
alertTypeName={ALERT_TYPES_CONFIG['apm.transaction_duration'].name}
chartPreview={chartPreview} chartPreview={chartPreview}
defaults={defaults} defaults={defaults}
fields={fields} fields={fields}

View file

@ -8,7 +8,6 @@ import { useParams } from 'react-router-dom';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import React from 'react'; import React from 'react';
import { ANOMALY_SEVERITY } from '../../../../../ml/common'; import { ANOMALY_SEVERITY } from '../../../../../ml/common';
import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types';
import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher';
import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useUrlParams } from '../../../context/url_params_context/use_url_params';
import { ServiceAlertTrigger } from '../service_alert_trigger'; import { ServiceAlertTrigger } from '../service_alert_trigger';
@ -106,9 +105,6 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) {
return ( return (
<ServiceAlertTrigger <ServiceAlertTrigger
alertTypeName={
ALERT_TYPES_CONFIG['apm.transaction_duration_anomaly'].name
}
fields={fields} fields={fields}
defaults={defaults} defaults={defaults}
setAlertParams={setAlertParams} setAlertParams={setAlertParams}

View file

@ -6,7 +6,6 @@
import React from 'react'; import React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../../common/alert_types';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { asPercent } from '../../../../common/utils/formatters'; import { asPercent } from '../../../../common/utils/formatters';
import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context';
@ -137,7 +136,6 @@ export function TransactionErrorRateAlertTrigger(props: Props) {
return ( return (
<ServiceAlertTrigger <ServiceAlertTrigger
alertTypeName={ALERT_TYPES_CONFIG[AlertType.TransactionErrorRate].name}
fields={fields} fields={fields}
defaults={defaultParams} defaults={defaultParams}
setAlertParams={setAlertParams} setAlertParams={setAlertParams}

View file

@ -3,14 +3,16 @@
* or more contributor license agreements. Licensed under the Elastic License; * or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { useCallback, useReducer, useMemo, useState, useEffect } from 'react'; import React, { useReducer, useMemo, useState, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
import { EuiTitle, EuiFlyoutHeader, EuiFlyout, EuiFlyoutBody, EuiPortal } from '@elastic/eui'; import { EuiTitle, EuiFlyoutHeader, EuiFlyout, EuiFlyoutBody, EuiPortal } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { import {
ActionTypeRegistryContract, ActionTypeRegistryContract,
Alert, Alert,
AlertTypeRegistryContract, AlertTypeRegistryContract,
AlertTypeParams,
AlertUpdates, AlertUpdates,
} from '../../../types'; } from '../../../types';
import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; import { AlertForm, getAlertErrors, isValidAlert } from './alert_form';
@ -18,10 +20,12 @@ import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer
import { createAlert } from '../../lib/alert_api'; import { createAlert } from '../../lib/alert_api';
import { HealthCheck } from '../../components/health_check'; import { HealthCheck } from '../../components/health_check';
import { ConfirmAlertSave } from './confirm_alert_save'; import { ConfirmAlertSave } from './confirm_alert_save';
import { ConfirmAlertClose } from './confirm_alert_close';
import { hasShowActionsCapability } from '../../lib/capabilities'; import { hasShowActionsCapability } from '../../lib/capabilities';
import AlertAddFooter from './alert_add_footer'; import AlertAddFooter from './alert_add_footer';
import { HealthContextProvider } from '../../context/health_context'; import { HealthContextProvider } from '../../context/health_context';
import { useKibana } from '../../../common/lib/kibana'; import { useKibana } from '../../../common/lib/kibana';
import { hasAlertChanged, haveAlertParamsChanged } from './has_alert_changed';
import { getAlertWithInvalidatedFields } from '../../lib/value_validators'; import { getAlertWithInvalidatedFields } from '../../lib/value_validators';
export interface AlertAddProps<MetaData = Record<string, any>> { export interface AlertAddProps<MetaData = Record<string, any>> {
@ -66,8 +70,10 @@ const AlertAdd = ({
const [{ alert }, dispatch] = useReducer(alertReducer as InitialAlertReducer, { const [{ alert }, dispatch] = useReducer(alertReducer as InitialAlertReducer, {
alert: initialAlert, alert: initialAlert,
}); });
const [initialAlertParams, setInitialAlertParams] = useState<AlertTypeParams>({});
const [isSaving, setIsSaving] = useState<boolean>(false); const [isSaving, setIsSaving] = useState<boolean>(false);
const [isConfirmAlertSaveModalOpen, setIsConfirmAlertSaveModalOpen] = useState<boolean>(false); const [isConfirmAlertSaveModalOpen, setIsConfirmAlertSaveModalOpen] = useState<boolean>(false);
const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState<boolean>(false);
const setAlert = (value: InitialAlert) => { const setAlert = (value: InitialAlert) => {
dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } }); dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } });
@ -91,16 +97,35 @@ const AlertAdd = ({
} }
}, [alertTypeId]); }, [alertTypeId]);
const closeFlyout = useCallback(() => { useEffect(() => {
setAlert(initialAlert); if (isEmpty(alert.params) && !isEmpty(initialAlertParams)) {
onClose(); // alert params are explicitly cleared when the alert type is cleared.
}, [initialAlert, onClose]); // clear the "initial" params in order to capture the
// default when a new alert type is selected
setInitialAlertParams({});
} else if (isEmpty(initialAlertParams)) {
// captures the first change to the alert params,
// when consumers set a default value for the alert params
setInitialAlertParams(alert.params);
}
}, [alert.params, initialAlertParams, setInitialAlertParams]);
const checkForChangesAndCloseFlyout = () => {
if (
hasAlertChanged(alert, initialAlert, false) ||
haveAlertParamsChanged(alert.params, initialAlertParams)
) {
setIsConfirmAlertCloseModalOpen(true);
} else {
onClose();
}
};
const saveAlertAndCloseFlyout = async () => { const saveAlertAndCloseFlyout = async () => {
const savedAlert = await onSaveAlert(); const savedAlert = await onSaveAlert();
setIsSaving(false); setIsSaving(false);
if (savedAlert) { if (savedAlert) {
closeFlyout(); onClose();
if (reloadAlerts) { if (reloadAlerts) {
reloadAlerts(); reloadAlerts();
} }
@ -142,7 +167,7 @@ const AlertAdd = ({
return ( return (
<EuiPortal> <EuiPortal>
<EuiFlyout <EuiFlyout
onClose={closeFlyout} onClose={checkForChangesAndCloseFlyout}
aria-labelledby="flyoutAlertAddTitle" aria-labelledby="flyoutAlertAddTitle"
size="m" size="m"
maxWidth={620} maxWidth={620}
@ -198,7 +223,7 @@ const AlertAdd = ({
await saveAlertAndCloseFlyout(); await saveAlertAndCloseFlyout();
} }
}} }}
onCancel={closeFlyout} onCancel={checkForChangesAndCloseFlyout}
/> />
</HealthCheck> </HealthCheck>
</HealthContextProvider> </HealthContextProvider>
@ -214,6 +239,17 @@ const AlertAdd = ({
}} }}
/> />
)} )}
{isConfirmAlertCloseModalOpen && (
<ConfirmAlertClose
onConfirm={() => {
setIsConfirmAlertCloseModalOpen(false);
onClose();
}}
onCancel={() => {
setIsConfirmAlertCloseModalOpen(false);
}}
/>
)}
</EuiFlyout> </EuiFlyout>
</EuiPortal> </EuiPortal>
); );

View file

@ -19,6 +19,7 @@ import {
EuiCallOut, EuiCallOut,
EuiSpacer, EuiSpacer,
} from '@elastic/eui'; } from '@elastic/eui';
import { cloneDeep } from 'lodash';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { ActionTypeRegistryContract, Alert, AlertTypeRegistryContract } from '../../../types'; import { ActionTypeRegistryContract, Alert, AlertTypeRegistryContract } from '../../../types';
import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; import { AlertForm, getAlertErrors, isValidAlert } from './alert_form';
@ -27,6 +28,8 @@ import { updateAlert } from '../../lib/alert_api';
import { HealthCheck } from '../../components/health_check'; import { HealthCheck } from '../../components/health_check';
import { HealthContextProvider } from '../../context/health_context'; import { HealthContextProvider } from '../../context/health_context';
import { useKibana } from '../../../common/lib/kibana'; import { useKibana } from '../../../common/lib/kibana';
import { ConfirmAlertClose } from './confirm_alert_close';
import { hasAlertChanged } from './has_alert_changed';
import { getAlertWithInvalidatedFields } from '../../lib/value_validators'; import { getAlertWithInvalidatedFields } from '../../lib/value_validators';
export interface AlertEditProps<MetaData = Record<string, any>> { export interface AlertEditProps<MetaData = Record<string, any>> {
@ -47,13 +50,14 @@ export const AlertEdit = ({
metadata, metadata,
}: AlertEditProps) => { }: AlertEditProps) => {
const [{ alert }, dispatch] = useReducer(alertReducer as ConcreteAlertReducer, { const [{ alert }, dispatch] = useReducer(alertReducer as ConcreteAlertReducer, {
alert: initialAlert, alert: cloneDeep(initialAlert),
}); });
const [isSaving, setIsSaving] = useState<boolean>(false); const [isSaving, setIsSaving] = useState<boolean>(false);
const [hasActionsDisabled, setHasActionsDisabled] = useState<boolean>(false); const [hasActionsDisabled, setHasActionsDisabled] = useState<boolean>(false);
const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = useState<boolean>( const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = useState<boolean>(
false false
); );
const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState<boolean>(false);
const { const {
http, http,
@ -71,6 +75,14 @@ export const AlertEdit = ({
alertType alertType
); );
const checkForChangesAndCloseFlyout = () => {
if (hasAlertChanged(alert, initialAlert, true)) {
setIsConfirmAlertCloseModalOpen(true);
} else {
onClose();
}
};
async function onSaveAlert(): Promise<Alert | undefined> { async function onSaveAlert(): Promise<Alert | undefined> {
try { try {
if (isValidAlert(alert, alertErrors, alertActionsErrors) && !hasActionsWithBrokenConnector) { if (isValidAlert(alert, alertErrors, alertActionsErrors) && !hasActionsWithBrokenConnector) {
@ -107,7 +119,7 @@ export const AlertEdit = ({
return ( return (
<EuiPortal> <EuiPortal>
<EuiFlyout <EuiFlyout
onClose={() => onClose()} onClose={checkForChangesAndCloseFlyout}
aria-labelledby="flyoutAlertEditTitle" aria-labelledby="flyoutAlertEditTitle"
size="m" size="m"
maxWidth={620} maxWidth={620}
@ -160,7 +172,7 @@ export const AlertEdit = ({
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiButtonEmpty <EuiButtonEmpty
data-test-subj="cancelSaveEditedAlertButton" data-test-subj="cancelSaveEditedAlertButton"
onClick={() => onClose()} onClick={() => checkForChangesAndCloseFlyout()}
> >
{i18n.translate( {i18n.translate(
'xpack.triggersActionsUI.sections.alertEdit.cancelButtonLabel', 'xpack.triggersActionsUI.sections.alertEdit.cancelButtonLabel',
@ -200,6 +212,17 @@ export const AlertEdit = ({
</EuiFlyoutFooter> </EuiFlyoutFooter>
</HealthCheck> </HealthCheck>
</HealthContextProvider> </HealthContextProvider>
{isConfirmAlertCloseModalOpen && (
<ConfirmAlertClose
onConfirm={() => {
setIsConfirmAlertCloseModalOpen(false);
onClose();
}}
onCancel={() => {
setIsConfirmAlertCloseModalOpen(false);
}}
/>
)}
</EuiFlyout> </EuiFlyout>
</EuiPortal> </EuiPortal>
); );

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
interface Props {
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmAlertClose: React.FC<Props> = ({ onConfirm, onCancel }) => {
return (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate(
'xpack.triggersActionsUI.sections.confirmAlertClose.confirmAlertCloseTitle',
{
defaultMessage: 'Discard unsaved changes to alert?',
}
)}
onCancel={onCancel}
onConfirm={onConfirm}
confirmButtonText={i18n.translate(
'xpack.triggersActionsUI.sections.confirmAlertClose.confirmAlertCloseConfirmButtonText',
{
defaultMessage: 'Discard changes',
}
)}
cancelButtonText={i18n.translate(
'xpack.triggersActionsUI.sections.confirmAlertClose.confirmAlertCloseCancelButtonText',
{
defaultMessage: 'Cancel',
}
)}
defaultFocusedButton="confirm"
data-test-subj="confirmAlertCloseModal"
>
<p>
<FormattedMessage
id="xpack.triggersActionsUI.sections.confirmAlertClose.confirmAlertCloseMessage"
defaultMessage="You can't recover unsaved changes."
/>
</p>
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -0,0 +1,164 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { InitialAlert } from './alert_reducer';
import { hasAlertChanged } from './has_alert_changed';
function createAlert(overrides = {}): InitialAlert {
return {
params: {},
consumer: 'test',
alertTypeId: 'test',
schedule: {
interval: '1m',
},
actions: [],
tags: [],
notifyWhen: 'onActionGroupChange',
...overrides,
};
}
test('should return false for same alert', () => {
const a = createAlert();
expect(hasAlertChanged(a, a, true)).toEqual(false);
});
test('should return true for different alert', () => {
const a = createAlert();
const b = createAlert({ alertTypeId: 'differentTest' });
expect(hasAlertChanged(a, b, true)).toEqual(true);
});
test('should correctly compare name field', () => {
// name field doesn't exist initially
const a = createAlert();
// set name to actual value
const b = createAlert({ name: 'myAlert' });
// set name to different value
const c = createAlert({ name: 'anotherAlert' });
// set name to various empty/null/undefined states
const d = createAlert({ name: '' });
const e = createAlert({ name: undefined });
const f = createAlert({ name: null });
expect(hasAlertChanged(a, b, true)).toEqual(true);
expect(hasAlertChanged(a, c, true)).toEqual(true);
expect(hasAlertChanged(a, d, true)).toEqual(false);
expect(hasAlertChanged(a, e, true)).toEqual(false);
expect(hasAlertChanged(a, f, true)).toEqual(false);
expect(hasAlertChanged(b, c, true)).toEqual(true);
expect(hasAlertChanged(b, d, true)).toEqual(true);
expect(hasAlertChanged(b, e, true)).toEqual(true);
expect(hasAlertChanged(b, f, true)).toEqual(true);
expect(hasAlertChanged(c, d, true)).toEqual(true);
expect(hasAlertChanged(c, e, true)).toEqual(true);
expect(hasAlertChanged(c, f, true)).toEqual(true);
expect(hasAlertChanged(d, e, true)).toEqual(false);
expect(hasAlertChanged(d, f, true)).toEqual(false);
});
test('should correctly compare alertTypeId field', () => {
const a = createAlert();
// set alertTypeId to different value
const b = createAlert({ alertTypeId: 'myAlertId' });
// set alertTypeId to various empty/null/undefined states
const c = createAlert({ alertTypeId: '' });
const d = createAlert({ alertTypeId: undefined });
const e = createAlert({ alertTypeId: null });
expect(hasAlertChanged(a, b, true)).toEqual(true);
expect(hasAlertChanged(a, c, true)).toEqual(true);
expect(hasAlertChanged(a, d, true)).toEqual(true);
expect(hasAlertChanged(a, e, true)).toEqual(true);
expect(hasAlertChanged(b, c, true)).toEqual(true);
expect(hasAlertChanged(b, d, true)).toEqual(true);
expect(hasAlertChanged(b, e, true)).toEqual(true);
expect(hasAlertChanged(c, d, true)).toEqual(false);
expect(hasAlertChanged(c, e, true)).toEqual(false);
expect(hasAlertChanged(d, e, true)).toEqual(false);
});
test('should correctly compare throttle field', () => {
// throttle field doesn't exist initially
const a = createAlert();
// set throttle to actual value
const b = createAlert({ throttle: '1m' });
// set throttle to different value
const c = createAlert({ throttle: '1h' });
// set throttle to various empty/null/undefined states
const d = createAlert({ throttle: '' });
const e = createAlert({ throttle: undefined });
const f = createAlert({ throttle: null });
expect(hasAlertChanged(a, b, true)).toEqual(true);
expect(hasAlertChanged(a, c, true)).toEqual(true);
expect(hasAlertChanged(a, d, true)).toEqual(false);
expect(hasAlertChanged(a, e, true)).toEqual(false);
expect(hasAlertChanged(a, f, true)).toEqual(false);
expect(hasAlertChanged(b, c, true)).toEqual(true);
expect(hasAlertChanged(b, d, true)).toEqual(true);
expect(hasAlertChanged(b, e, true)).toEqual(true);
expect(hasAlertChanged(b, f, true)).toEqual(true);
expect(hasAlertChanged(c, d, true)).toEqual(true);
expect(hasAlertChanged(c, e, true)).toEqual(true);
expect(hasAlertChanged(c, f, true)).toEqual(true);
expect(hasAlertChanged(d, e, true)).toEqual(false);
expect(hasAlertChanged(d, f, true)).toEqual(false);
});
test('should correctly compare tags field', () => {
const a = createAlert();
const b = createAlert({ tags: ['first'] });
expect(hasAlertChanged(a, b, true)).toEqual(true);
});
test('should correctly compare schedule field', () => {
const a = createAlert();
const b = createAlert({ schedule: { interval: '3h' } });
expect(hasAlertChanged(a, b, true)).toEqual(true);
});
test('should correctly compare actions field', () => {
const a = createAlert();
const b = createAlert({
actions: [{ actionTypeId: 'action', group: 'group', id: 'actionId', params: {} }],
});
expect(hasAlertChanged(a, b, true)).toEqual(true);
});
test('should skip comparing params field if compareParams=false', () => {
const a = createAlert();
const b = createAlert({ params: { newParam: 'value' } });
expect(hasAlertChanged(a, b, false)).toEqual(false);
});
test('should correctly compare params field if compareParams=true', () => {
const a = createAlert();
const b = createAlert({ params: { newParam: 'value' } });
expect(hasAlertChanged(a, b, true)).toEqual(true);
});
test('should correctly compare notifyWhen field', () => {
const a = createAlert();
const b = createAlert({ notifyWhen: 'onActiveAlert' });
expect(hasAlertChanged(a, b, true)).toEqual(true);
});

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import deepEqual from 'fast-deep-equal';
import { pick } from 'lodash';
import { AlertTypeParams } from '../../../types';
import { InitialAlert } from './alert_reducer';
const DEEP_COMPARE_FIELDS = ['tags', 'schedule', 'actions', 'notifyWhen'];
function getNonNullCompareFields(alert: InitialAlert) {
const { name, alertTypeId, throttle } = alert;
return {
...(!!(name && name.length > 0) ? { name } : {}),
...(!!(alertTypeId && alertTypeId.length > 0) ? { alertTypeId } : {}),
...(!!(throttle && throttle.length > 0) ? { throttle } : {}),
};
}
export function hasAlertChanged(a: InitialAlert, b: InitialAlert, compareParams: boolean) {
// Deep compare these fields
let objectsAreEqual = deepEqual(pick(a, DEEP_COMPARE_FIELDS), pick(b, DEEP_COMPARE_FIELDS));
if (compareParams) {
objectsAreEqual = objectsAreEqual && deepEqual(a.params, b.params);
}
const nonNullCompareFieldsAreEqual = deepEqual(
getNonNullCompareFields(a),
getNonNullCompareFields(b)
);
return !objectsAreEqual || !nonNullCompareFieldsAreEqual;
}
export function haveAlertParamsChanged(a: AlertTypeParams, b: AlertTypeParams) {
return !deepEqual(a, b);
}

View file

@ -8,11 +8,12 @@ import type { DocLinksStart } from 'kibana/public';
import { ComponentType } from 'react'; import { ComponentType } from 'react';
import { ChartsPluginSetup } from 'src/plugins/charts/public'; import { ChartsPluginSetup } from 'src/plugins/charts/public';
import { DataPublicPluginStart } from 'src/plugins/data/public'; import { DataPublicPluginStart } from 'src/plugins/data/public';
import { ActionGroup, AlertActionParam, AlertTypeParams } from '../../alerts/common';
import { ActionType } from '../../actions/common'; import { ActionType } from '../../actions/common';
import { TypeRegistry } from './application/type_registry'; import { TypeRegistry } from './application/type_registry';
import { AlertType as CommonAlertType } from '../../alerts/common'; import { AlertType as CommonAlertType } from '../../alerts/common';
import { import {
ActionGroup,
AlertActionParam,
SanitizedAlert, SanitizedAlert,
AlertAction, AlertAction,
AlertAggregations, AlertAggregations,
@ -22,6 +23,7 @@ import {
RawAlertInstance, RawAlertInstance,
AlertingFrameworkHealth, AlertingFrameworkHealth,
AlertNotifyWhenType, AlertNotifyWhenType,
AlertTypeParams,
} from '../../alerts/common'; } from '../../alerts/common';
// In Triggers and Actions we treat all `Alert`s as `SanitizedAlert<AlertTypeParams>` // In Triggers and Actions we treat all `Alert`s as `SanitizedAlert<AlertTypeParams>`
@ -38,6 +40,7 @@ export {
RawAlertInstance, RawAlertInstance,
AlertingFrameworkHealth, AlertingFrameworkHealth,
AlertNotifyWhenType, AlertNotifyWhenType,
AlertTypeParams,
}; };
export { ActionType }; export { ActionType };

View file

@ -204,5 +204,18 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const alertsToDelete = await getAlertsByName(alertName); const alertsToDelete = await getAlertsByName(alertName);
await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id)); await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id));
}); });
it('should show discard confirmation before closing flyout without saving', async () => {
await pageObjects.triggersActionsUI.clickCreateAlertButton();
await testSubjects.click('cancelSaveAlertButton');
await testSubjects.missingOrFail('confirmAlertCloseModal');
await pageObjects.triggersActionsUI.clickCreateAlertButton();
await testSubjects.setValue('intervalInput', '10');
await testSubjects.click('cancelSaveAlertButton');
await testSubjects.existOrFail('confirmAlertCloseModal');
await testSubjects.click('confirmAlertCloseModal > confirmModalCancelButton');
await testSubjects.missingOrFail('confirmAlertCloseModal');
});
}); });
}; };

View file

@ -298,6 +298,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
}); });
await testSubjects.click('cancelSaveEditedAlertButton'); await testSubjects.click('cancelSaveEditedAlertButton');
await testSubjects.existOrFail('confirmAlertCloseModal');
await testSubjects.click('confirmAlertCloseModal > confirmModalConfirmButton');
await find.waitForDeletedByCssSelector('[data-test-subj="cancelSaveEditedAlertButton"]'); await find.waitForDeletedByCssSelector('[data-test-subj="cancelSaveEditedAlertButton"]');
await editButton.click(); await editButton.click();