[Cases] Use absolute time ranges when attaching a visualization to a case (#189168)

## Summary

Users can add visualizations to cases. If the user used a relative time
range it will be added to the case with the relative time range. This
can be problematic because, in an investigation of an incident, the
visualization should reflect the data at the time of discovery. If time
passes the visualization will query different data and the finding will
be lost. This PR fixes this issue and uses an absolute time range when
adding the visualization to the case.

### Testing

1. Attach a visualization from a dashboard with a relative time range.
Verify that the absolute time range persisted.
2. Attach a visualization from a dashboard with an absolute time range.
Verify that the absolute time range remains affected.
3. Attach a visualization from a dashboard with a relative time range.
Open the visualization and verify that the absolute time range is set.

<img width="1726" alt="Screenshot 2024-07-25 at 12 49 28 PM"
src="https://github.com/user-attachments/assets/f3057d87-e860-4bc9-8f01-876dbf1078f9">

4. Attach a visualization from the markdown with a relative time range.
Verify that the absolute time range persisted.
5. Attach a visualization from the markdown with an absolute time range.
Verify that the absolute time range remains affected.
6. Attach a visualization from the markdown with a relative time range.
Edit the visualization and verify that the absolute time range is set.
7. Attach a visualization from the markdown with a relative time range.
Edit the visualization, change the time range to a relative one, and
save it to the case. Verify that the absolute time range is set to the
case.

<img width="1082" alt="Screenshot 2024-07-25 at 1 01 22 PM"
src="https://github.com/user-attachments/assets/a753d918-391f-40ad-90a4-4d12843bab43">


### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

## Release notes
Use absolute time ranges when adding visualizations to a case
This commit is contained in:
Christos Nasikas 2024-07-30 09:33:27 +03:00 committed by GitHub
parent a54be04871
commit c6548c806d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 213 additions and 49 deletions

View file

@ -35,6 +35,7 @@ import { CommentEditorContext } from '../../context';
import { useLensDraftComment } from './use_lens_draft_comment';
import { VISUALIZATION } from './translations';
import { useIsMainApplication } from '../../../../common/hooks';
import { convertToAbsoluteTimeRange } from '../../../visualizations/actions/convert_to_absolute_time_range';
const DEFAULT_TIMERANGE = {
from: 'now-7d',
@ -86,10 +87,10 @@ const LensEditorComponent: LensEuiMarkdownEditorUiPlugin['editor'] = ({
}, [clearDraftComment, currentAppId, embeddable, onCancel]);
const handleAdd = useCallback(
(attributes, timerange) => {
(attributes, timeRange) => {
onSave(
`!{${ID}${JSON.stringify({
timeRange: timerange,
timeRange: convertToAbsoluteTimeRange(timeRange),
attributes,
})}}`,
{
@ -103,11 +104,11 @@ const LensEditorComponent: LensEuiMarkdownEditorUiPlugin['editor'] = ({
);
const handleUpdate = useCallback(
(attributes, timerange, position) => {
(attributes, timeRange, position) => {
markdownContext.replaceNode(
position,
`!{${ID}${JSON.stringify({
timeRange: timerange,
timeRange: convertToAbsoluteTimeRange(timeRange),
attributes,
})}}`
);

View file

@ -0,0 +1,27 @@
/*
* 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 sinon from 'sinon';
import { convertToAbsoluteTimeRange } from './convert_to_absolute_time_range';
describe('convertToAbsoluteDateRange', () => {
test('should not change absolute time range', () => {
const from = '2024-01-01T00:00:00.000Z';
const to = '2024-02-01T00:00:00.000Z';
expect(convertToAbsoluteTimeRange({ from, to })).toEqual({ from, to });
});
it('converts a relative day correctly', () => {
const from = '2024-01-01T00:00:00.000Z';
const clock = sinon.useFakeTimers(new Date(from));
const to = new Date(clock.now).toISOString();
expect(convertToAbsoluteTimeRange({ from, to: 'now' })).toEqual({ from, to });
clock.restore();
});
});

View file

@ -0,0 +1,28 @@
/*
* 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 type { TimeRange } from '@kbn/data-plugin/common';
import { getAbsoluteTimeRange } from '@kbn/data-plugin/common';
export const convertToAbsoluteTimeRange = (timeRange?: TimeRange): TimeRange | undefined => {
if (!timeRange) {
return;
}
const absRange = getAbsoluteTimeRange(
{
from: timeRange.from,
to: timeRange.to,
},
{ forceNow: new Date() }
);
return {
from: absRange.from,
to: absRange.to,
};
};

View file

@ -9,7 +9,7 @@ import { BehaviorSubject } from 'rxjs';
import { coreMock } from '@kbn/core/public/mocks';
import { isCompatible } from './is_compatible';
import { canUseCases } from '../../../client/helpers/can_use_cases';
import { mockLensApi } from './mocks';
import { getMockLensApi } from './mocks';
jest.mock('../../../../common/utils/owner', () => ({
getCaseOwnerByAppId: () => 'securitySolution',
@ -37,7 +37,7 @@ describe('isCompatible', () => {
test('should return false if error embeddable', async () => {
const errorApi = {
...mockLensApi,
...getMockLensApi(),
blockingError: new BehaviorSubject<Error | undefined>(new Error('Simulated blocking error')),
};
expect(isCompatible(errorApi, appId, mockCoreStart)).toBe(false);
@ -49,10 +49,10 @@ describe('isCompatible', () => {
test('should return false if no permission', async () => {
mockCasePermissions.mockReturnValue({ create: false, update: false });
expect(isCompatible(mockLensApi, appId, mockCoreStart)).toBe(false);
expect(isCompatible(getMockLensApi(), appId, mockCoreStart)).toBe(false);
});
test('should return true if is lens embeddable', async () => {
expect(isCompatible(mockLensApi, appId, mockCoreStart)).toBe(true);
expect(isCompatible(getMockLensApi(), appId, mockCoreStart)).toBe(true);
});
});

View file

@ -36,24 +36,27 @@ export const mockLensAttributes = {
},
} as unknown as LensSavedObjectAttributes;
export const mockLensApi = {
type: 'lens',
getSavedVis: () => {},
canViewUnderlyingData: () => {},
getViewUnderlyingDataArgs: () => {},
getFullAttributes: () => {
return mockLensAttributes;
},
panelTitle: new BehaviorSubject('myPanel'),
hidePanelTitle: new BehaviorSubject('false'),
timeslice$: new BehaviorSubject<[number, number] | undefined>(undefined),
timeRange$: new BehaviorSubject<TimeRange | undefined>({
from: 'now-24h',
to: 'now',
}),
filters$: new BehaviorSubject<Filter[] | undefined>(undefined),
query$: new BehaviorSubject<Query | AggregateQuery | undefined>(undefined),
} as unknown as LensApi;
export const getMockLensApi = (
{ from, to = 'now' }: { from: string; to: string } = { from: 'now-24h', to: 'now' }
): LensApi =>
({
type: 'lens',
getSavedVis: () => {},
canViewUnderlyingData: () => {},
getViewUnderlyingDataArgs: () => {},
getFullAttributes: () => {
return mockLensAttributes;
},
panelTitle: new BehaviorSubject('myPanel'),
hidePanelTitle: new BehaviorSubject('false'),
timeslice$: new BehaviorSubject<[number, number] | undefined>(undefined),
timeRange$: new BehaviorSubject<TimeRange | undefined>({
from,
to,
}),
filters$: new BehaviorSubject<Filter[] | undefined>(undefined),
query$: new BehaviorSubject<Query | AggregateQuery | undefined>(undefined),
} as unknown as LensApi);
export const getMockCurrentAppId$ = () => new BehaviorSubject<string>('securitySolutionUI');
export const getMockApplications$ = () =>

View file

@ -5,15 +5,14 @@
* 2.0.
*/
import ReactDOM, { unmountComponentAtNode } from 'react-dom';
import { unmountComponentAtNode } from 'react-dom';
import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
import type { PropsWithChildren } from 'react';
import React from 'react';
import { toMountPoint } from '@kbn/react-kibana-mount';
import {
getMockApplications$,
getMockCurrentAppId$,
mockLensApi,
getMockLensApi,
mockLensAttributes,
getMockServices,
} from './mocks';
@ -35,10 +34,6 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({
.mockImplementation(({ children }: PropsWithChildren<unknown>) => <>{children}</>),
}));
jest.mock('@kbn/react-kibana-mount', () => ({
toMountPoint: jest.fn(),
}));
jest.mock('../../../common/lib/kibana', () => {
return {
useKibana: jest.fn(),
@ -58,11 +53,24 @@ jest.mock('./action_wrapper');
describe('openModal', () => {
const mockUseCasesAddToExistingCaseModal = useCasesAddToExistingCaseModal as jest.Mock;
const mockOpenModal = jest.fn();
const mockMount = jest.fn();
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2024-01-01T00:00:00.000Z') });
});
afterEach(() => {
jest.clearAllTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
mockUseCasesAddToExistingCaseModal.mockReturnValue({
open: mockOpenModal,
});
(useKibana as jest.Mock).mockReturnValue({
services: {
application: {
@ -71,26 +79,31 @@ describe('openModal', () => {
},
},
});
(toMountPoint as jest.Mock).mockImplementation((node) => {
ReactDOM.render(node, element);
return mockMount;
});
jest.clearAllMocks();
openModal(mockLensApi, 'myAppId', {} as unknown as CasesActionContextProps, getMockServices());
});
test('should open modal with an attachment', async () => {
it('should open modal with an attachment with the time range as relative values', async () => {
openModal(
getMockLensApi(),
'myAppId',
{} as unknown as CasesActionContextProps,
getMockServices()
);
await waitFor(() => {
expect(mockOpenModal).toHaveBeenCalled();
const getAttachments = mockOpenModal.mock.calls[0][0].getAttachments;
expect(getAttachments()).toEqual([
const res = getAttachments();
expect(res).toEqual([
{
persistableStateAttachmentState: {
attributes: mockLensAttributes,
timeRange: {
from: 'now-24h',
to: 'now',
from: '2023-12-31T00:00:00.000Z',
to: '2024-01-01T00:00:00.000Z',
},
},
persistableStateAttachmentTypeId: '.lens',
@ -100,27 +113,115 @@ describe('openModal', () => {
});
});
test('should have correct onClose handler - when close modal clicked', () => {
it('should have correct onClose handler - when close modal clicked', () => {
openModal(
getMockLensApi(),
'myAppId',
{} as unknown as CasesActionContextProps,
getMockServices()
);
const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
onClose();
expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
});
test('should have correct onClose handler - when case selected', () => {
it('should have correct onClose handler - when case selected', () => {
openModal(
getMockLensApi(),
'myAppId',
{} as unknown as CasesActionContextProps,
getMockServices()
);
const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
onClose({ id: 'case-id', title: 'case-title' });
expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
});
test('should have correct onClose handler - when case created', () => {
it('should have correct onClose handler - when case created', () => {
openModal(
getMockLensApi(),
'myAppId',
{} as unknown as CasesActionContextProps,
getMockServices()
);
const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
onClose(null, true);
expect(unmountComponentAtNode as jest.Mock).not.toHaveBeenCalled();
});
test('should have correct onSuccess handler', () => {
it('should have correct onSuccess handler', () => {
openModal(
getMockLensApi(),
'myAppId',
{} as unknown as CasesActionContextProps,
getMockServices()
);
const onSuccess = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onSuccess;
onSuccess();
expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
});
it('should open modal with an attachment with the time range in absolute values', async () => {
openModal(
getMockLensApi({ from: '2024-01-09T00:00:00.000Z', to: '2024-01-10T00:00:00.000Z' }),
'myAppId',
{} as unknown as CasesActionContextProps,
getMockServices()
);
await waitFor(() => {
expect(mockOpenModal).toHaveBeenCalled();
const getAttachments = mockOpenModal.mock.calls[0][0].getAttachments;
const res = getAttachments();
expect(res).toEqual([
{
persistableStateAttachmentState: {
attributes: mockLensAttributes,
timeRange: {
from: '2024-01-09T00:00:00.000Z',
to: '2024-01-10T00:00:00.000Z',
},
},
persistableStateAttachmentTypeId: '.lens',
type: 'persistableState',
},
]);
});
});
it('should open modal with an attachment with the time range in absolute and relative values', async () => {
openModal(
getMockLensApi({ from: '2023-12-01T00:00:00.000Z', to: 'now' }),
'myAppId',
{} as unknown as CasesActionContextProps,
getMockServices()
);
await waitFor(() => {
expect(mockOpenModal).toHaveBeenCalled();
const getAttachments = mockOpenModal.mock.calls[0][0].getAttachments;
const res = getAttachments();
expect(res).toEqual([
{
persistableStateAttachmentState: {
attributes: mockLensAttributes,
timeRange: {
from: '2023-12-01T00:00:00.000Z',
to: '2024-01-01T00:00:00.000Z',
},
},
persistableStateAttachmentTypeId: '.lens',
type: 'persistableState',
},
]);
});
});
});

View file

@ -15,6 +15,7 @@ import type { CasesActionContextProps, Services } from './types';
import type { CaseUI } from '../../../../common';
import { getLensCaseAttachment } from './utils';
import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
import { convertToAbsoluteTimeRange } from './convert_to_absolute_time_range';
interface Props {
lensApi: LensApi;
@ -30,14 +31,17 @@ const AddExistingCaseModalWrapper: React.FC<Props> = ({ lensApi, onClose, onSucc
const timeRange = useStateFromPublishingSubject(lensApi.timeRange$);
const parentTimeRange = useStateFromPublishingSubject(lensApi.parentApi?.timeRange$);
const absoluteTimeRange = convertToAbsoluteTimeRange(timeRange);
const absoluteParentTimeRange = convertToAbsoluteTimeRange(parentTimeRange);
const attachments = useMemo(() => {
const appliedTimeRange = timeRange ?? parentTimeRange;
const appliedTimeRange = absoluteTimeRange ?? absoluteParentTimeRange;
const attributes = lensApi.getFullAttributes();
return !attributes || !appliedTimeRange
? []
: [getLensCaseAttachment({ attributes, timeRange: appliedTimeRange })];
}, [lensApi, timeRange, parentTimeRange]);
}, [lensApi, absoluteTimeRange, absoluteParentTimeRange]);
useEffect(() => {
modal.open({ getAttachments: () => attachments });
}, [attachments, modal]);