diff --git a/x-pack/plugins/observability/public/application/types.ts b/x-pack/plugins/observability/public/application/types.ts index 09c5de1e694c..0bf3fd1ea5a0 100644 --- a/x-pack/plugins/observability/public/application/types.ts +++ b/x-pack/plugins/observability/public/application/types.ts @@ -20,6 +20,7 @@ import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public' import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { LensPublicStart } from '../../../lens/public'; import { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public'; +import { CasesUiStart } from '../../../cases/public'; export interface ObservabilityAppServices { http: HttpStart; @@ -36,4 +37,5 @@ export interface ObservabilityAppServices { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; lens: LensPublicStart; + cases: CasesUiStart; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx new file mode 100644 index 000000000000..619ea0d21ae1 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 { render } from '../rtl_helpers'; +import { fireEvent } from '@testing-library/dom'; +import { AddToCaseAction } from './add_to_case_action'; + +describe('AddToCaseAction', function () { + it('should render properly', async function () { + const { findByText } = render( + + ); + expect(await findByText('Add to case')).toBeInTheDocument(); + }); + + it('should be able to click add to case button', async function () { + const initSeries = { + data: { + 'uptime-pings-histogram': { + dataType: 'synthetics' as const, + reportType: 'kpi-over-time' as const, + breakdown: 'monitor.status', + time: { from: 'now-15m', to: 'now' }, + }, + }, + }; + + const { findByText, core } = render( + , + { initSeries } + ); + fireEvent.click(await findByText('Add to case')); + + expect(core?.cases?.getAllCasesSelectorModal).toHaveBeenCalledTimes(1); + expect(core?.cases?.getAllCasesSelectorModal).toHaveBeenCalledWith( + expect.objectContaining({ + createCaseNavigation: expect.objectContaining({ href: '/app/observability/cases/create' }), + owner: ['observability'], + userCanCrud: true, + }) + ); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx new file mode 100644 index 000000000000..4fa8deb2700d --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx @@ -0,0 +1,89 @@ +/* + * 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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback } from 'react'; +import { toMountPoint, useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { ObservabilityAppServices } from '../../../../application/types'; +import { AllCasesSelectorModalProps } from '../../../../../../cases/public'; +import { TypedLensByValueInput } from '../../../../../../lens/public'; +import { useAddToCase } from '../hooks/use_add_to_case'; +import { Case, SubCase } from '../../../../../../cases/common'; +import { observabilityFeatureId } from '../../../../../common'; + +export interface AddToCaseProps { + timeRange: { from: string; to: string }; + lensAttributes: TypedLensByValueInput['attributes'] | null; +} + +export function AddToCaseAction({ lensAttributes, timeRange }: AddToCaseProps) { + const kServices = useKibana().services; + + const { cases, http } = kServices; + + const getToastText = useCallback( + (theCase) => toMountPoint(), + [http.basePath] + ); + + const { createCaseUrl, goToCreateCase, onCaseClicked, isCasesOpen, setIsCasesOpen, isSaving } = + useAddToCase({ + lensAttributes, + getToastText, + timeRange, + }); + + const getAllCasesSelectorModalProps: AllCasesSelectorModalProps = { + createCaseNavigation: { + href: createCaseUrl, + onClick: goToCreateCase, + }, + onRowClick: onCaseClicked, + userCanCrud: true, + owner: [observabilityFeatureId], + onClose: () => { + setIsCasesOpen(false); + }, + }; + + return ( + <> + { + if (lensAttributes) { + setIsCasesOpen(true); + } + }} + > + {i18n.translate('xpack.observability.expView.heading.addToCase', { + defaultMessage: 'Add to case', + })} + + {isCasesOpen && + lensAttributes && + cases.getAllCasesSelectorModal(getAllCasesSelectorModalProps)} + + ); +} + +function CaseToastText({ theCase, basePath }: { theCase: Case | SubCase; basePath: string }) { + return ( + + + + {i18n.translate('xpack.observability.expView.heading.addToCase.notification.viewCase', { + defaultMessage: 'View case', + })} + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 706ba546b284..7adef4779ea9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -14,6 +14,7 @@ import { DataViewLabels } from '../configurations/constants'; import { ObservabilityAppServices } from '../../../../application/types'; import { useSeriesStorage } from '../hooks/use_series_storage'; import { combineTimeRanges } from '../exploratory_view'; +import { AddToCaseAction } from './add_to_case_action'; interface Props { seriesId: string; @@ -56,6 +57,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { + + + { + setData(result); + }, [result]); + + return ( + + Add new case + result.onCaseClicked({ id: 'test' } as any)}> + On case click + + + ); + } + + const renderSetup = render(); + + return { setData, ...renderSetup }; + } + it('should return expected result', async function () { + const { setData, core, findByText } = setupTestComponent(); + + expect(setData).toHaveBeenLastCalledWith({ + createCaseUrl: '/app/observability/cases/create', + goToCreateCase: expect.any(Function), + isCasesOpen: false, + isSaving: false, + onCaseClicked: expect.any(Function), + setIsCasesOpen: expect.any(Function), + }); + fireEvent.click(await findByText('Add new case')); + + expect(core.application?.navigateToApp).toHaveBeenCalledTimes(1); + expect(core.application?.navigateToApp).toHaveBeenCalledWith('observability', { + path: '/cases/create', + }); + + fireEvent.click(await findByText('On case click')); + + expect(core.http?.post).toHaveBeenCalledTimes(1); + expect(core.http?.post).toHaveBeenCalledWith('/api/cases/test/comments', { + body: '{"comment":"!{lens{\\"attributes\\":{\\"title\\":\\"Test lens attributes\\"},\\"timeRange\\":{\\"to\\":\\"now\\",\\"from\\":\\"now-5m\\"}}}","type":"user","owner":"observability"}', + }); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts new file mode 100644 index 000000000000..5ec9e1d4ab4b --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts @@ -0,0 +1,128 @@ +/* + * 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 { useCallback, useMemo, useState } from 'react'; +import { isEmpty } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { HttpSetup, MountPoint } from 'kibana/public'; +import { useKibana } from '../../../../utils/kibana_react'; +import { Case, SubCase } from '../../../../../../cases/common'; +import { TypedLensByValueInput } from '../../../../../../lens/public'; +import { AddToCaseProps } from '../header/add_to_case_action'; +import { observabilityFeatureId } from '../../../../../common'; + +const appendSearch = (search?: string) => + isEmpty(search) ? '' : `${search?.startsWith('?') ? search : `?${search}`}`; + +const getCreateCaseUrl = (search?: string | null) => + `/cases/create${appendSearch(search ?? undefined)}`; + +async function addToCase( + http: HttpSetup, + theCase: Case | SubCase, + attributes: TypedLensByValueInput['attributes'], + timeRange: { from: string; to: string } +) { + const apiPath = `/api/cases/${theCase?.id}/comments`; + + const vizPayload = { + attributes, + timeRange, + }; + + const payload = { + comment: `!{lens${JSON.stringify(vizPayload)}}`, + type: 'user', + owner: observabilityFeatureId, + }; + + return http.post(apiPath, { body: JSON.stringify(payload) }); +} + +export const useAddToCase = ({ + lensAttributes, + getToastText, + timeRange, +}: AddToCaseProps & { getToastText: (thaCase: Case | SubCase) => MountPoint }) => { + const [isSaving, setIsSaving] = useState(false); + const [isCasesOpen, setIsCasesOpen] = useState(false); + + const { + http, + application: { navigateToApp, getUrlForApp }, + notifications: { toasts }, + } = useKibana().services; + + const createCaseUrl = useMemo( + () => getUrlForApp(observabilityFeatureId) + getCreateCaseUrl(), + [getUrlForApp] + ); + + const goToCreateCase = useCallback( + async (ev) => { + ev.preventDefault(); + return navigateToApp(observabilityFeatureId, { + path: getCreateCaseUrl(), + }); + }, + [navigateToApp] + ); + + const onCaseClicked = useCallback( + (theCase?: Case | SubCase) => { + if (theCase && lensAttributes) { + setIsCasesOpen(false); + setIsSaving(true); + addToCase(http, theCase, lensAttributes, timeRange).then( + () => { + setIsSaving(false); + toasts.addSuccess( + { + title: i18n.translate( + 'xpack.observability.expView.heading.addToCase.notification', + { + defaultMessage: 'Successfully added visualization to the case: {caseTitle}', + values: { caseTitle: theCase.title }, + } + ), + text: getToastText(theCase), + }, + { + toastLifeTimeMs: 10000, + } + ); + }, + (error) => { + toasts.addError(error, { + title: i18n.translate( + 'xpack.observability.expView.heading.addToCase.notification.error', + { + defaultMessage: 'Failed to add visualization to the selected case.', + } + ), + }); + } + ); + } else { + navigateToApp(observabilityFeatureId, { + path: getCreateCaseUrl(), + openInNewTab: true, + }); + } + }, + [getToastText, http, lensAttributes, navigateToApp, timeRange, toasts] + ); + + return { + createCaseUrl, + goToCreateCase, + onCaseClicked, + isSaving, + isCasesOpen, + setIsCasesOpen, + }; +}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx index bc77a0520925..a577a8df3e3d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -39,6 +39,7 @@ import { createStubIndexPattern } from '../../../../../../../src/plugins/data/co import { AppDataType, UrlFilter } from './types'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { ListItem } from '../../../hooks/use_values_list'; +import { casesPluginMock } from '../../../../../cases/public/mocks'; interface KibanaProps { services?: KibanaServices; @@ -118,6 +119,7 @@ export const mockCore: () => Partial