diff --git a/x-pack/plugins/observability/public/components/slo/feedback_button/feedback_button.tsx b/x-pack/plugins/observability/public/components/slo/feedback_button/feedback_button.tsx index f72e4b008f390..dbd45bf74dee4 100644 --- a/x-pack/plugins/observability/public/components/slo/feedback_button/feedback_button.tsx +++ b/x-pack/plugins/observability/public/components/slo/feedback_button/feedback_button.tsx @@ -5,16 +5,21 @@ * 2.0. */ +import React from 'react'; import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; const SLO_FEEDBACK_LINK = 'https://ela.st/slo-feedback'; -export function FeedbackButton() { +interface Props { + disabled?: boolean; +} + +export function FeedbackButton({ disabled }: Props) { return ( ().services; const { hasWriteCapabilities } = useCapabilities(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isRuleFlyoutVisible, setRuleFlyoutVisibility] = useState(false); + const [isDeleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = useState(false); + + const { mutateAsync: cloneSlo } = useCloneSlo(); + const isDeleting = Boolean(useIsMutating(['deleteSlo', slo?.id])); const handleActionsClick = () => setIsPopoverOpen((value) => !value); const closePopover = () => setIsPopoverOpen(false); @@ -85,6 +98,40 @@ export function HeaderControl({ isLoading, slo }: Props) { } }; + const handleClone = async () => { + if (slo) { + setIsPopoverOpen(false); + + const newSlo = transformValuesToCreateSLOInput( + transformSloResponseToCreateSloInput({ ...slo, name: `[Copy] ${slo.name}` })! + ); + + await cloneSlo({ slo: newSlo, idToCopyFrom: slo.id }); + + toasts.addSuccess( + i18n.translate('xpack.observability.slo.sloDetails.headerControl.cloneSuccess', { + defaultMessage: 'Successfully created {name}', + values: { name: newSlo.name }, + }) + ); + + navigateToUrl(basePath.prepend(paths.observability.slos)); + } + }; + + const handleDelete = () => { + setDeleteConfirmationModalOpen(true); + setIsPopoverOpen(false); + }; + + const handleDeleteCancel = () => { + setDeleteConfirmationModalOpen(false); + }; + + const handleDeleteSuccess = () => { + navigateToUrl(basePath.prepend(paths.observability.slos)); + }; + return ( <> {i18n.translate('xpack.observability.slo.sloDetails.headerControl.actions', { defaultMessage: 'Actions', @@ -108,11 +155,12 @@ export function HeaderControl({ isLoading, slo }: Props) { closePopover={closePopover} > @@ -123,6 +171,7 @@ export function HeaderControl({ isLoading, slo }: Props) { @@ -136,6 +185,7 @@ export function HeaderControl({ isLoading, slo }: Props) { @@ -143,24 +193,50 @@ export function HeaderControl({ isLoading, slo }: Props) { defaultMessage: 'Manage rules', })} , - ].concat( - !!slo && isApmIndicatorType(slo.indicator.type) - ? [ - - {i18n.translate( - 'xpack.observability.slos.sloDetails.headerControl.exploreInApm', - { - defaultMessage: 'Explore in APM', - } - )} - , - ] - : [] - )} + ] + .concat( + !!slo && isApmIndicatorType(slo.indicator.type) ? ( + + {i18n.translate( + 'xpack.observability.slos.sloDetails.headerControl.exploreInApm', + { + defaultMessage: 'Service details', + } + )} + + ) : ( + [] + ) + ) + .concat( + + {i18n.translate('xpack.observability.slo.slo.item.actions.clone', { + defaultMessage: 'Clone', + })} + , + + {i18n.translate('xpack.observability.slo.slo.item.actions.delete', { + defaultMessage: 'Delete', + })} + + )} /> @@ -173,6 +249,14 @@ export function HeaderControl({ isLoading, slo }: Props) { initialValues={{ name: `${slo.name} burn rate`, params: { sloId: slo.id } }} /> ) : null} + + {slo && isDeleteConfirmationModalOpen ? ( + + ) : null} ); } diff --git a/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx b/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx index 4028b80203591..32f4670e3e840 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx @@ -60,6 +60,12 @@ const mockKibana = () => { prepend: mockBasePathPrepend, }, }, + notifications: { + toasts: { + addSuccess: jest.fn(), + addError: jest.fn(), + }, + }, triggersActionsUi: { getAddRuleFlyout: jest.fn(() => (
mocked component
@@ -182,6 +188,45 @@ describe('SLO Details Page', () => { expect(screen.queryByTestId('sloDetailsHeaderControlPopoverCreateRule')).toBeTruthy(); }); + it("renders a 'Manage rules' button under actions menu", async () => { + const slo = buildSlo(); + useParamsMock.mockReturnValue(slo.id); + useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo }); + useLicenseMock.mockReturnValue({ hasAtLeast: () => true }); + + render(); + + fireEvent.click(screen.getByTestId('o11yHeaderControlActionsButton')); + expect(screen.queryByTestId('sloDetailsHeaderControlPopoverManageRules')).toBeTruthy(); + }); + + it("renders a 'Clone' button under actions menu", async () => { + const slo = buildSlo(); + useParamsMock.mockReturnValue(slo.id); + useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo }); + useLicenseMock.mockReturnValue({ hasAtLeast: () => true }); + + render(); + + fireEvent.click(screen.getByTestId('o11yHeaderControlActionsButton')); + expect(screen.queryByTestId('sloDetailsHeaderControlPopoverClone')).toBeTruthy(); + }); + + it("renders a 'Delete' button under actions menu", async () => { + const slo = buildSlo(); + useParamsMock.mockReturnValue(slo.id); + useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo }); + useLicenseMock.mockReturnValue({ hasAtLeast: () => true }); + + render(); + + fireEvent.click(screen.getByTestId('o11yHeaderControlActionsButton')); + expect(screen.queryByTestId('sloDetailsHeaderControlPopoverDelete')).toBeTruthy(); + + const manageRulesButton = screen.queryByTestId('sloDetailsHeaderControlPopoverManageRules'); + expect(manageRulesButton).toBeTruthy(); + }); + it('renders the Overview tab by default', async () => { const slo = buildSlo(); useParamsMock.mockReturnValue(slo.id); diff --git a/x-pack/plugins/observability/public/pages/slo_details/slo_details.tsx b/x-pack/plugins/observability/public/pages/slo_details/slo_details.tsx index 48401ca29f94e..29a7f573e3c14 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/slo_details.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/slo_details.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useIsMutating } from '@tanstack/react-query'; import { EuiBreadcrumbProps } from '@elastic/eui/src/components/breadcrumbs/breadcrumb'; import { EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -44,6 +45,8 @@ export function SloDetailsPage() { const { isLoading, slo } = useFetchSloDetails({ sloId, shouldRefetch: isAutoRefreshing }); + const isCloningOrDeleting = Boolean(useIsMutating()); + useBreadcrumbs(getBreadcrumbs(basePath, slo)); const isSloNotFound = !isLoading && slo === undefined; @@ -55,6 +58,8 @@ export function SloDetailsPage() { navigateToUrl(basePath.prepend(paths.observability.slos)); } + const isPerformingAction = isLoading || isCloningOrDeleting; + const handleToggleAutoRefresh = () => { setIsAutoRefreshing(!isAutoRefreshing); }; @@ -62,15 +67,15 @@ export function SloDetailsPage() { return ( , + pageTitle: , rightSideItems: [ - , + , , - , + , ], bottomBorder: false, }} diff --git a/x-pack/plugins/observability/public/pages/slos/components/badges/slo_indicator_type_badge.tsx b/x-pack/plugins/observability/public/pages/slos/components/badges/slo_indicator_type_badge.tsx index 87f005e2fa5c0..ad73af3d73bfa 100644 --- a/x-pack/plugins/observability/public/pages/slos/components/badges/slo_indicator_type_badge.tsx +++ b/x-pack/plugins/observability/public/pages/slos/components/badges/slo_indicator_type_badge.tsx @@ -63,7 +63,7 @@ export function SloIndicatorTypeBadge({ slo }: Props) { @@ -73,7 +73,7 @@ export function SloIndicatorTypeBadge({ slo }: Props) { onClickAriaLabel={i18n.translate( 'xpack.observability.slo.indicatorTypeBadge.exploreInApm', { - defaultMessage: 'Explore {service} in APM', + defaultMessage: 'View {service} details', values: { service: slo.indicator.params.service }, } )} diff --git a/x-pack/plugins/observability/public/pages/slos/components/slo_delete_confirmation_modal.tsx b/x-pack/plugins/observability/public/pages/slos/components/slo_delete_confirmation_modal.tsx index 4a292bef8c14b..b1b2d341a9bd8 100644 --- a/x-pack/plugins/observability/public/pages/slos/components/slo_delete_confirmation_modal.tsx +++ b/x-pack/plugins/observability/public/pages/slos/components/slo_delete_confirmation_modal.tsx @@ -15,11 +15,13 @@ import { useDeleteSlo } from '../../../hooks/slo/use_delete_slo'; export interface SloDeleteConfirmationModalProps { slo: SLOWithSummaryResponse; onCancel: () => void; + onSuccess?: () => void; } export function SloDeleteConfirmationModal({ slo: { id, name }, onCancel, + onSuccess, }: SloDeleteConfirmationModalProps) { const { notifications: { toasts }, @@ -31,6 +33,7 @@ export function SloDeleteConfirmationModal({ if (isSuccess) { toasts.addSuccess(getDeleteSuccesfulMessage(name)); + onSuccess?.(); } if (isError) { diff --git a/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx b/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx index 049481486eb9e..9552875de4e25 100644 --- a/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx +++ b/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx @@ -175,12 +175,12 @@ export function SloListItem({ onClick={handleClickActions} /> } - panelPaddingSize="none" + panelPaddingSize="m" closePopover={handleClickActions} isOpen={isActionsPopoverOpen} > {i18n.translate('xpack.observability.slo.slo.item.actions.createRule', { - defaultMessage: 'Create new Alert rule', + defaultMessage: 'Create new alert rule', })} ,