mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
a54be04871
commit
c6548c806d
7 changed files with 213 additions and 49 deletions
|
@ -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,
|
||||
})}}`
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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$ = () =>
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue