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