Skip to content

Commit

Permalink
[Cases] Use absolute time ranges when attaching a visualization to a …
Browse files Browse the repository at this point in the history
…case (elastic#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
  • Loading branch information
cnasikas authored Jul 30, 2024
1 parent a54be04 commit c6548c8
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
})}}`,
{
Expand All @@ -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,
})}}`
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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$ = () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(),
Expand All @@ -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: {
Expand All @@ -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',
Expand All @@ -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',
},
]);
});
});
});
Loading

0 comments on commit c6548c8

Please sign in to comment.