[Exploratory View] Add to case button (#112463)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Shahzad 2021-09-30 11:30:30 +02:00 committed by GitHub
parent 50134cbec6
commit bbf3d4b9ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 347 additions and 0 deletions

View file

@ -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;
}

View file

@ -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(
<AddToCaseAction
lensAttributes={{ title: 'Performance distribution' } as any}
timeRange={{ to: 'now', from: 'now-5m' }}
/>
);
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(
<AddToCaseAction
lensAttributes={{ title: 'Performance distribution' } as any}
timeRange={{ to: 'now', from: 'now-5m' }}
/>,
{ 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,
})
);
});
});

View file

@ -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<ObservabilityAppServices>().services;
const { cases, http } = kServices;
const getToastText = useCallback(
(theCase) => toMountPoint(<CaseToastText theCase={theCase} basePath={http.basePath.get()} />),
[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 (
<>
<EuiButton
isLoading={isSaving}
fullWidth={false}
isDisabled={lensAttributes === null}
onClick={() => {
if (lensAttributes) {
setIsCasesOpen(true);
}
}}
>
{i18n.translate('xpack.observability.expView.heading.addToCase', {
defaultMessage: 'Add to case',
})}
</EuiButton>
{isCasesOpen &&
lensAttributes &&
cases.getAllCasesSelectorModal(getAllCasesSelectorModalProps)}
</>
);
}
function CaseToastText({ theCase, basePath }: { theCase: Case | SubCase; basePath: string }) {
return (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem>
<EuiLink href={`${basePath}/app/observability/cases/${theCase.id}`} target="_blank">
{i18n.translate('xpack.observability.expView.heading.addToCase.notification.viewCase', {
defaultMessage: 'View case',
})}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -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) {
</h2>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddToCaseAction lensAttributes={lensAttributes} timeRange={timeRange} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
iconType="lensApp"

View file

@ -0,0 +1,68 @@
/*
* 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 { useAddToCase } from './use_add_to_case';
import React, { useEffect } from 'react';
import { render } from '../rtl_helpers';
import { EuiButton } from '@elastic/eui';
import { fireEvent } from '@testing-library/dom';
describe('useAddToCase', function () {
function setupTestComponent() {
const setData = jest.fn();
function TestComponent() {
const getToastText = jest.fn();
const result = useAddToCase({
lensAttributes: { title: 'Test lens attributes' } as any,
timeRange: { to: 'now', from: 'now-5m' },
getToastText,
});
useEffect(() => {
setData(result);
}, [result]);
return (
<span>
<EuiButton onClick={result.goToCreateCase}>Add new case</EuiButton>
<EuiButton onClick={() => result.onCaseClicked({ id: 'test' } as any)}>
On case click
</EuiButton>
</span>
);
}
const renderSetup = render(<TestComponent />);
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"}',
});
});
});

View file

@ -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<HTMLElement> }) => {
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,
};
};

View file

@ -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<CoreStart & ObservabilityPublicPluginsStart
},
lens: lensPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
cases: casesPluginMock.createStartContract(),
};
return core;