From db69e1e7fdc4967425940f7161d299bc3d00f6ad Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:37:39 +0100 Subject: [PATCH 01/17] Clean up Delete Confirmation Modal --- .../components/delete_confirmation_modal.tsx | 123 ++++++++++++++++++ .../components/delete_modal_confirmation.tsx | 93 ------------- .../public/pages/rule_details/translations.ts | 46 ------- 3 files changed, 123 insertions(+), 139 deletions(-) create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/delete_confirmation_modal.tsx delete mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/delete_modal_confirmation.tsx diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/delete_confirmation_modal.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/delete_confirmation_modal.tsx new file mode 100644 index 0000000000000..d954143d398a5 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/delete_confirmation_modal.tsx @@ -0,0 +1,123 @@ +/* + * 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 { EuiConfirmModal } from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { HttpSetup } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../utils/kibana_react'; + +interface DeleteConfirmationPropsModal { + apiDeleteCall: ({ + ids, + http, + }: { + ids: string[]; + http: HttpSetup; + }) => Promise<{ successes: string[]; errors: string[] }>; + idToDelete: string | undefined; + title: string; + onCancel: () => void; + onDeleted: () => void; + onDeleting: () => void; + onErrors: () => void; +} + +export function DeleteConfirmationModal({ + apiDeleteCall, + idToDelete, + title, + onCancel, + onDeleted, + onDeleting, + onErrors, +}: DeleteConfirmationPropsModal) { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const [deleteModalFlyoutVisible, setDeleteModalVisibility] = useState(false); + + useEffect(() => { + setDeleteModalVisibility(Boolean(idToDelete)); + }, [idToDelete]); + + if (!deleteModalFlyoutVisible) { + return null; + } + + return ( + { + setDeleteModalVisibility(false); + onCancel(); + }} + onConfirm={async () => { + if (idToDelete) { + setDeleteModalVisibility(false); + onDeleting(); + const { successes, errors } = await apiDeleteCall({ ids: [idToDelete], http }); + + const hasSucceeded = Boolean(successes.length); + const hasErrored = Boolean(errors.length); + + if (hasSucceeded) { + toasts.addSuccess( + i18n.translate( + 'xpack.observability.rules.deleteConfirmationModal.successNotification.descriptionText', + { + defaultMessage: 'Deleted {title}', + values: { title }, + } + ) + ); + } + + if (hasErrored) { + toasts.addDanger( + i18n.translate( + 'xpack.observability.rules.deleteConfirmationModal.errorNotification.descriptionText', + { + defaultMessage: 'Failed to delete {title}', + values: { title }, + } + ) + ); + onErrors(); + } + + onDeleted(); + } + }} + > + {i18n.translate('xpack.observability.rules.deleteConfirmationModal.descriptionText', { + defaultMessage: "You can't recover {title} after deleting.", + values: { title }, + })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/delete_modal_confirmation.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/delete_modal_confirmation.tsx deleted file mode 100644 index 61eec8b8c3ea8..0000000000000 --- a/x-pack/plugins/observability/public/pages/rule_details/components/delete_modal_confirmation.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/* - * 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 { EuiConfirmModal } from '@elastic/eui'; -import React, { useEffect, useState } from 'react'; -import { HttpSetup } from '@kbn/core/public'; -import { useKibana } from '../../../utils/kibana_react'; -import { - confirmModalText, - confirmButtonText, - cancelButtonText, - deleteSuccessText, - deleteErrorText, -} from '../translations'; - -export function DeleteModalConfirmation({ - idsToDelete, - apiDeleteCall, - onDeleted, - onCancel, - onErrors, - singleTitle, - multipleTitle, - setIsLoadingState, -}: { - idsToDelete: string[]; - apiDeleteCall: ({ - ids, - http, - }: { - ids: string[]; - http: HttpSetup; - }) => Promise<{ successes: string[]; errors: string[] }>; - onDeleted: (deleted: string[]) => void; - onCancel: () => void; - onErrors: () => void; - singleTitle: string; - multipleTitle: string; - setIsLoadingState: (isLoading: boolean) => void; -}) { - const [deleteModalFlyoutVisible, setDeleteModalVisibility] = useState(false); - - useEffect(() => { - setDeleteModalVisibility(idsToDelete.length > 0); - }, [idsToDelete]); - - const { - http, - notifications: { toasts }, - } = useKibana().services; - const numIdsToDelete = idsToDelete.length; - if (!deleteModalFlyoutVisible) { - return null; - } - - return ( - { - setDeleteModalVisibility(false); - onCancel(); - }} - onConfirm={async () => { - setDeleteModalVisibility(false); - setIsLoadingState(true); - const { successes, errors } = await apiDeleteCall({ ids: idsToDelete, http }); - setIsLoadingState(false); - - const numSuccesses = successes.length; - const numErrors = errors.length; - if (numSuccesses > 0) { - toasts.addSuccess(deleteSuccessText(numSuccesses, singleTitle, multipleTitle)); - } - - if (numErrors > 0) { - toasts.addDanger(deleteErrorText(numErrors, singleTitle, multipleTitle)); - await onErrors(); - } - await onDeleted(successes); - }} - cancelButtonText={cancelButtonText} - confirmButtonText={confirmButtonText(numIdsToDelete, singleTitle, multipleTitle)} - > - {confirmModalText(numIdsToDelete, singleTitle, multipleTitle)} - - ); -} diff --git a/x-pack/plugins/observability/public/pages/rule_details/translations.ts b/x-pack/plugins/observability/public/pages/rule_details/translations.ts index e30178e15cf47..9efd05a549ec4 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/translations.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/translations.ts @@ -41,52 +41,6 @@ export const CREATED_WORD = i18n.translate('xpack.observability.ruleDetails.crea defaultMessage: 'Created', }); -export const confirmModalText = ( - numIdsToDelete: number, - singleTitle: string, - multipleTitle: string -) => - i18n.translate('xpack.observability.rules.deleteSelectedIdsConfirmModal.descriptionText', { - defaultMessage: - "You can't recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.", - values: { numIdsToDelete, singleTitle, multipleTitle }, - }); - -export const confirmButtonText = ( - numIdsToDelete: number, - singleTitle: string, - multipleTitle: string -) => - i18n.translate('xpack.observability.rules.deleteSelectedIdsConfirmModal.deleteButtonLabel', { - defaultMessage: - 'Delete {numIdsToDelete, plural, one {{singleTitle}} other {# {multipleTitle}}} ', - values: { numIdsToDelete, singleTitle, multipleTitle }, - }); - -export const cancelButtonText = i18n.translate( - 'xpack.observability.rules.deleteSelectedIdsConfirmModal.cancelButtonLabel', - { - defaultMessage: 'Cancel', - } -); - -export const deleteSuccessText = ( - numSuccesses: number, - singleTitle: string, - multipleTitle: string -) => - i18n.translate('xpack.observability.rules.deleteSelectedIdsSuccessNotification.descriptionText', { - defaultMessage: - 'Deleted {numSuccesses, number} {numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}}', - values: { numSuccesses, singleTitle, multipleTitle }, - }); - -export const deleteErrorText = (numErrors: number, singleTitle: string, multipleTitle: string) => - i18n.translate('xpack.observability.rules.deleteSelectedIdsErrorNotification.descriptionText', { - defaultMessage: - 'Failed to delete {numErrors, number} {numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}', - values: { numErrors, singleTitle, multipleTitle }, - }); export const ALERT_STATUS_LICENSE_ERROR = i18n.translate( 'xpack.observability.ruleDetails.ruleStatusLicenseError', { From 2aa7689a71f86e142978b4c3e92a00bcb3f2da0e Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:38:03 +0100 Subject: [PATCH 02/17] Break out Rule Detail tabs into its own component --- .../components/rule_detail_tabs.tsx | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/rule_detail_tabs.tsx diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/rule_detail_tabs.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/rule_detail_tabs.tsx new file mode 100644 index 0000000000000..936d8d790499e --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/rule_detail_tabs.tsx @@ -0,0 +1,145 @@ +/* + * 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, { useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiTabbedContent, + EuiTabbedContentTab, +} from '@elastic/eui'; +import { AlertConsumers } from '@kbn/rule-data-utils'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useHistory, useLocation } from 'react-router-dom'; +import { Query, BoolQuery } from '@kbn/es-query'; +import { Rule, RuleType } from '@kbn/triggers-actions-ui-plugin/public'; +import { RuleTypeParams } from '@kbn/alerting-plugin/common'; +import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common'; + +import { ObservabilityAppServices } from '../../../application/types'; +import { fromQuery, toQuery } from '../../../utils/url'; +import { ObservabilityAlertSearchbarWithUrlSync } from '../../../components/shared/alert_search_bar'; +import { + EXECUTION_TAB, + ALERTS_TAB, + RULE_DETAILS_PAGE_ID, + RULE_DETAILS_ALERTS_SEARCH_BAR_ID, + URL_STORAGE_KEY, +} from '../constants'; +import { TabId } from '../types'; +import { observabilityFeatureId } from '../../../../common'; + +interface TabProps { + rule: Rule; + ruleType: RuleType | undefined; +} + +export function RuleDetailTabs({ rule, ruleType }: TabProps) { + const { + triggersActionsUi: { + alertsTableConfigurationRegistry, + getRuleEventLogList: RuleEventLogList, + getAlertsStateTable: AlertsStateTable, + }, + } = useKibana().services; + + const history = useHistory(); + const location = useLocation(); + + const ruleQuery = useRef([ + { query: `kibana.alert.rule.uuid: ${rule.id}`, language: 'kuery' }, + ] as Query[]); + + const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>(); + + const [tabId, setTabId] = useState( + (toQuery(location.search)?.tabId as TabId) || EXECUTION_TAB + ); + + const updateUrl = (nextQuery: { tabId: TabId }) => { + history.push({ + ...location, + search: fromQuery({ + ...toQuery(location.search), + ...nextQuery, + }), + }); + }; + + const onTabIdChange = (newTabId: TabId) => { + setTabId(newTabId); + updateUrl({ tabId: newTabId }); + }; + + const features = ( + rule?.consumer === ALERTS_FEATURE_ID && ruleType?.producer ? ruleType.producer : rule?.consumer + ) as AlertConsumers; + + const tabs: EuiTabbedContentTab[] = [ + { + id: EXECUTION_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.eventLogTabText', { + defaultMessage: 'Execution history', + }), + 'data-test-subj': 'eventLogListTab', + content: ( + + + {ruleType ? : null} + + + ), + }, + { + id: ALERTS_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.alertsTabText', { + defaultMessage: 'Alerts', + }), + 'data-test-subj': 'ruleAlertListTab', + content: ( + <> + + + + + + {esQuery ? ( + + ) : null} + + + + ), + }, + ]; + + return ( + tab.id === tabId)} + onTabClick={(tab) => { + onTabIdChange(tab.id as TabId); + }} + /> + ); +} From edbd05e2d9255913f29f61b91e133a730905fbaa Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:38:35 +0100 Subject: [PATCH 03/17] Break out loading error state into its own component --- .../components/rule_loading_error.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/rule_loading_error.tsx diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/rule_loading_error.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/rule_loading_error.tsx new file mode 100644 index 0000000000000..8c26a5c79b447 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/rule_loading_error.tsx @@ -0,0 +1,35 @@ +/* + * 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 { EuiPanel, EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function RuleLoadingError() { + return ( + + + {i18n.translate('xpack.observability.ruleDetails.errorPromptTitle', { + defaultMessage: 'Unable to load rule details', + })} + + } + body={ +

+ {i18n.translate('xpack.observability.ruleDetails.errorPromptBody', { + defaultMessage: 'There was an error loading the rule details.', + })} +

+ } + /> +
+ ); +} From c60c8e8b7afa732268ed922332e4c9832f02b83a Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:38:52 +0100 Subject: [PATCH 04/17] Break out HeaderActions into its own component --- .../components/header_actions.tsx | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/header_actions.tsx diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/header_actions.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/header_actions.tsx new file mode 100644 index 0000000000000..29ff67bcdca7c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/header_actions.tsx @@ -0,0 +1,100 @@ +/* + * 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, { useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiButton, + EuiButtonEmpty, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface HeaderActionsProps { + loading: boolean; + ruleId: string | undefined; + onDeleteRule: (ruleId: string) => void; + onEditRule: () => void; +} + +export function HeaderActions({ loading, ruleId, onDeleteRule, onEditRule }: HeaderActionsProps) { + const [isRuleEditPopoverOpen, setIsRuleEditPopoverOpen] = useState(false); + + const togglePopover = () => setIsRuleEditPopoverOpen(!isRuleEditPopoverOpen); + + const handleClosePopover = () => setIsRuleEditPopoverOpen(false); + + const handleEditRule = () => { + setIsRuleEditPopoverOpen(false); + + onEditRule(); + }; + + const handleRemoveRule = () => { + setIsRuleEditPopoverOpen(false); + + if (ruleId) { + onDeleteRule(ruleId); + } + }; + + return ( + + + + {i18n.translate('xpack.observability.ruleDetails.actionsButtonLabel', { + defaultMessage: 'Actions', + })} + + } + closePopover={handleClosePopover} + isOpen={isRuleEditPopoverOpen} + > + + + + {i18n.translate('xpack.observability.ruleDetails.editRule', { + defaultMessage: 'Edit rule', + })} + + + + + + {i18n.translate('xpack.observability.ruleDetails.deleteRule', { + defaultMessage: 'Delete rule', + })} + + + + + + + ); +} From 0baf37d884ca244e5ce7b1a5ee8adc9de6b7571b Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:39:34 +0100 Subject: [PATCH 05/17] Tweak loader styling --- .../rule_details/components/center_justified_spinner.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/center_justified_spinner.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/center_justified_spinner.tsx index 867d530eb4e2f..03758fc23bd14 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/center_justified_spinner.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/components/center_justified_spinner.tsx @@ -6,8 +6,7 @@ */ import React from 'react'; - -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; import { EuiLoadingSpinnerSize } from '@elastic/eui/src/components/loading/loading_spinner'; interface Props { @@ -18,7 +17,9 @@ export function CenterJustifiedSpinner({ size }: Props) { return ( - + + + ); From 8d0b1a055718472118a3953e7c4188fe760a6ab9 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:40:24 +0100 Subject: [PATCH 06/17] Clean up Page Title component --- .../rule_details/components/page_title.tsx | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx index d0ad5bbc18891..bb21c8d8acfac 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx @@ -7,48 +7,50 @@ import React from 'react'; import moment from 'moment'; import { EuiText, EuiFlexGroup, EuiFlexItem, EuiBadge, EuiSpacer } from '@elastic/eui'; -import { PageHeaderProps } from '../types'; +import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; import { useKibana } from '../../../utils/kibana_react'; import { LAST_UPDATED_MESSAGE, CREATED_WORD, BY_WORD, ON_WORD } from '../translations'; -import { getHealthColor } from '../config'; +import { getHealthColor } from '../helpers/utils'; -export function PageTitle({ rule }: PageHeaderProps) { - const { triggersActionsUi } = useKibana().services; +export interface PageTitleProps { + rule: Rule; +} + +export function PageTitle({ rule }: PageTitleProps) { + const { + triggersActionsUi: { getRuleTagBadge: RuleTagBadge }, + } = useKibana().services; + + const { name, executionStatus, updatedBy, createdBy, updatedAt, createdAt, tags } = rule; return ( <> - {rule.name} + {name} - + - - {rule.executionStatus.status.charAt(0).toUpperCase() + - rule.executionStatus.status.slice(1)} + + {executionStatus.status.charAt(0).toUpperCase() + executionStatus.status.slice(1)} - + - {LAST_UPDATED_MESSAGE} {BY_WORD} {rule.updatedBy} {ON_WORD}  - {moment(rule.updatedAt).format('ll')}   - {CREATED_WORD} {BY_WORD} {rule.createdBy} {ON_WORD}  - {moment(rule.createdAt).format('ll')} + {LAST_UPDATED_MESSAGE} {BY_WORD} {updatedBy} {ON_WORD}  + {moment(updatedAt).format('ll')}   + {CREATED_WORD} {BY_WORD} {createdBy} {ON_WORD}  + {moment(createdAt).format('ll')} - {rule.tags.length > 0 && - triggersActionsUi.getRuleTagBadge<'tagsOutPopover'>({ - tagsOutPopover: true, - tags: rule.tags, - })} - + {tags.length ? : null} ); } From 2f63b8a2ab2f2bac528a32b046ac5e93ec968a9f Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:52:27 +0100 Subject: [PATCH 07/17] Export all components from index.ts --- .../pages/rule_details/components/index.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/index.ts b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts index e6650e7f5dd07..b9f9547506c5e 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/index.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts @@ -5,4 +5,18 @@ * 2.0. */ -export { PageTitle } from './page_title'; +import { CenterJustifiedSpinner } from './center_justified_spinner'; +import { DeleteConfirmationModal } from './delete_confirmation_modal'; +import { HeaderActions } from './header_actions'; +import { PageTitle } from './page_title'; +import { RuleDetailTabs } from './rule_detail_tabs'; +import { RuleLoadingError } from './rule_loading_error'; + +export { + CenterJustifiedSpinner, + DeleteConfirmationModal, + HeaderActions, + PageTitle, + RuleDetailTabs, + RuleLoadingError, +}; From 07afee93be25286733f5d9e8a9d4c408394f1759 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:53:32 +0100 Subject: [PATCH 08/17] Move util functions --- .../public/pages/rule_details/config.ts | 35 --------- .../pages/rule_details/helpers/utils.ts | 76 +++++++++++++++++++ 2 files changed, 76 insertions(+), 35 deletions(-) delete mode 100644 x-pack/plugins/observability/public/pages/rule_details/config.ts create mode 100644 x-pack/plugins/observability/public/pages/rule_details/helpers/utils.ts diff --git a/x-pack/plugins/observability/public/pages/rule_details/config.ts b/x-pack/plugins/observability/public/pages/rule_details/config.ts deleted file mode 100644 index 410c893aba7a3..0000000000000 --- a/x-pack/plugins/observability/public/pages/rule_details/config.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 { RuleExecutionStatuses } from '@kbn/alerting-plugin/common'; -import { RuleType, Rule } from '@kbn/triggers-actions-ui-plugin/public'; - -export function getHealthColor(status: RuleExecutionStatuses) { - switch (status) { - case 'active': - return 'success'; - case 'error': - return 'danger'; - case 'ok': - return 'primary'; - case 'pending': - return 'accent'; - default: - return 'subdued'; - } -} - -type Capabilities = Record; - -export type InitialRule = Partial & - Pick; - -export function hasAllPrivilege(rule: InitialRule, ruleType?: RuleType): boolean { - return ruleType?.authorizedConsumers[rule.consumer]?.all ?? false; -} - -export const hasExecuteActionsCapability = (capabilities: Capabilities) => - capabilities?.actions?.execute; diff --git a/x-pack/plugins/observability/public/pages/rule_details/helpers/utils.ts b/x-pack/plugins/observability/public/pages/rule_details/helpers/utils.ts new file mode 100644 index 0000000000000..7dad805b4cf44 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/helpers/utils.ts @@ -0,0 +1,76 @@ +/* + * 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 { + RuleExecutionStatusErrorReasons, + RuleExecutionStatuses, +} from '@kbn/alerting-plugin/common'; +import { Capabilities } from '@kbn/core-capabilities-common'; +import { Rule, RuleType, RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public'; +import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import { RecursiveReadonly } from '@kbn/utility-types'; + +import { ALERT_STATUS_LICENSE_ERROR, rulesStatusesTranslationsMapping } from '../translations'; + +export function getHealthColor(status: RuleExecutionStatuses) { + switch (status) { + case 'active': + return 'success'; + case 'error': + return 'danger'; + case 'ok': + return 'primary'; + case 'pending': + return 'accent'; + default: + return 'subdued'; + } +} + +export function getStatusMessage(rule: Rule): string { + return rule?.executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License + ? ALERT_STATUS_LICENSE_ERROR + : rule + ? rulesStatusesTranslationsMapping[rule.executionStatus.status] + : ''; +} + +function hasAllPrivilege(rule: Rule, ruleType?: RuleType): boolean { + return ruleType?.authorizedConsumers[rule.consumer]?.all ?? false; +} + +export interface IsRuleEditableProps { + capabilities: RecursiveReadonly; + rule: Rule | undefined; + ruleType: RuleType | undefined; + ruleTypeRegistry: TypeRegistry>; +} + +export function isRuleEditable({ + capabilities, + rule, + ruleType, + ruleTypeRegistry, +}: IsRuleEditableProps) { + if (!rule) { + return false; + } + + if (!hasAllPrivilege(rule, ruleType)) { + return false; + } + + if (!capabilities.actions?.execute && rule.actions.length !== 0) { + return false; + } + + try { + return ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext === false; + } catch (e) { + return false; + } +} From fa8ff7e7d29f48585a630aa58340ce749e69f463 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:54:12 +0100 Subject: [PATCH 09/17] Add useIsRuleEditable hook --- .../public/hooks/use_is_rule_editable.test.ts | 138 ++++++++++++++++++ .../public/hooks/use_is_rule_editable.ts | 21 +++ 2 files changed, 159 insertions(+) create mode 100644 x-pack/plugins/observability/public/hooks/use_is_rule_editable.test.ts create mode 100644 x-pack/plugins/observability/public/hooks/use_is_rule_editable.ts diff --git a/x-pack/plugins/observability/public/hooks/use_is_rule_editable.test.ts b/x-pack/plugins/observability/public/hooks/use_is_rule_editable.test.ts new file mode 100644 index 0000000000000..5c52c266faa5f --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_is_rule_editable.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { Capabilities } from '@kbn/core-capabilities-common'; +import { Rule, RuleAction, RuleType, RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public'; +import { RecursiveReadonly } from '@kbn/utility-types'; + +import { useIsRuleEditable } from './use_is_rule_editable'; +import { IsRuleEditableProps } from '../pages/rule_details/helpers/utils'; +import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; + +const mockConsumer = 'mock-consumerId'; +const mockRuleTypeId = 'mock-ruleTypeId'; + +const ruleTypeRegistry = new TypeRegistry(); +ruleTypeRegistry.register({ + id: mockRuleTypeId, + requiresAppContext: false, + actions: [], +} as unknown as RuleTypeModel); + +const renderUseIsRuleEditableHook = (props: IsRuleEditableProps) => { + return renderHook(() => useIsRuleEditable({ ...props })); +}; + +const capabilities = { + actions: { + execute: true, + }, +} as unknown as RecursiveReadonly; + +const rule = { + consumer: mockConsumer, + actions: [], + ruleTypeId: mockRuleTypeId, +} as unknown as Rule; + +const ruleType = { + authorizedConsumers: { + [mockConsumer]: { + all: true, + }, + }, +} as unknown as RuleType; + +describe('useIsRuleEditable', () => { + it('should return false if there is no rule', () => { + const { + result: { current: isRuleEditable }, + } = renderUseIsRuleEditableHook({ + capabilities, + rule: undefined, + ruleType, + ruleTypeRegistry, + }); + + expect(isRuleEditable).toBe(false); + }); + + it('should return false if the authorized consumers object of the rule type does not contain the particular rule being passed', () => { + const { + result: { current: isRuleEditable }, + } = renderUseIsRuleEditableHook({ + capabilities, + rule, + ruleType: { + authorizedConsumers: {}, + } as unknown as RuleType, + ruleTypeRegistry, + }); + expect(isRuleEditable).toBe(false); + }); + + it('should return false if the authorized consumers object of the rule type has the id for the particular rule, but all is not set to true', () => { + const { + result: { current: isRuleEditable }, + } = renderUseIsRuleEditableHook({ + capabilities, + rule, + ruleType: { + authorizedConsumers: { + [mockConsumer]: { + all: false, + }, + }, + } as unknown as RuleType, + ruleTypeRegistry, + }); + expect(isRuleEditable).toBe(false); + }); + + it('should return false if the rule has actions to perform but the execute capability is false ', () => { + const { + result: { current: isRuleEditable }, + } = renderUseIsRuleEditableHook({ + capabilities: { + actions: { + execute: false, + }, + } as unknown as RecursiveReadonly, + rule: { + ...rule, + actions: ['123'] as unknown as RuleAction[], + }, + ruleType, + ruleTypeRegistry, + }); + expect(isRuleEditable).toBe(false); + }); + + it('should return false if the rule is not registered in the rule registry', () => { + const { + result: { current: isRuleEditable }, + } = renderUseIsRuleEditableHook({ + capabilities, + rule, + ruleType, + ruleTypeRegistry: new TypeRegistry(), + }); + expect(isRuleEditable).toBe(false); + }); + + it('it should return true if all conditions are met', () => { + const { + result: { current: isRuleEditable }, + } = renderUseIsRuleEditableHook({ + capabilities, + rule, + ruleType, + ruleTypeRegistry, + }); + expect(isRuleEditable).toBe(true); + }); +}); diff --git a/x-pack/plugins/observability/public/hooks/use_is_rule_editable.ts b/x-pack/plugins/observability/public/hooks/use_is_rule_editable.ts new file mode 100644 index 0000000000000..459f2b50c8986 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_is_rule_editable.ts @@ -0,0 +1,21 @@ +/* + * 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 { useMemo } from 'react'; +import { isRuleEditable, IsRuleEditableProps } from '../pages/rule_details/helpers/utils'; + +export function useIsRuleEditable({ + capabilities, + rule, + ruleType, + ruleTypeRegistry, +}: IsRuleEditableProps): boolean { + return useMemo( + () => isRuleEditable({ capabilities, rule, ruleType, ruleTypeRegistry }), + [capabilities, rule, ruleType, ruleTypeRegistry] + ); +} From ea7719173e17f705222a2e474dadcffa1616e64b Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:54:36 +0100 Subject: [PATCH 10/17] Create UseGetFilteredRuleTypes hook --- .../public/hooks/use_get_filtered_rule_types.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 x-pack/plugins/observability/public/hooks/use_get_filtered_rule_types.ts diff --git a/x-pack/plugins/observability/public/hooks/use_get_filtered_rule_types.ts b/x-pack/plugins/observability/public/hooks/use_get_filtered_rule_types.ts new file mode 100644 index 0000000000000..d72118182ed65 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_get_filtered_rule_types.ts @@ -0,0 +1,15 @@ +/* + * 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 { useMemo } from 'react'; +import { usePluginContext } from './use_plugin_context'; + +export function useGetFilteredRuleTypes() { + const { observabilityRuleTypeRegistry } = usePluginContext(); + + return useMemo(() => observabilityRuleTypeRegistry.list(), [observabilityRuleTypeRegistry]); +} From 79b5a16c0f72c95f9d68fb28060a099effbf5762 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:55:05 +0100 Subject: [PATCH 11/17] Create UseGetRuleTypeDefinitionFromRuleType hook --- ...get_rule_type_definition_from_rule_type.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 x-pack/plugins/observability/public/hooks/use_get_rule_type_definition_from_rule_type.ts diff --git a/x-pack/plugins/observability/public/hooks/use_get_rule_type_definition_from_rule_type.ts b/x-pack/plugins/observability/public/hooks/use_get_rule_type_definition_from_rule_type.ts new file mode 100644 index 0000000000000..e4eb8abc26f63 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_get_rule_type_definition_from_rule_type.ts @@ -0,0 +1,24 @@ +/* + * 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 { useLoadRuleTypes } from '@kbn/triggers-actions-ui-plugin/public'; +import { useGetFilteredRuleTypes } from './use_get_filtered_rule_types'; + +interface UseGetRuleTypeDefinitionFromRuleTypeProps { + ruleTypeId: string | undefined; +} + +export function useGetRuleTypeDefinitionFromRuleType({ + ruleTypeId, +}: UseGetRuleTypeDefinitionFromRuleTypeProps) { + const filteredRuleTypes = useGetFilteredRuleTypes(); + + const { ruleTypes } = useLoadRuleTypes({ + filteredRuleTypes, + }); + + return ruleTypes.find(({ id }) => id === ruleTypeId); +} From 33d0fb281b5a50fbf945c13963cb26bc15a44847 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:55:26 +0100 Subject: [PATCH 12/17] Update types --- .../plugins/observability/public/pages/rule_details/types.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/types.ts b/x-pack/plugins/observability/public/pages/rule_details/types.ts index ead002266e53e..c042ad3ac6fcc 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/types.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/types.ts @@ -14,9 +14,6 @@ export type TabId = typeof ALERTS_TAB | typeof EXECUTION_TAB; export interface RuleDetailsPathParams { ruleId: string; } -export interface PageHeaderProps { - rule: Rule; -} export interface FetchRuleProps { ruleId: string; From 2901648c4a45eaf7a772a3d4dfbe3d931651f894 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:55:50 +0100 Subject: [PATCH 13/17] Refactor Rule Details page --- .../public/pages/rule_details/index.tsx | 481 +++++------------- 1 file changed, 130 insertions(+), 351 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 5487dba9a0195..05118e22959e4 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -5,162 +5,67 @@ * 2.0. */ -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { useHistory, useParams, useLocation } from 'react-router-dom'; +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { - EuiText, - EuiSpacer, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiPopover, - EuiTabbedContent, - EuiEmptyPrompt, - EuiSuperSelectOption, - EuiButton, - EuiFlyoutSize, - EuiTabbedContentTab, -} from '@elastic/eui'; - -import { - deleteRules, - useLoadRuleTypes, - RuleType, - getNotifyWhenOptions, - RuleEventLogListProps, -} from '@kbn/triggers-actions-ui-plugin/public'; -// TODO: use a Delete modal from triggersActionUI when it's sharable -import { ALERTS_FEATURE_ID, RuleExecutionStatusErrorReasons } from '@kbn/alerting-plugin/common'; -import { Query, BoolQuery } from '@kbn/es-query'; -import { AlertConsumers } from '@kbn/rule-data-utils'; -import { RuleDefinitionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { deleteRules } from '@kbn/triggers-actions-ui-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { fromQuery, toQuery } from '../../utils/url'; -import { ObservabilityAlertSearchbarWithUrlSync } from '../../components/shared/alert_search_bar'; -import { DeleteModalConfirmation } from './components/delete_modal_confirmation'; -import { CenterJustifiedSpinner } from './components/center_justified_spinner'; import { - EXECUTION_TAB, - ALERTS_TAB, - RULE_DETAILS_PAGE_ID, - RULE_DETAILS_ALERTS_SEARCH_BAR_ID, - URL_STORAGE_KEY, -} from './constants'; -import { RuleDetailsPathParams, TabId } from './types'; + CenterJustifiedSpinner, + DeleteConfirmationModal, + PageTitle, + HeaderActions, + RuleLoadingError, + RuleDetailTabs, +} from './components'; +import { RuleDetailsPathParams } from './types'; +import { ObservabilityAppServices } from '../../application/types'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; -import { usePluginContext } from '../../hooks/use_plugin_context'; import { useFetchRule } from '../../hooks/use_fetch_rule'; -import { RULES_BREADCRUMB_TEXT } from '../rules/translations'; -import { PageTitle } from './components'; -import { getHealthColor } from './config'; -import { hasExecuteActionsCapability, hasAllPrivilege } from './config'; +import { useGetFilteredRuleTypes } from '../../hooks/use_get_filtered_rule_types'; +import { useGetRuleTypeDefinitionFromRuleType } from '../../hooks/use_get_rule_type_definition_from_rule_type'; +import { useIsRuleEditable } from '../../hooks/use_is_rule_editable'; +import { usePluginContext } from '../../hooks/use_plugin_context'; +import { getHealthColor, getStatusMessage } from './helpers/utils'; import { paths } from '../../config/paths'; -import { observabilityFeatureId } from '../../../common'; -import { ALERT_STATUS_LICENSE_ERROR, rulesStatusesTranslationsMapping } from './translations'; -import { ObservabilityAppServices } from '../../application/types'; +import { RULES_BREADCRUMB_TEXT } from '../rules/translations'; export function RuleDetailsPage() { const { + application: { capabilities, navigateToUrl }, http, + notifications: { toasts }, triggersActionsUi: { - alertsTableConfigurationRegistry, + actionTypeRegistry, ruleTypeRegistry, - getEditAlertFlyout, - getRuleEventLogList, - getAlertsStateTable: AlertsStateTable, - getRuleAlertsSummary, - getRuleStatusPanel, - getRuleDefinition, + getEditAlertFlyout: EditAlertFlyout, + getRuleAlertsSummary: RuleAlertsSummary, + getRuleStatusPanel: RuleStatusPanel, + getRuleDefinition: RuleDefinition, }, - application: { capabilities, navigateToUrl }, - notifications: { toasts }, } = useKibana().services; - const { ruleId } = useParams(); - const { ObservabilityPageTemplate, observabilityRuleTypeRegistry } = usePluginContext(); - const history = useHistory(); - const location = useLocation(); - - const filteredRuleTypes = useMemo( - () => observabilityRuleTypeRegistry.list(), - [observabilityRuleTypeRegistry] - ); + const { ObservabilityPageTemplate } = usePluginContext(); - const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http }); - const { ruleTypes } = useLoadRuleTypes({ - filteredRuleTypes, - }); - const [tabId, setTabId] = useState(() => { - const urlTabId = (toQuery(location.search)?.tabId as TabId) || EXECUTION_TAB; - return [EXECUTION_TAB, ALERTS_TAB].includes(urlTabId) ? urlTabId : EXECUTION_TAB; - }); - const [features, setFeatures] = useState(''); - const [ruleType, setRuleType] = useState>(); - const [ruleToDelete, setRuleToDelete] = useState([]); - const [isPageLoading, setIsPageLoading] = useState(false); - const [editFlyoutVisible, setEditFlyoutVisible] = useState(false); - const [isRuleEditPopoverOpen, setIsRuleEditPopoverOpen] = useState(false); - const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>(); - const ruleQuery = useRef([ - { query: `kibana.alert.rule.uuid: ${ruleId}`, language: 'kuery' }, - ] as Query[]); - - const updateUrl = (nextQuery: { tabId: TabId }) => { - const newTabId = nextQuery.tabId; - const nextSearch = - newTabId === ALERTS_TAB - ? { - ...toQuery(location.search), - ...nextQuery, - } - : { tabId: EXECUTION_TAB }; - - history.replace({ - ...location, - search: fromQuery(nextSearch), - }); - }; - - const onTabIdChange = (newTabId: TabId) => { - setTabId(newTabId); - updateUrl({ tabId: newTabId }); - }; - - const NOTIFY_WHEN_OPTIONS = useRef>>([]); - useEffect(() => { - const loadNotifyWhenOption = async () => { - NOTIFY_WHEN_OPTIONS.current = await getNotifyWhenOptions(); - }; - loadNotifyWhenOption(); - }, []); - - const togglePopover = () => - setIsRuleEditPopoverOpen((pervIsRuleEditPopoverOpen) => !pervIsRuleEditPopoverOpen); + const { ruleId } = useParams(); - const handleClosePopover = () => setIsRuleEditPopoverOpen(false); + const { rule, isRuleLoading, errorRule, reloadRule } = useFetchRule({ http, ruleId }); + const filteredRuleTypes = useGetFilteredRuleTypes(); - const handleRemoveRule = useCallback(() => { - setIsRuleEditPopoverOpen(false); - if (rule) setRuleToDelete([rule.id]); - }, [rule]); + const ruleTypeDefinition = useGetRuleTypeDefinitionFromRuleType({ ruleTypeId: rule?.ruleTypeId }); - const handleEditRule = useCallback(() => { - setIsRuleEditPopoverOpen(false); - setEditFlyoutVisible(true); - }, []); + const isRuleEditable = useIsRuleEditable({ + capabilities, + rule, + ruleType: ruleTypeDefinition, + ruleTypeRegistry, + }); - useEffect(() => { - if (ruleTypes.length && rule) { - const matchedRuleType = ruleTypes.find((type) => type.id === rule.ruleTypeId); - setRuleType(matchedRuleType); + const [editRuleFlyoutVisible, setEditRuleFlyoutVisible] = useState(false); - if (rule.consumer === ALERTS_FEATURE_ID && matchedRuleType && matchedRuleType.producer) { - setFeatures(matchedRuleType.producer); - } else setFeatures(rule.consumer); - } - }, [rule, ruleTypes]); + const [ruleToDelete, setRuleToDelete] = useState(undefined); + const [isRuleDeleting, setIsRuleDeleting] = useState(false); useBreadcrumbs([ { @@ -174,233 +79,107 @@ export function RuleDetailsPage() { text: RULES_BREADCRUMB_TEXT, }, { - text: rule && rule.name, + text: rule?.name, }, ]); - const canExecuteActions = hasExecuteActionsCapability(capabilities); + const handleEditRule = () => { + setEditRuleFlyoutVisible(true); + }; - const canSaveRule = - rule && - hasAllPrivilege(rule, ruleType) && - // if the rule has actions, can the user save the rule's action params - (canExecuteActions || (!canExecuteActions && rule.actions.length === 0)); + const handleDeleteRule = () => { + if (rule) { + setRuleToDelete(rule.id); + } + setEditRuleFlyoutVisible(false); + }; - const hasEditButton = - // can the user save the rule - canSaveRule && - // is this rule type editable from within Rules Management - (ruleTypeRegistry.has(rule.ruleTypeId) - ? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext - : false); + if (errorRule) { + toasts.addDanger({ title: errorRule }); + } - const tabs: EuiTabbedContentTab[] = [ - { - id: EXECUTION_TAB, - name: i18n.translate('xpack.observability.ruleDetails.rule.eventLogTabText', { - defaultMessage: 'Execution history', - }), - 'data-test-subj': 'eventLogListTab', - content: ( - - - {getRuleEventLogList<'default'>({ - ruleId: rule?.id, - ruleType, - } as RuleEventLogListProps)} - - - ), - }, - { - id: ALERTS_TAB, - name: i18n.translate('xpack.observability.ruleDetails.rule.alertsTabText', { - defaultMessage: 'Alerts', - }), - 'data-test-subj': 'ruleAlertListTab', - content: ( - <> - - - - - - {esQuery && features && ( - - )} + return ( + <> + {isRuleLoading ? : null} + + {errorRule && !rule ? ( + + ) : rule ? ( + , + bottomBorder: false, + rightSideItems: isRuleEditable + ? [ + , + ] + : undefined, + }} + > + + + - - - ), - }, - ]; - if (isPageLoading || isRuleLoading) return ; - if (!rule || errorRule) - return ( - - - {i18n.translate('xpack.observability.ruleDetails.errorPromptTitle', { - defaultMessage: 'Unable to load rule details', - })} - - } - body={ -

- {i18n.translate('xpack.observability.ruleDetails.errorPromptBody', { - defaultMessage: 'There was an error loading the rule details.', - })} -

- } - /> -
- ); + - const isLicenseError = - rule.executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; + + + - const statusMessage = isLicenseError - ? ALERT_STATUS_LICENSE_ERROR - : rulesStatusesTranslationsMapping[rule.executionStatus.status]; + - return ( - , - bottomBorder: false, - rightSideItems: hasEditButton - ? [ - - - - {i18n.translate('xpack.observability.ruleDetails.actionsButtonLabel', { - defaultMessage: 'Actions', - })} - - } - > - - - - - {i18n.translate('xpack.observability.ruleDetails.editRule', { - defaultMessage: 'Edit rule', - })} - - - - - - {i18n.translate('xpack.observability.ruleDetails.deleteRule', { - defaultMessage: 'Delete rule', - })} - - - - - - - - , - ] - : [], - }} - > - - - {getRuleStatusPanel({ - rule, - isEditable: hasEditButton, - requestRefresh: reloadRule, - healthColor: getHealthColor(rule.executionStatus.status), - statusMessage, - })} - - - - {getRuleAlertsSummary({ - rule, - filteredRuleTypes, - })} - - - {getRuleDefinition({ rule, onEditRule: () => reloadRule() } as RuleDefinitionProps)} - + +
- - tab.id === tabId)} - onTabClick={(tab) => { - onTabIdChange(tab.id as TabId); - }} - /> - {editFlyoutVisible && - getEditAlertFlyout({ - initialRule: rule, - onClose: () => { - setEditFlyoutVisible(false); - }, - onSave: reloadRule, - })} - { - setRuleToDelete([]); - navigateToUrl(http.basePath.prepend(paths.observability.rules)); - }} - onErrors={() => { - setRuleToDelete([]); - navigateToUrl(http.basePath.prepend(paths.observability.rules)); - }} - onCancel={() => setRuleToDelete([])} - apiDeleteCall={deleteRules} - idsToDelete={ruleToDelete} - singleTitle={rule.name} - multipleTitle={rule.name} - setIsLoadingState={() => setIsPageLoading(true)} - /> - {errorRule && toasts.addDanger({ title: errorRule })} - + + + + + {editRuleFlyoutVisible && ( + { + setEditRuleFlyoutVisible(false); + }} + onSave={reloadRule} + /> + )} + + setIsRuleDeleting(true)} + onDeleted={() => { + setRuleToDelete(undefined); + navigateToUrl(http.basePath.prepend(paths.observability.rules)); + }} + onErrors={() => { + setRuleToDelete(undefined); + navigateToUrl(http.basePath.prepend(paths.observability.rules)); + }} + onCancel={() => setRuleToDelete(undefined)} + /> + + ) : null} + ); } From 56e627b6314e957621a65388b2aed4d360f88d63 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 23 Nov 2022 17:56:02 +0100 Subject: [PATCH 14/17] Add test --- .../public/pages/rule_details/index.test.tsx | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 x-pack/plugins/observability/public/pages/rule_details/index.test.tsx diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.test.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.test.tsx new file mode 100644 index 0000000000000..bc4a275a483e4 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/index.test.tsx @@ -0,0 +1,285 @@ +/* + * 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 { fireEvent, render } from '@testing-library/react'; +import { CoreStart } from '@kbn/core/public'; +import { ConfigSchema, ObservabilityPublicPluginsStart } from '../../plugin'; +import { RuleDetailsPage } from '.'; +import { kibanaStartMock } from '../../utils/kibana_react.mock'; +import * as pluginContext from '../../hooks/use_plugin_context'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { createObservabilityRuleTypeRegistryMock } from '../../rules/observability_rule_type_registry_mock'; +import { AppMountParameters } from '@kbn/core/public'; +import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common'; + +interface SetupProps { + ruleLoading: boolean; + ruleError: boolean; + ruleLoaded: boolean; + ruleEditable: boolean; +} + +const mockUseKibanaReturnValue = kibanaStartMock.startContract(); +const mockRuleId = 'mock-rule-id'; + +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + useKibana: jest.fn(() => mockUseKibanaReturnValue), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => ({ ruleId: mockRuleId })), + useLocation: jest.fn(() => ({ search: '123' })), +})); + +jest.mock('../../hooks/use_breadcrumbs', () => ({ + useBreadcrumbs: jest.fn(), +})); + +jest.mock('@kbn/triggers-actions-ui-plugin/public', () => ({ + useLoadRuleTypes: jest.fn(), +})); + +jest.mock('../../hooks/use_fetch_rule', () => ({ + useFetchRule: jest.fn(), +})); + +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + useIsRuleEditable: jest.fn(), +})); + +jest.mock('../../hooks/use_get_filtered_rule_types', () => ({ + useGetFilteredRuleTypes: jest.fn(() => ['123']), +})); + +jest.mock('../../hooks/use_get_rule_type_definition_from_rule_type', () => ({ + useGetRuleTypeDefinitionFromRuleType: jest.fn(), +})); + +jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ + appMountParameters: {} as AppMountParameters, + config: {} as unknown as ConfigSchema, + observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), + ObservabilityPageTemplate: KibanaPageTemplate, + kibanaFeatures: [], + core: {} as CoreStart, + plugins: {} as ObservabilityPublicPluginsStart, +})); + +const { useFetchRule } = jest.requireMock('../../hooks/use_fetch_rule'); +const { useGetRuleTypeDefinitionFromRuleType } = jest.requireMock( + '../../hooks/use_get_rule_type_definition_from_rule_type' +); +const { useIsRuleEditable } = jest.requireMock('./utils'); + +describe('RulesDetailPage', () => { + async function setup({ ruleLoading, ruleError, ruleLoaded, ruleEditable }: SetupProps) { + const mockRuleType = { + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + actionVariables: { + context: [], + state: [], + }, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { + all: true, + }, + }, + defaultActionGroupId: 'default', + enabledInLicense: true, + executionStatus: { + status: 'active', + }, + id: 'test_rule_type', + minimumLicenseRequired: 'basic', + name: 'some rule type', + producer: ALERTS_FEATURE_ID, + recoveryActionGroup: { + id: 'recovered', + name: 'Recovered', + }, + ruleTaskTimeout: '1m', + tags: [], + }; + + const mockRule = { + ruleTypeId: 'metrics.alert.threshold', + createdBy: 'admin', + updatedBy: 'admin', + createdAt: '2022-11-21T13:21:10.555Z', + updatedAt: '2022-11-21T13:21:10.555Z', + apiKeyOwner: 'admin', + notifyWhen: 'onActionGroupChange', + muteAll: false, + mutedInstanceIds: [], + snoozeSchedule: [], + executionStatus: { + lastExecutionDate: '2022-11-22T11:35:37.219Z', + lastDuration: 4190, + status: 'active', + }, + actions: [], + scheduledTaskId: '5c0177b0-699f-11ed-8e77-89558ff3f691', + isSnoozedUntil: null, + lastRun: { + outcomeMsg: null, + alertsCount: { + new: 0, + ignored: 0, + recovered: 0, + active: 1, + }, + warning: null, + outcome: 'succeeded', + }, + nextRun: '2022-11-22T11:36:36.983Z', + id: '5c0177b0-699f-11ed-8e77-89558ff3f691', + consumer: 'alerts', + tags: [], + name: 'foo', + enabled: true, + throttle: null, + schedule: { + interval: '1m', + }, + params: { + criteria: [ + { + aggType: 'avg', + comparator: '>', + threshold: [2], + timeSize: 1, + timeUnit: 'm', + metric: '_score', + }, + ], + sourceId: 'default', + alertOnNoData: true, + alertOnGroupDisappear: true, + }, + monitoring: { + run: { + last_run: { + timestamp: '2022-11-22T11:35:37.219Z', + metrics: { + total_search_duration_ms: null, + total_indexing_duration_ms: null, + total_alerts_detected: null, + total_alerts_created: null, + gap_duration_s: null, + duration: 4190, + }, + }, + history: [], + calculated_metrics: { + success_ratio: 0.9936708860759493, + p99: 5367.439999999997, + p50: 4182, + p95: 5019, + }, + }, + }, + }; + + useFetchRule.mockReturnValue({ + rule: ruleError || ruleLoading || !ruleLoaded ? undefined : mockRule, + isRuleLoading: ruleLoading, + errorRule: ruleError ? 'error loading rule' : undefined, + reloadRule: jest.fn(), + }); + + useGetRuleTypeDefinitionFromRuleType.mockReturnValue(mockRuleType); + + useIsRuleEditable.mockReturnValue(ruleEditable); + + return render(); + } + + describe('when a rule is loading', () => { + it('should render a loader', async () => { + const wrapper = await setup({ + ruleLoading: true, + ruleError: false, + ruleLoaded: false, + ruleEditable: false, + }); + + expect(wrapper.getByTestId('centerJustifiedSpinner')).toBeInTheDocument(); + }); + }); + + const ruleLoadedUnsuccessfully = { + ruleLoading: false, + ruleError: true, + ruleLoaded: false, + ruleEditable: false, + }; + describe('when loading a rule has errored', () => { + it('should render an error state', async () => { + const wrapper = await setup(ruleLoadedUnsuccessfully); + + expect(wrapper.getByTestId('rule-loading-error')).toBeInTheDocument(); + }); + }); + + const ruleLoadedSuccesfully = { + ruleLoading: false, + ruleError: false, + ruleLoaded: true, + ruleEditable: true, + }; + + describe('when a rule has been loaded', () => { + it('should render a page template', async () => { + const wrapper = await setup(ruleLoadedSuccesfully); + expect(wrapper.getByTestId('ruleDetails')).toBeInTheDocument(); + }); + + it('should render header actions when the rule is editable', async () => { + const wrapperRuleNonEditable = await setup({ ...ruleLoadedSuccesfully, ruleEditable: false }); + expect(wrapperRuleNonEditable.queryByTestId('actions')).toBeNull(); + + const wrapperRuleEditable = await setup(ruleLoadedSuccesfully); + expect(wrapperRuleEditable.queryByTestId('actions')).toBeInTheDocument(); + }); + + it('should render a DeleteConfirmationModal when deleting a rule', async () => { + const wrapper = await setup(ruleLoadedSuccesfully); + + fireEvent.click(wrapper.getByTestId('actions')); + fireEvent.click(wrapper.getByTestId('deleteRuleButton')); + + expect(wrapper.getByTestId('deleteIdsConfirmation')).toBeInTheDocument(); + }); + + it('should render RuleDetailTabs', async () => { + const wrapper = await setup(ruleLoadedSuccesfully); + expect(wrapper.getByTestId('rule-detail-tabs')).toBeInTheDocument(); + }); + + it('should render a RuleStatusPanel', async () => { + const wrapper = await setup(ruleLoadedSuccesfully); + expect(wrapper.getByTestId('rule-status-panel')).toBeInTheDocument(); + }); + + it('should render an EditAlertFlyout', async () => { + const wrapper = await setup(ruleLoadedSuccesfully); + + fireEvent.click(wrapper.getByTestId('actions')); + fireEvent.click(wrapper.getByTestId('editRuleButton')); + + expect(wrapper.getByTestId('edit-alert-flyout')).toBeInTheDocument(); + }); + }); +}); From 56f7b1ba26e21f52d0f66a8710d31a8f843d8dca Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Tue, 22 Nov 2022 17:05:30 +0100 Subject: [PATCH 15/17] Update translations --- x-pack/plugins/translations/translations/fr-FR.json | 5 ----- x-pack/plugins/translations/translations/ja-JP.json | 5 ----- x-pack/plugins/translations/translations/zh-CN.json | 4 ---- 3 files changed, 14 deletions(-) diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 85184f9c7164a..1df22cd1a8a83 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -23284,10 +23284,6 @@ "xpack.observability.overview.exploratoryView.noDataAvailable": "Aucune donnée {dataType} disponible.", "xpack.observability.ruleDetails.executionLogError": "Impossible de charger le log d'exécution de la règle. Raison : {message}", "xpack.observability.ruleDetails.ruleLoadError": "Impossible de charger la règle. Raison : {message}", - "xpack.observability.rules.deleteSelectedIdsConfirmModal.deleteButtonLabel": "Supprimer {numIdsToDelete, plural, one {{singleTitle}} other {# {multipleTitle}}} ", - "xpack.observability.rules.deleteSelectedIdsConfirmModal.descriptionText": "Vous ne pouvez pas récupérer {numIdsToDelete, plural, one {un {singleTitle} supprimé} other {des {multipleTitle} supprimés}}.", - "xpack.observability.rules.deleteSelectedIdsErrorNotification.descriptionText": "Impossible de supprimer {numErrors, number} {numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}", - "xpack.observability.rules.deleteSelectedIdsSuccessNotification.descriptionText": "Suppression de {numSuccesses, number} {numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}} effectuée", "xpack.observability.textDefinitionField.placeholder.search": "Rechercher dans {label}", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.urlFilter.wildcard": "Utiliser le caractère générique *{wildcard}*", @@ -23651,7 +23647,6 @@ "xpack.observability.ruleDetails.ruleStatusWarning": "Avertissement", "xpack.observability.ruleDetails.tagsTitle": "Balises", "xpack.observability.rules.addRuleButtonLabel": "Créer une règle", - "xpack.observability.rules.deleteSelectedIdsConfirmModal.cancelButtonLabel": "Annuler", "xpack.observability.rules.docsLinkText": "Documentation", "xpack.observability.rules.loadError": "Impossible de charger les règles", "xpack.observability.rules.rulesTable.changeStatusAriaLabel": "Modifier le statut", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e371777dc9f67..7582a5126d130 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23263,10 +23263,6 @@ "xpack.observability.overview.exploratoryView.noDataAvailable": "{dataType}データがありません。", "xpack.observability.ruleDetails.executionLogError": "ルール実行ログを読み込めません。理由:{message}", "xpack.observability.ruleDetails.ruleLoadError": "ルールを読み込めません。理由:{message}", - "xpack.observability.rules.deleteSelectedIdsConfirmModal.deleteButtonLabel": "{numIdsToDelete, plural, one {{singleTitle}} other {# {multipleTitle}}}を削除 ", - "xpack.observability.rules.deleteSelectedIdsConfirmModal.descriptionText": "{numIdsToDelete, plural, one {削除された {singleTitle}} other {deleted {multipleTitle}}}を回復できません。", - "xpack.observability.rules.deleteSelectedIdsErrorNotification.descriptionText": "{numErrors, number} {numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}を削除できませんでした", - "xpack.observability.rules.deleteSelectedIdsSuccessNotification.descriptionText": "{numSuccesses, number} {numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}}を削除しました", "xpack.observability.textDefinitionField.placeholder.search": "{label}を検索", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.urlFilter.wildcard": "ワイルドカード*{wildcard}*を使用", @@ -23630,7 +23626,6 @@ "xpack.observability.ruleDetails.ruleStatusWarning": "警告", "xpack.observability.ruleDetails.tagsTitle": "タグ", "xpack.observability.rules.addRuleButtonLabel": "ルールを作成", - "xpack.observability.rules.deleteSelectedIdsConfirmModal.cancelButtonLabel": "キャンセル", "xpack.observability.rules.docsLinkText": "ドキュメント", "xpack.observability.rules.loadError": "ルールを読み込めません", "xpack.observability.rules.rulesTable.changeStatusAriaLabel": "ステータスの変更", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3abbec5db2bda..394d34f8d92e2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23295,9 +23295,6 @@ "xpack.observability.overview.exploratoryView.noDataAvailable": "没有可用的 {dataType} 数据。", "xpack.observability.ruleDetails.executionLogError": "无法加载规则执行日志。原因:{message}", "xpack.observability.ruleDetails.ruleLoadError": "无法加载规则。原因:{message}", - "xpack.observability.rules.deleteSelectedIdsConfirmModal.deleteButtonLabel": "删除{numIdsToDelete, plural, one {{singleTitle}} other { # 个{multipleTitle}}} ", - "xpack.observability.rules.deleteSelectedIdsConfirmModal.descriptionText": "无法恢复{numIdsToDelete, plural, one {删除的{singleTitle}} other {删除的{multipleTitle}}}。", - "xpack.observability.rules.deleteSelectedIdsErrorNotification.descriptionText": "无法删除 {numErrors, number} 个{numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}", "xpack.observability.textDefinitionField.placeholder.search": "搜索 {label}", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.urlFilter.wildcard": "使用通配符 *{wildcard}*", @@ -23661,7 +23658,6 @@ "xpack.observability.ruleDetails.ruleStatusWarning": "警告", "xpack.observability.ruleDetails.tagsTitle": "标签", "xpack.observability.rules.addRuleButtonLabel": "创建规则", - "xpack.observability.rules.deleteSelectedIdsConfirmModal.cancelButtonLabel": "取消", "xpack.observability.rules.docsLinkText": "文档", "xpack.observability.rules.loadError": "无法加载规则", "xpack.observability.rules.rulesTable.changeStatusAriaLabel": "更改状态", From 9b71597ec05878edeac1384ff7ffeac2502584ad Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Thu, 24 Nov 2022 09:58:13 +0100 Subject: [PATCH 16/17] Add all triggerActionsUI comps to mock, turn them into JSX components --- ...servability_public_plugins_start.mock.tsx} | 41 +++++++++++++++---- .../public/pages/rule_details/index.test.tsx | 7 ++-- 2 files changed, 37 insertions(+), 11 deletions(-) rename x-pack/plugins/observability/public/{observability_public_plugins_start.mock.ts => observability_public_plugins_start.mock.tsx} (50%) diff --git a/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts b/x-pack/plugins/observability/public/observability_public_plugins_start.mock.tsx similarity index 50% rename from x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts rename to x-pack/plugins/observability/public/observability_public_plugins_start.mock.tsx index 31aa38cb2fd49..caaf843b38ef6 100644 --- a/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts +++ b/x-pack/plugins/observability/public/observability_public_plugins_start.mock.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; const embeddableStartMock = { @@ -26,13 +27,39 @@ const embeddableStartMock = { const triggersActionsUiStartMock = { createStart() { return { - getAddAlertFlyout: jest.fn(), - getAlertsSearchBar: jest.fn(), - getRuleStatusDropdown: jest.fn(), - getRuleTagBadge: jest.fn(), - getRuleStatusFilter: jest.fn(), - getRuleTagFilter: jest.fn(), - getRulesList: jest.fn(), + getAddAlertFlyout: jest.fn(() => ( +
mocked component
+ )), + getAlertsSearchBar: jest.fn(() => ( +
mocked component
+ )), + getAlertsStateTable: jest.fn(() => ( +
mocked component
+ )), + getEditAlertFlyout: jest.fn(() => ( +
mocked component
+ )), + getRuleAlertsSummary: jest.fn(() => ( +
mocked component
+ )), + getRuleDefinition: jest.fn(() => ( +
mocked component
+ )), + getRuleEventLogList: jest.fn(() => ( +
mocked component
+ )), + getRuleStatusDropdown: jest.fn(() => ( +
mocked component
+ )), + getRuleStatusPanel: jest.fn(() => ( +
mocked component
+ )), + getRuleTagBadge: jest.fn(() =>
mocked component
), + getRuleStatusFilter: jest.fn(() => ( +
mocked component
+ )), + getRuleTagFilter: jest.fn(() =>
mocked component
), + getRulesList: jest.fn(() =>
mocked component
), ruleTypeRegistry: { has: jest.fn(), register: jest.fn(), diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.test.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.test.tsx index bc4a275a483e4..6a302285479d4 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.test.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.test.tsx @@ -49,8 +49,7 @@ jest.mock('../../hooks/use_fetch_rule', () => ({ useFetchRule: jest.fn(), })); -jest.mock('./utils', () => ({ - ...jest.requireActual('./utils'), +jest.mock('../../hooks/use_is_rule_editable', () => ({ useIsRuleEditable: jest.fn(), })); @@ -76,9 +75,9 @@ const { useFetchRule } = jest.requireMock('../../hooks/use_fetch_rule'); const { useGetRuleTypeDefinitionFromRuleType } = jest.requireMock( '../../hooks/use_get_rule_type_definition_from_rule_type' ); -const { useIsRuleEditable } = jest.requireMock('./utils'); +const { useIsRuleEditable } = jest.requireMock('../../hooks/use_is_rule_editable'); -describe('RulesDetailPage', () => { +describe('RuleDetailPage', () => { async function setup({ ruleLoading, ruleError, ruleLoaded, ruleEditable }: SetupProps) { const mockRuleType = { actionGroups: [ From 865279bb448de653520b165718f09e23385deed8 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Thu, 24 Nov 2022 13:47:11 +0100 Subject: [PATCH 17/17] Update isRuleEditable hook --- .../public/hooks/use_is_rule_editable.test.ts | 5 +-- .../public/hooks/use_is_rule_editable.ts | 43 ++++++++++++++++--- .../pages/rule_details/helpers/utils.ts | 41 +----------------- 3 files changed, 39 insertions(+), 50 deletions(-) diff --git a/x-pack/plugins/observability/public/hooks/use_is_rule_editable.test.ts b/x-pack/plugins/observability/public/hooks/use_is_rule_editable.test.ts index 5c52c266faa5f..599fc3bfe6e83 100644 --- a/x-pack/plugins/observability/public/hooks/use_is_rule_editable.test.ts +++ b/x-pack/plugins/observability/public/hooks/use_is_rule_editable.test.ts @@ -9,8 +9,7 @@ import { Capabilities } from '@kbn/core-capabilities-common'; import { Rule, RuleAction, RuleType, RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public'; import { RecursiveReadonly } from '@kbn/utility-types'; -import { useIsRuleEditable } from './use_is_rule_editable'; -import { IsRuleEditableProps } from '../pages/rule_details/helpers/utils'; +import { useIsRuleEditable, UseIsRuleEditableProps } from './use_is_rule_editable'; import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; const mockConsumer = 'mock-consumerId'; @@ -23,7 +22,7 @@ ruleTypeRegistry.register({ actions: [], } as unknown as RuleTypeModel); -const renderUseIsRuleEditableHook = (props: IsRuleEditableProps) => { +const renderUseIsRuleEditableHook = (props: UseIsRuleEditableProps) => { return renderHook(() => useIsRuleEditable({ ...props })); }; diff --git a/x-pack/plugins/observability/public/hooks/use_is_rule_editable.ts b/x-pack/plugins/observability/public/hooks/use_is_rule_editable.ts index 459f2b50c8986..e5c053d742b2c 100644 --- a/x-pack/plugins/observability/public/hooks/use_is_rule_editable.ts +++ b/x-pack/plugins/observability/public/hooks/use_is_rule_editable.ts @@ -5,17 +5,46 @@ * 2.0. */ -import { useMemo } from 'react'; -import { isRuleEditable, IsRuleEditableProps } from '../pages/rule_details/helpers/utils'; +import { Capabilities } from '@kbn/core-capabilities-common'; +import { Rule, RuleType, RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public'; +import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import { RecursiveReadonly } from '@kbn/utility-types'; + +export interface UseIsRuleEditableProps { + capabilities: RecursiveReadonly; + rule: Rule | undefined; + ruleType: RuleType | undefined; + ruleTypeRegistry: TypeRegistry>; +} export function useIsRuleEditable({ capabilities, rule, ruleType, ruleTypeRegistry, -}: IsRuleEditableProps): boolean { - return useMemo( - () => isRuleEditable({ capabilities, rule, ruleType, ruleTypeRegistry }), - [capabilities, rule, ruleType, ruleTypeRegistry] - ); +}: UseIsRuleEditableProps): boolean { + if (!rule) { + return false; + } + + // If the authorized consumers object of the rule type does not contain the rule + // being passed, the rule is not editable + if (!ruleType?.authorizedConsumers[rule.consumer]?.all) { + return false; + } + + // If there are no capabilities to execute actions and the rule has 1 or more + // actions to perform, the rule is not editable. + if (!capabilities.actions?.execute && rule.actions.length !== 0) { + return false; + } + + try { + // If the rule has been registered in the ruleTypeRegistry and requiresAppContext + // is set to false, it means the rule is editable. + // Wrapped in try-catch as ruleTypeRegistry will throw an Error if the rule is not registered + return ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext === false; + } catch (e) { + return false; + } } diff --git a/x-pack/plugins/observability/public/pages/rule_details/helpers/utils.ts b/x-pack/plugins/observability/public/pages/rule_details/helpers/utils.ts index 7dad805b4cf44..09776a40c8e6f 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/helpers/utils.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/helpers/utils.ts @@ -9,10 +9,7 @@ import { RuleExecutionStatusErrorReasons, RuleExecutionStatuses, } from '@kbn/alerting-plugin/common'; -import { Capabilities } from '@kbn/core-capabilities-common'; -import { Rule, RuleType, RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public'; -import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; -import { RecursiveReadonly } from '@kbn/utility-types'; +import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; import { ALERT_STATUS_LICENSE_ERROR, rulesStatusesTranslationsMapping } from '../translations'; @@ -38,39 +35,3 @@ export function getStatusMessage(rule: Rule): string { ? rulesStatusesTranslationsMapping[rule.executionStatus.status] : ''; } - -function hasAllPrivilege(rule: Rule, ruleType?: RuleType): boolean { - return ruleType?.authorizedConsumers[rule.consumer]?.all ?? false; -} - -export interface IsRuleEditableProps { - capabilities: RecursiveReadonly; - rule: Rule | undefined; - ruleType: RuleType | undefined; - ruleTypeRegistry: TypeRegistry>; -} - -export function isRuleEditable({ - capabilities, - rule, - ruleType, - ruleTypeRegistry, -}: IsRuleEditableProps) { - if (!rule) { - return false; - } - - if (!hasAllPrivilege(rule, ruleType)) { - return false; - } - - if (!capabilities.actions?.execute && rule.actions.length !== 0) { - return false; - } - - try { - return ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext === false; - } catch (e) { - return false; - } -}