[SIEM] Import timeline fix (#65448)

* fix import timeline and clean up

fix unit tests

apply failure checker

clean up error message

fix update template

* add unit tests

* clean up common libs

* rename variables

* add unit tests

* fix types

* Fix imports

* rename file

* poc

* fix unit test

* review

* cleanup fallback values

* cleanup

* check if title exists

* fix unit test

* add unit test

* lint error

* put the flag for disableTemplate into common

* add immutiable

* fix unit

* check templateTimelineVersion only when update via import

* update template timeline via import with response

* add template filter

* add filter count

* add filter numbers

* rename

* enable pin events and note under active status

* disable comment and pinnedEvents for template timelines

* add timelineType for openTimeline

* enable note icon for template

* add timeline type for propertyLeft

* fix types

* duplicate elastic template

* update schema

* fix status check

* fix import

* add templateTimelineType

* disable note for immutable timeline

* fix unit

* fix error message

* fix update

* fix types

* rollback change

* rollback change

* fix create template timeline

* add i18n for error message

* fix unit test

* fix wording and disable delete btn for immutable timeline

* fix unit test provider

* fix types

* fix toaster

* fix notes and pins

* add i18n

* fix selected items

* set disableTemplateto true

* move templateInfo to helper

* review + imporvement

* fix review

* fix types

* fix types

Co-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>
Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2020-06-27 04:53:53 +01:00 committed by GitHub
parent 684289d6e3
commit f4e7f14ffe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
126 changed files with 4543 additions and 1372 deletions

View file

@ -158,6 +158,12 @@ export const showAllOthersBucket: string[] = [
/**
* CreateTemplateTimelineBtn
* https://github.com/elastic/kibana/pull/66613
* Remove the comment here to enable template timeline
*/
export const disableTemplate = true;
export const disableTemplate = false;
/*
* This should be set to true after https://github.com/elastic/kibana/pull/67496 is merged
*/
export const enableElasticFilter = false;

View file

@ -137,11 +137,13 @@ const SavedSortRuntimeType = runtimeTypes.partial({
export enum TimelineStatus {
active = 'active',
draft = 'draft',
immutable = 'immutable',
}
export const TimelineStatusLiteralRt = runtimeTypes.union([
runtimeTypes.literal(TimelineStatus.active),
runtimeTypes.literal(TimelineStatus.draft),
runtimeTypes.literal(TimelineStatus.immutable),
]);
const TimelineStatusLiteralWithNullRt = unionWithNullType(TimelineStatusLiteralRt);
@ -151,6 +153,29 @@ export type TimelineStatusLiteralWithNull = runtimeTypes.TypeOf<
typeof TimelineStatusLiteralWithNullRt
>;
/**
* Template timeline type
*/
export enum TemplateTimelineType {
elastic = 'elastic',
custom = 'custom',
}
export const TemplateTimelineTypeLiteralRt = runtimeTypes.union([
runtimeTypes.literal(TemplateTimelineType.elastic),
runtimeTypes.literal(TemplateTimelineType.custom),
]);
export const TemplateTimelineTypeLiteralWithNullRt = unionWithNullType(
TemplateTimelineTypeLiteralRt
);
export type TemplateTimelineTypeLiteral = runtimeTypes.TypeOf<typeof TemplateTimelineTypeLiteralRt>;
export type TemplateTimelineTypeLiteralWithNull = runtimeTypes.TypeOf<
typeof TemplateTimelineTypeLiteralWithNullRt
>;
/*
* Timeline Types
*/
@ -273,6 +298,13 @@ export const TimelineResponseType = runtimeTypes.type({
}),
});
export const TimelineErrorResponseType = runtimeTypes.type({
status_code: runtimeTypes.number,
message: runtimeTypes.string,
});
export interface TimelineErrorResponse
extends runtimeTypes.TypeOf<typeof TimelineErrorResponseType> {}
export interface TimelineResponse extends runtimeTypes.TypeOf<typeof TimelineResponseType> {}
/**

View file

@ -215,8 +215,8 @@ describe('alert actions', () => {
columnId: '@timestamp',
sortDirection: 'desc',
},
status: TimelineStatus.draft,
title: '',
status: TimelineStatus.active,
title: 'Test rule - Duplicate',
timelineType: TimelineType.default,
templateTimelineId: null,
templateTimelineVersion: null,

View file

@ -12,6 +12,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../mock';
import { createStore, State } from '../../store';
@ -35,10 +36,22 @@ jest.mock('../../lib/kibana', () => ({
describe('AddFilterToGlobalSearchBar Component', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
mockAddFilters.mockClear();
});

View file

@ -12,6 +12,7 @@ import {
apolloClientObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../mock';
import { createStore } from '../../store/store';
@ -22,10 +23,22 @@ import { State } from '../../store/types';
describe('Error Toast Dispatcher', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('rendering', () => {

View file

@ -14,6 +14,7 @@ import {
mockGlobalState,
apolloClientObservable,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../mock';
import { createStore, State } from '../../store';
@ -36,13 +37,25 @@ describe('Inspect Button', () => {
state: state.inputs,
};
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
describe('Render', () => {
beforeEach(() => {
const myState = cloneDeep(state);
myState.inputs = upsertQuery(newQuery);
store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
test('Eui Empty Button', () => {
const wrapper = mount(
@ -146,7 +159,13 @@ describe('Inspect Button', () => {
response: ['my response'],
};
myState.inputs = upsertQuery(myQuery);
store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
test('Open Inspect Modal', () => {
const wrapper = mount(

View file

@ -34,6 +34,7 @@ import {
mockGlobalState,
apolloClientObservable,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../mock';
import { State, createStore } from '../../store';
@ -55,7 +56,13 @@ describe('Stat Items Component', () => {
const theme = () => ({ eui: euiDarkVars, darkMode: true });
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
describe.each([
[

View file

@ -14,6 +14,7 @@ import {
apolloClientObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../mock';
import { createUseUiSetting$Mock } from '../../mock/kibana_react';
@ -81,11 +82,23 @@ describe('SIEM Super Date Picker', () => {
describe('#SuperDatePicker', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
jest.clearAllMocks();
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
mockUseUiSetting$.mockImplementation((key, defaultValue) => {
const useUiSetting$Mock = createUseUiSetting$Mock();

View file

@ -13,6 +13,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../mock';
import { createKibanaCoreStartMock } from '../../mock/kibana_core';
@ -156,7 +157,13 @@ const state: State = {
};
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
describe('StatefulTopN', () => {
// Suppress warnings about "react-beautiful-dnd"

View file

@ -9,10 +9,10 @@ import ApolloClient from 'apollo-client';
import { ApolloLink } from 'apollo-link';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { CoreStart } from '../../../../../../../src/core/public';
import introspectionQueryResultData from '../../../graphql/introspection.json';
import { AppFrontendLibs } from '../lib';
import { getLinks } from './helpers';
import { CoreStart } from '../../../../../../../src/core/public';
export function composeLibs(core: CoreStart): AppFrontendLibs {
const cache = new InMemoryCache({

View file

@ -13,7 +13,7 @@ import { coreMock } from '../../../../../../../src/core/public/mocks';
import { StartPlugins } from '../../../types';
import { depsStartMock } from './dependencies_start_mock';
import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils';
import { apolloClientObservable } from '../test_providers';
import { apolloClientObservable, kibanaObservable } from '../test_providers';
import { createStore, State, substateMiddlewareFactory } from '../../store';
import { alertMiddlewareFactory } from '../../../endpoint_alerts/store/middleware';
import { AppRootProvider } from './app_root_provider';
@ -58,14 +58,21 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
const middlewareSpy = createSpyMiddleware();
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage, [
substateMiddlewareFactory(
(globalState) => globalState.alertList,
alertMiddlewareFactory(coreStart, depsStart)
),
...managementMiddlewareFactory(coreStart, depsStart),
middlewareSpy.actionSpyMiddleware,
]);
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage,
[
substateMiddlewareFactory(
(globalState) => globalState.alertList,
alertMiddlewareFactory(coreStart, depsStart)
),
...managementMiddlewareFactory(coreStart, depsStart),
middlewareSpy.actionSpyMiddleware,
]
);
const MockKibanaContextProvider = createKibanaContextProviderMock();

View file

@ -26,6 +26,7 @@ import {
DEFAULT_INDEX_PATTERN,
} from '../../../common/constants';
import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core';
import { StartServices } from '../../types';
import { createSecuritySolutionStorageMock } from './mock_local_storage';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -71,6 +72,8 @@ export const createUseUiSetting$Mock = () => {
): [T, () => void] | undefined => [useUiSettingMock(key, defaultValue), jest.fn()];
};
export const createKibanaObservable$Mock = createKibanaCoreStartMock;
export const createUseKibanaMock = () => {
const core = createKibanaCoreStartMock();
const plugins = createKibanaPluginsStartMock();
@ -90,6 +93,36 @@ export const createUseKibanaMock = () => {
return () => ({ services });
};
export const createStartServices = () => {
const core = createKibanaCoreStartMock();
const plugins = createKibanaPluginsStartMock();
const security = {
authc: {
getCurrentUser: jest.fn(),
areAPIKeysEnabled: jest.fn(),
},
sessionTimeout: {
start: jest.fn(),
stop: jest.fn(),
extend: jest.fn(),
},
license: {
isEnabled: jest.fn(),
getFeatures: jest.fn(),
features$: jest.fn(),
},
__legacyCompat: { logoutUrl: 'logoutUrl', tenant: 'tenant' },
};
const services = ({
...core,
...plugins,
security,
} as unknown) as StartServices;
return services;
};
export const createWithKibanaMock = () => {
const kibana = createUseKibanaMock()();

View file

@ -19,7 +19,7 @@ import { ThemeProvider } from 'styled-components';
import { createStore, State } from '../store';
import { mockGlobalState } from './global_state';
import { createKibanaContextProviderMock } from './kibana_react';
import { createKibanaContextProviderMock, createStartServices } from './kibana_react';
import { FieldHook, useForm } from '../../shared_imports';
import { SUB_PLUGINS_REDUCER } from './utils';
import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage';
@ -38,6 +38,7 @@ export const apolloClient = new ApolloClient({
});
export const apolloClientObservable = new BehaviorSubject(apolloClient);
export const kibanaObservable = new BehaviorSubject(createStartServices());
Object.defineProperty(window, 'localStorage', {
value: localStorageMock(),
@ -49,7 +50,13 @@ const { storage } = createSecuritySolutionStorageMock();
/** A utility for wrapping children in the providers required to run most tests */
const TestProvidersComponent: React.FC<Props> = ({
children,
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage),
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
),
onDragEnd = jest.fn(),
}) => (
<I18nProvider>
@ -69,7 +76,13 @@ export const TestProviders = React.memo(TestProvidersComponent);
const TestProviderWithoutDragAndDropComponent: React.FC<Props> = ({
children,
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage),
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
),
}) => (
<I18nProvider>
<ReduxStoreProvider store={store}>{children}</ReduxStoreProvider>

View file

@ -29,6 +29,7 @@ import { AppAction } from './actions';
import { Immutable } from '../../../common/endpoint/types';
import { State } from './types';
import { Storage } from '../../../../../../src/plugins/kibana_utils/public';
import { CoreStart } from '../../../../../../src/core/public';
type ComposeType = typeof compose;
declare global {
@ -49,6 +50,7 @@ export const createStore = (
state: PreloadedState<State>,
pluginsReducer: SubPluginsInitReducer,
apolloClient: Observable<AppApolloClient>,
kibana: Observable<CoreStart>,
storage: Storage,
additionalMiddleware?: Array<Middleware<{}, State, Dispatch<AppAction | Immutable<AppAction>>>>
): Store<State, Action> => {
@ -56,6 +58,7 @@ export const createStore = (
const middlewareDependencies = {
apolloClient$: apolloClient,
kibana$: kibana,
selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector,
selectNotesByIdSelector: appSelectors.selectNotesByIdSelector,
timelineByIdSelector: timelineSelectors.timelineByIdSelector,

View file

@ -19,23 +19,22 @@ import { NetworkPluginState } from '../../network/store';
import { EndpointAlertsPluginState } from '../../endpoint_alerts';
import { ManagementPluginState } from '../../management';
export type StoreState = HostsPluginState &
NetworkPluginState &
TimelinePluginState &
EndpointAlertsPluginState &
ManagementPluginState & {
app: AppState;
dragAndDrop: DragAndDropState;
inputs: InputsState;
};
/**
* The redux `State` type for the Security App.
* We use `CombinedState` to wrap our shape because we create our reducer using `combineReducers`.
* `combineReducers` returns a type wrapped in `CombinedState`.
* `CombinedState` is required for redux to know what keys to make optional when preloaded state into a store.
*/
export type State = CombinedState<
HostsPluginState &
NetworkPluginState &
TimelinePluginState &
EndpointAlertsPluginState &
ManagementPluginState & {
app: AppState;
dragAndDrop: DragAndDropState;
inputs: InputsState;
}
>;
export type State = CombinedState<StoreState>;
export type KueryFilterQueryKind = 'kuery' | 'lucene';

View file

@ -19,6 +19,7 @@ import {
SUB_PLUGINS_REDUCER,
mockGlobalState,
apolloClientObservable,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
@ -31,7 +32,13 @@ export const alertPageTestRender = () => {
* Create a store, with the middleware disabled. We don't want side effects being created by our code in this test.
*/
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const depsStart = depsStartMock();
depsStart.data.ui.SearchBar.mockImplementation(() => <div />);

View file

@ -255,6 +255,18 @@
"description": "",
"type": { "kind": "ENUM", "name": "TimelineType", "ofType": null },
"defaultValue": null
},
{
"name": "templateTimelineType",
"description": "",
"type": { "kind": "ENUM", "name": "TemplateTimelineType", "ofType": null },
"defaultValue": null
},
{
"name": "status",
"description": "",
"type": { "kind": "ENUM", "name": "TimelineStatus", "ofType": null },
"defaultValue": null
}
],
"type": {
@ -10405,7 +10417,13 @@
"interfaces": null,
"enumValues": [
{ "name": "active", "description": "", "isDeprecated": false, "deprecationReason": null },
{ "name": "draft", "description": "", "isDeprecated": false, "deprecationReason": null }
{ "name": "draft", "description": "", "isDeprecated": false, "deprecationReason": null },
{
"name": "immutable",
"description": "",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
@ -10529,6 +10547,24 @@
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "TemplateTimelineType",
"description": "",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "elastic",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{ "name": "custom", "description": "", "isDeprecated": false, "deprecationReason": null }
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ResponseTimelines",
@ -10557,6 +10593,46 @@
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "defaultTimelineCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "templateTimelineCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "elasticTemplateTimelineCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "customTemplateTimelineCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "favoriteCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,

View file

@ -345,6 +345,7 @@ export enum TlsFields {
export enum TimelineStatus {
active = 'active',
draft = 'draft',
immutable = 'immutable',
}
export enum TimelineType {
@ -359,6 +360,11 @@ export enum SortFieldTimeline {
created = 'created',
}
export enum TemplateTimelineType {
elastic = 'elastic',
custom = 'custom',
}
export enum NetworkDirectionEcs {
inbound = 'inbound',
outbound = 'outbound',
@ -2117,6 +2123,16 @@ export interface ResponseTimelines {
timeline: (Maybe<TimelineResult>)[];
totalCount?: Maybe<number>;
defaultTimelineCount?: Maybe<number>;
templateTimelineCount?: Maybe<number>;
elasticTemplateTimelineCount?: Maybe<number>;
customTemplateTimelineCount?: Maybe<number>;
favoriteCount?: Maybe<number>;
}
export interface Mutation {
@ -2254,6 +2270,10 @@ export interface GetAllTimelineQueryArgs {
onlyUserFavorite?: Maybe<boolean>;
timelineType?: Maybe<TimelineType>;
templateTimelineType?: Maybe<TemplateTimelineType>;
status?: Maybe<TimelineStatus>;
}
export interface AuthenticationsSourceArgs {
timerange: TimerangeInput;
@ -4315,6 +4335,8 @@ export namespace GetAllTimeline {
sort?: Maybe<SortTimeline>;
onlyUserFavorite?: Maybe<boolean>;
timelineType?: Maybe<TimelineType>;
templateTimelineType?: Maybe<TemplateTimelineType>;
status?: Maybe<TimelineStatus>;
};
export type Query = {
@ -4328,6 +4350,16 @@ export namespace GetAllTimeline {
totalCount: Maybe<number>;
defaultTimelineCount: Maybe<number>;
templateTimelineCount: Maybe<number>;
elasticTemplateTimelineCount: Maybe<number>;
customTemplateTimelineCount: Maybe<number>;
favoriteCount: Maybe<number>;
timeline: (Maybe<Timeline>)[];
};

View file

@ -13,6 +13,7 @@ import {
apolloClientObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { createStore, State } from '../../../common/store';
@ -26,10 +27,22 @@ describe('Authentication Table Component', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('rendering', () => {

View file

@ -15,6 +15,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -40,11 +41,23 @@ describe('Hosts Table', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended();
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('rendering', () => {

View file

@ -16,6 +16,7 @@ import {
TestProviders,
mockGlobalState,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../common/mock';
import { SiemNavigation } from '../../common/components/navigation';
@ -154,7 +155,13 @@ describe('Hosts - rendering', () => {
});
const myState: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
const myStore = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const wrapper = mount(
<TestProviders store={myStore}>
<Router history={mockHistory}>

View file

@ -14,6 +14,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { createStore, State } from '../../../common/store';
@ -28,10 +29,22 @@ describe('IP Overview Component', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('rendering', () => {

View file

@ -12,6 +12,7 @@ import {
apolloClientObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { createStore, State } from '../../../common/store';
@ -25,10 +26,22 @@ describe('KpiNetwork Component', () => {
const narrowDateRange = jest.fn();
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('rendering', () => {

View file

@ -15,6 +15,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { State, createStore } from '../../../common/store';
@ -28,11 +29,23 @@ describe('NetworkTopNFlow Table Component', () => {
const loadPage = jest.fn();
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended();
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('rendering', () => {

View file

@ -15,6 +15,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -31,11 +32,23 @@ describe('NetworkHttp Table Component', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended();
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('rendering', () => {

View file

@ -17,6 +17,7 @@ import {
mockIndexPattern,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -32,10 +33,22 @@ describe('NetworkTopCountries Table Component', () => {
const mount = useMountAppended();
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('rendering', () => {

View file

@ -16,6 +16,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -31,11 +32,23 @@ describe('NetworkTopNFlow Table Component', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended();
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('rendering', () => {

View file

@ -15,6 +15,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -28,11 +29,23 @@ describe('Tls Table Component', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended();
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('Rendering', () => {

View file

@ -16,6 +16,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -30,11 +31,23 @@ describe('Users Table Component', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended();
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
describe('Rendering', () => {

View file

@ -18,6 +18,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -90,7 +91,6 @@ const getMockProps = (ip: string) => ({
describe('Ip Details', () => {
const mount = useMountAppended();
beforeAll(() => {
(useWithSource as jest.Mock).mockReturnValue({
indicesExist: false,
@ -107,15 +107,27 @@ describe('Ip Details', () => {
});
afterAll(() => {
delete (global as GlobalWithFetch).fetch;
jest.resetAllMocks();
});
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
test('it renders', () => {

View file

@ -16,6 +16,7 @@ import {
mockGlobalState,
apolloClientObservable,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../common/mock';
import { State, createStore } from '../../common/store';
@ -139,7 +140,13 @@ describe('rendering - rendering', () => {
});
const myState: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
const myStore = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const wrapper = mount(
<TestProviders store={myStore}>
<Router history={mockHistory}>

View file

@ -14,6 +14,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
@ -95,11 +96,23 @@ describe('OverviewHost', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
const myState = cloneDeep(state);
store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
test('it renders the expected widget title', () => {

View file

@ -14,6 +14,7 @@ import {
TestProviders,
SUB_PLUGINS_REDUCER,
createSecuritySolutionStorageMock,
kibanaObservable,
} from '../../../common/mock';
import { OverviewNetwork } from '.';
@ -86,11 +87,23 @@ describe('OverviewNetwork', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
const myState = cloneDeep(state);
store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
});
test('it renders the expected widget title', () => {

View file

@ -24,6 +24,7 @@ import { RecentTimelines } from './recent_timelines';
import * as i18n from './translations';
import { FilterMode } from './types';
import { LoadingPlaceholders } from '../loading_placeholders';
import { useTimelineStatus } from '../../../timelines/components/open_timeline/use_timeline_status';
import { useKibana } from '../../../common/lib/kibana';
import { SecurityPageName } from '../../../app/types';
import { APP_ID } from '../../../../common/constants';
@ -83,25 +84,25 @@ const StatefulRecentTimelinesComponent = React.memo<Props>(
);
const { fetchAllTimeline, timelines, loading } = useGetAllTimeline();
useEffect(
() =>
fetchAllTimeline({
pageInfo: {
pageIndex: 1,
pageSize: PAGE_SIZE,
},
search: '',
sort: {
sortField: SortFieldTimeline.updated,
sortOrder: Direction.desc,
},
onlyUserFavorite: filterBy === 'favorites',
timelineType: TimelineType.default,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[filterBy]
);
const timelineType = TimelineType.default;
const { templateTimelineType, timelineStatus } = useTimelineStatus({ timelineType });
useEffect(() => {
fetchAllTimeline({
pageInfo: {
pageIndex: 1,
pageSize: PAGE_SIZE,
},
search: '',
sort: {
sortField: SortFieldTimeline.updated,
sortOrder: Direction.desc,
},
onlyUserFavorite: filterBy === 'favorites',
status: timelineStatus,
timelineType,
templateTimelineType,
});
}, [fetchAllTimeline, filterBy, timelineStatus, timelineType, templateTimelineType]);
return (
<>

View file

@ -23,7 +23,14 @@ import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public';
import { initTelemetry } from './common/lib/telemetry';
import { KibanaServices } from './common/lib/kibana/services';
import { serviceNowActionType, jiraActionType } from './common/lib/connectors';
import { PluginSetup, PluginStart, SetupPlugins, StartPlugins, StartServices } from './types';
import {
PluginSetup,
PluginStart,
SetupPlugins,
StartPlugins,
StartServices,
AppObservableLibs,
} from './types';
import {
APP_ID,
APP_ICON,
@ -120,6 +127,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.downloadAssets(),
this.downloadSubPlugins(),
]);
return renderApp({
...composeLibs(coreStart),
...params,
@ -396,8 +404,9 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
endpointAlertsSubPlugin,
managementSubPlugin,
} = await this.downloadSubPlugins();
const libs$ = new BehaviorSubject(composeLibs(coreStart));
const { apolloClient } = composeLibs(coreStart);
const appLibs: AppObservableLibs = { apolloClient, kibana: coreStart };
const libs$ = new BehaviorSubject(appLibs);
const alertsStart = alertsSubPlugin.start(storage);
const hostsStart = hostsSubPlugin.start(storage);
@ -434,6 +443,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
...managementSubPluginStart.store.reducer,
},
libs$.pipe(pluck('apolloClient')),
libs$.pipe(pluck('kibana')),
storage,
[
...(endpointAlertsStart.store.middleware ?? []),

View file

@ -41,6 +41,7 @@ const StatefulFlyoutHeader = React.memo<Props>(
notesById,
status,
timelineId,
timelineType,
title,
toggleLock,
updateDescription,
@ -66,6 +67,7 @@ const StatefulFlyoutHeader = React.memo<Props>(
noteIds={noteIds}
status={status}
timelineId={timelineId}
timelineType={timelineType}
title={title}
toggleLock={toggleLock}
updateDescription={updateDescription}
@ -100,6 +102,7 @@ const makeMapStateToProps = () => {
title = '',
noteIds = emptyNotesId,
status,
timelineType,
} = timeline;
const history = emptyHistory; // TODO: get history from store via selector
@ -116,6 +119,7 @@ const makeMapStateToProps = () => {
notesById: getNotesByIds(state),
status,
title,
timelineType,
};
};
return mapStateToProps;

View file

@ -4,6 +4,7 @@ exports[`FlyoutHeaderWithCloseButton renders correctly against snapshot 1`] = `
<FlyoutHeaderWithCloseButton
onClose={[MockFunction]}
timelineId="test"
timelineType="default"
usersViewing={
Array [
"elastic",

View file

@ -7,6 +7,7 @@
import { mount, shallow } from 'enzyme';
import React from 'react';
import { TimelineType } from '../../../../../common/types/timeline';
import { TestProviders } from '../../../../common/mock';
import { FlyoutHeaderWithCloseButton } from '.';
@ -40,14 +41,16 @@ jest.mock('../../../../common/lib/kibana', () => {
});
describe('FlyoutHeaderWithCloseButton', () => {
const props = {
onClose: jest.fn(),
timelineId: 'test',
timelineType: TimelineType.default,
usersViewing: ['elastic'],
};
test('renders correctly against snapshot', () => {
const EmptyComponent = shallow(
<TestProviders>
<FlyoutHeaderWithCloseButton
onClose={jest.fn()}
timelineId={'test'}
usersViewing={['elastic']}
/>
<FlyoutHeaderWithCloseButton {...props} />
</TestProviders>
);
expect(EmptyComponent.find('FlyoutHeaderWithCloseButton')).toMatchSnapshot();
@ -55,13 +58,13 @@ describe('FlyoutHeaderWithCloseButton', () => {
test('it should invoke onClose when the close button is clicked', () => {
const closeMock = jest.fn();
const testProps = {
...props,
onClose: closeMock,
};
const wrapper = mount(
<TestProviders>
<FlyoutHeaderWithCloseButton
onClose={closeMock}
timelineId={'test'}
usersViewing={['elastic']}
/>
<FlyoutHeaderWithCloseButton {...testProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="close-timeline"] button').first().simulate('click');

View file

@ -14,6 +14,7 @@ import {
mockGlobalState,
TestProviders,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../common/mock';
import { createStore, State } from '../../../common/store';
@ -62,6 +63,7 @@ describe('Flyout', () => {
stateShowIsTrue,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
@ -86,6 +88,7 @@ describe('Flyout', () => {
stateWithDataProviders,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
@ -108,6 +111,7 @@ describe('Flyout', () => {
stateWithDataProviders,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
@ -142,6 +146,7 @@ describe('Flyout', () => {
stateWithDataProviders,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);

View file

@ -8,52 +8,39 @@ import { mount, shallow } from 'enzyme';
import React from 'react';
import { AddNote } from '.';
import { TimelineStatus } from '../../../../../common/types/timeline';
describe('AddNote', () => {
const note = 'The contents of a new note';
const props = {
associateNote: jest.fn(),
getNewNoteId: jest.fn(),
newNote: note,
onCancelAddNote: jest.fn(),
updateNewNote: jest.fn(),
updateNote: jest.fn(),
status: TimelineStatus.active,
};
test('renders correctly', () => {
const wrapper = shallow(
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const wrapper = shallow(<AddNote {...props} />);
expect(wrapper).toMatchSnapshot();
});
test('it renders the Cancel button when onCancelAddNote is provided', () => {
const wrapper = mount(
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const wrapper = mount(<AddNote {...props} />);
expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(true);
});
test('it invokes onCancelAddNote when the Cancel button is clicked', () => {
const onCancelAddNote = jest.fn();
const testProps = {
...props,
onCancelAddNote,
};
const wrapper = mount(
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={onCancelAddNote}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const wrapper = mount(<AddNote {...testProps} />);
wrapper.find('[data-test-subj="cancel"]').first().simulate('click');
@ -62,17 +49,12 @@ describe('AddNote', () => {
test('it does NOT invoke associateNote when the Cancel button is clicked', () => {
const associateNote = jest.fn();
const testProps = {
...props,
associateNote,
};
const wrapper = mount(
<AddNote
associateNote={associateNote}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const wrapper = mount(<AddNote {...testProps} />);
wrapper.find('[data-test-subj="cancel"]').first().simulate('click');
@ -80,47 +62,29 @@ describe('AddNote', () => {
});
test('it does NOT render the Cancel button when onCancelAddNote is NOT provided', () => {
const wrapper = mount(
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const testProps = {
...props,
onCancelAddNote: undefined,
};
const wrapper = mount(<AddNote {...testProps} />);
expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(false);
});
test('it renders the contents of the note', () => {
const wrapper = mount(
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const wrapper = mount(<AddNote {...props} />);
expect(wrapper.find('[data-test-subj="add-a-note"]').first().text()).toEqual(note);
});
test('it invokes associateNote when the Add Note button is clicked', () => {
const associateNote = jest.fn();
const wrapper = mount(
<AddNote
associateNote={associateNote}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const testProps = {
...props,
newNote: note,
associateNote,
};
const wrapper = mount(<AddNote {...testProps} />);
wrapper.find('[data-test-subj="add-note"]').first().simulate('click');
@ -129,17 +93,12 @@ describe('AddNote', () => {
test('it invokes getNewNoteId when the Add Note button is clicked', () => {
const getNewNoteId = jest.fn();
const testProps = {
...props,
getNewNoteId,
};
const wrapper = mount(
<AddNote
associateNote={jest.fn()}
getNewNoteId={getNewNoteId}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const wrapper = mount(<AddNote {...testProps} />);
wrapper.find('[data-test-subj="add-note"]').first().simulate('click');
@ -148,17 +107,12 @@ describe('AddNote', () => {
test('it invokes updateNewNote when the Add Note button is clicked', () => {
const updateNewNote = jest.fn();
const testProps = {
...props,
updateNewNote,
};
const wrapper = mount(
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={updateNewNote}
updateNote={jest.fn()}
/>
);
const wrapper = mount(<AddNote {...testProps} />);
wrapper.find('[data-test-subj="add-note"]').first().simulate('click');
@ -167,17 +121,11 @@ describe('AddNote', () => {
test('it invokes updateNote when the Add Note button is clicked', () => {
const updateNote = jest.fn();
const wrapper = mount(
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={updateNote}
/>
);
const testProps = {
...props,
updateNote,
};
const wrapper = mount(<AddNote {...testProps} />);
wrapper.find('[data-test-subj="add-note"]').first().simulate('click');
@ -185,16 +133,11 @@ describe('AddNote', () => {
});
test('it does NOT display the markdown formatting hint when a note has NOT been entered', () => {
const wrapper = mount(
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={''}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const testProps = {
...props,
newNote: '',
};
const wrapper = mount(<AddNote {...testProps} />);
expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule(
'visibility',
@ -203,16 +146,11 @@ describe('AddNote', () => {
});
test('it displays the markdown formatting hint when a note has been entered', () => {
const wrapper = mount(
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={'We should see a formatting hint now'}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
const testProps = {
...props,
newNote: 'We should see a formatting hint now',
};
const wrapper = mount(<AddNote {...testProps} />);
expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule(
'visibility',

View file

@ -61,7 +61,6 @@ export const AddNote = React.memo<{
}),
[associateNote, getNewNoteId, newNote, updateNewNote, updateNote]
);
return (
<AddNotesContainer alignItems="flexEnd" direction="column" gutterSize="none">
<NewNote note={newNote} noteInputHeight={200} updateNewNote={updateNewNote} />

View file

@ -21,12 +21,14 @@ import { AddNote } from './add_note';
import { columns } from './columns';
import { AssociateNote, GetNewNoteId, NotesCount, search, UpdateNote } from './helpers';
import { NOTES_PANEL_WIDTH, NOTES_PANEL_HEIGHT } from '../timeline/properties/notes_size';
import { TimelineStatusLiteral, TimelineStatus } from '../../../../common/types/timeline';
interface Props {
associateNote: AssociateNote;
getNotesByIds: (noteIds: string[]) => Note[];
getNewNoteId: GetNewNoteId;
noteIds: string[];
status: TimelineStatusLiteral;
updateNote: UpdateNote;
}
@ -53,8 +55,9 @@ InMemoryTable.displayName = 'InMemoryTable';
/** A view for entering and reviewing notes */
export const Notes = React.memo<Props>(
({ associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote }) => {
({ associateNote, getNotesByIds, getNewNoteId, noteIds, status, updateNote }) => {
const [newNote, setNewNote] = useState('');
const isImmutable = status === TimelineStatus.immutable;
return (
<NotesPanel>
@ -63,13 +66,15 @@ export const Notes = React.memo<Props>(
</EuiModalHeader>
<EuiModalBody>
<AddNote
associateNote={associateNote}
getNewNoteId={getNewNoteId}
newNote={newNote}
updateNewNote={setNewNote}
updateNote={updateNote}
/>
{!isImmutable && (
<AddNote
associateNote={associateNote}
getNewNoteId={getNewNoteId}
newNote={newNote}
updateNewNote={setNewNote}
updateNote={updateNote}
/>
)}
<EuiSpacer size="s" />
<InMemoryTable
data-test-subj="notes-table"

View file

@ -12,6 +12,7 @@ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { Note } from '../../../../common/lib/note';
import { NoteCards } from '.';
import { TimelineStatus } from '../../../../../common/types/timeline';
describe('NoteCards', () => {
const noteIds = ['abc', 'def'];
@ -38,18 +39,21 @@ describe('NoteCards', () => {
},
];
const props = {
associateNote: jest.fn(),
getNotesByIds,
getNewNoteId: jest.fn(),
noteIds,
showAddNote: true,
status: TimelineStatus.active,
toggleShowAddNote: jest.fn(),
updateNote: jest.fn(),
};
test('it renders the notes column when noteIds are specified', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<NoteCards
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={noteIds}
showAddNote={true}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
<NoteCards {...props} />
</ThemeProvider>
);
@ -57,17 +61,10 @@ describe('NoteCards', () => {
});
test('it does NOT render the notes column when noteIds are NOT specified', () => {
const testProps = { ...props, noteIds: [] };
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<NoteCards
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={[]}
showAddNote={true}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
<NoteCards {...testProps} />
</ThemeProvider>
);
@ -77,15 +74,7 @@ describe('NoteCards', () => {
test('renders note cards', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<NoteCards
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={noteIds}
showAddNote={true}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
<NoteCards {...props} />
</ThemeProvider>
);
@ -102,15 +91,7 @@ describe('NoteCards', () => {
test('it shows controls for adding notes when showAddNote is true', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<NoteCards
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={noteIds}
showAddNote={true}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
<NoteCards {...props} />
</ThemeProvider>
);
@ -118,17 +99,11 @@ describe('NoteCards', () => {
});
test('it does NOT show controls for adding notes when showAddNote is false', () => {
const testProps = { ...props, showAddNote: false };
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<NoteCards
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={noteIds}
showAddNote={false}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
<NoteCards {...testProps} />
</ThemeProvider>
);

View file

@ -12,6 +12,7 @@ import { Note } from '../../../../common/lib/note';
import { AddNote } from '../add_note';
import { AssociateNote, GetNewNoteId, UpdateNote } from '../helpers';
import { NoteCard } from '../note_card';
import { TimelineStatusLiteral } from '../../../../../common/types/timeline';
const AddNoteContainer = styled.div``;
AddNoteContainer.displayName = 'AddNoteContainer';
@ -49,6 +50,7 @@ interface Props {
getNewNoteId: GetNewNoteId;
noteIds: string[];
showAddNote: boolean;
status: TimelineStatusLiteral;
toggleShowAddNote: () => void;
updateNote: UpdateNote;
}
@ -61,6 +63,7 @@ export const NoteCards = React.memo<Props>(
getNewNoteId,
noteIds,
showAddNote,
status,
toggleShowAddNote,
updateNote,
}) => {

View file

@ -6,7 +6,9 @@
import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { isEmpty } from 'lodash/fp';
import { TimelineStatus } from '../../../../common/types/timeline';
import * as i18n from './translations';
import { DeleteTimelines, OpenTimelineResult } from './types';
import { EditTimelineActions } from './export_timeline';
@ -63,7 +65,7 @@ export const useEditTimelineBatchActions = ({
const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void) => {
const isDisabled = isEmpty(selectedItems);
const disabled = selectedItems?.some((item) => item.status === TimelineStatus.immutable);
return (
<>
<EditTimelineActions
@ -82,7 +84,7 @@ export const useEditTimelineBatchActions = ({
<EuiContextMenuPanel
items={[
<EuiContextMenuItem
disabled={isDisabled}
disabled={disabled}
icon="exportAction"
key="ExportItemKey"
onClick={handleEnableExportTimelineDownloader}
@ -90,7 +92,7 @@ export const useEditTimelineBatchActions = ({
{i18n.EXPORT_SELECTED}
</EuiContextMenuItem>,
<EuiContextMenuItem
disabled={isDisabled}
disabled={disabled}
icon="trash"
key="DeleteItemKey"
onClick={handleOnOpenDeleteTimelineModal}
@ -102,6 +104,7 @@ export const useEditTimelineBatchActions = ({
</>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
deleteTimelines,
isEnableDownloader,

View file

@ -8,7 +8,6 @@ import React from 'react';
import { TimelineDownloader } from './export_timeline';
import { mockSelectedTimeline } from './mocks';
import { ReactWrapper, mount } from 'enzyme';
import { useExportTimeline } from '.';
jest.mock('../translations', () => {
return {
@ -32,19 +31,6 @@ describe('TimelineDownloader', () => {
onComplete: jest.fn(),
};
describe('should not render a downloader', () => {
beforeAll(() => {
((useExportTimeline as unknown) as jest.Mock).mockReturnValue({
enableDownloader: false,
setEnableDownloader: jest.fn(),
exportedIds: {},
getExportedData: jest.fn(),
});
});
afterAll(() => {
((useExportTimeline as unknown) as jest.Mock).mockReset();
});
test('Without exportedIds', () => {
const testProps = {
...defaultTestProps,
@ -65,19 +51,6 @@ describe('TimelineDownloader', () => {
});
describe('should render a downloader', () => {
beforeAll(() => {
((useExportTimeline as unknown) as jest.Mock).mockReturnValue({
enableDownloader: false,
setEnableDownloader: jest.fn(),
exportedIds: {},
getExportedData: jest.fn(),
});
});
afterAll(() => {
((useExportTimeline as unknown) as jest.Mock).mockReset();
});
test('With selectedItems and exportedIds is given and isEnableDownloader is true', () => {
const testProps = {
...defaultTestProps,

View file

@ -5,31 +5,41 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import { useExportTimeline, ExportTimeline } from '.';
import { shallow } from 'enzyme';
import { EditTimelineActionsComponent } from '.';
describe('useExportTimeline', () => {
describe('call with selected timelines', () => {
let exportTimelineRes: ExportTimeline;
const TestHook = () => {
exportTimelineRes = useExportTimeline();
return <div />;
describe('EditTimelineActionsComponent', () => {
describe('render', () => {
const props = {
deleteTimelines: jest.fn(),
ids: ['id1'],
isEnableDownloader: false,
isDeleteTimelineModalOpen: false,
onComplete: jest.fn(),
title: 'mockTitle',
};
beforeAll(() => {
mount(<TestHook />);
test('should render timelineDownloader', () => {
const wrapper = shallow(<EditTimelineActionsComponent {...props} />);
expect(wrapper.find('[data-test-subj="TimelineDownloader"]').exists()).toBeTruthy();
});
test('Downloader should be disabled by default', () => {
expect(exportTimelineRes.isEnableDownloader).toBeFalsy();
test('Should render DeleteTimelineModalOverlay if deleteTimelines is given', () => {
const wrapper = shallow(<EditTimelineActionsComponent {...props} />);
expect(wrapper.find('[data-test-subj="DeleteTimelineModalOverlay"]').exists()).toBeTruthy();
});
test('Should include disableExportTimelineDownloader in return value', () => {
expect(exportTimelineRes).toHaveProperty('disableExportTimelineDownloader');
});
test('Should include enableExportTimelineDownloader in return value', () => {
expect(exportTimelineRes).toHaveProperty('enableExportTimelineDownloader');
test('Should not render DeleteTimelineModalOverlay if deleteTimelines is not given', () => {
const newProps = {
...props,
deleteTimelines: undefined,
};
const wrapper = shallow(<EditTimelineActionsComponent {...newProps} />);
expect(
wrapper.find('[data-test-subj="DeleteTimelineModalOverlay"]').exists()
).not.toBeTruthy();
});
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useCallback } from 'react';
import React from 'react';
import { DeleteTimelines } from '../types';
import { TimelineDownloader } from './export_timeline';
@ -17,25 +17,7 @@ export interface ExportTimeline {
isEnableDownloader: boolean;
}
export const useExportTimeline = (): ExportTimeline => {
const [isEnableDownloader, setIsEnableDownloader] = useState(false);
const enableExportTimelineDownloader = useCallback(() => {
setIsEnableDownloader(true);
}, []);
const disableExportTimelineDownloader = useCallback(() => {
setIsEnableDownloader(false);
}, []);
return {
disableExportTimelineDownloader,
enableExportTimelineDownloader,
isEnableDownloader,
};
};
const EditTimelineActionsComponent: React.FC<{
export const EditTimelineActionsComponent: React.FC<{
deleteTimelines: DeleteTimelines | undefined;
ids: string[];
isEnableDownloader: boolean;
@ -52,6 +34,7 @@ const EditTimelineActionsComponent: React.FC<{
}) => (
<>
<TimelineDownloader
data-test-subj="TimelineDownloader"
exportedIds={ids}
getExportedData={exportSelectedTimeline}
isEnableDownloader={isEnableDownloader}
@ -59,6 +42,7 @@ const EditTimelineActionsComponent: React.FC<{
/>
{deleteTimelines != null && (
<DeleteTimelineModalOverlay
data-test-subj="DeleteTimelineModalOverlay"
deleteTimelines={deleteTimelines}
isModalOpen={isDeleteTimelineModalOpen}
onComplete={onComplete}

View file

@ -10,7 +10,17 @@ import { Action } from 'typescript-fsa';
import uuid from 'uuid';
import { Dispatch } from 'redux';
import { oneTimelineQuery } from '../../containers/one/index.gql_query';
import { TimelineResult, GetOneTimeline, NoteResult } from '../../../graphql/types';
import {
TimelineResult,
GetOneTimeline,
NoteResult,
FilterTimelineResult,
ColumnHeaderResult,
PinnedEvent,
} from '../../../graphql/types';
import { TimelineStatus, TimelineType } from '../../../../common/types/timeline';
import {
addNotes as dispatchAddNotes,
updateNote as dispatchUpdateNote,
@ -22,9 +32,9 @@ import {
addTimeline as dispatchAddTimeline,
addNote as dispatchAddGlobalTimelineNote,
} from '../../../timelines/store/timeline/actions';
import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import {
defaultColumnHeaderType,
defaultHeaders,
@ -77,103 +87,115 @@ const parseString = (params: string) => {
}
};
const setTimelineColumn = (col: ColumnHeaderResult) => {
const timelineCols: ColumnHeaderOptions = {
...col,
columnHeaderType: defaultColumnHeaderType,
id: col.id != null ? col.id : 'unknown',
placeholder: col.placeholder != null ? col.placeholder : undefined,
category: col.category != null ? col.category : undefined,
description: col.description != null ? col.description : undefined,
example: col.example != null ? col.example : undefined,
type: col.type != null ? col.type : undefined,
aggregatable: col.aggregatable != null ? col.aggregatable : undefined,
width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH,
};
return timelineCols;
};
const setTimelineFilters = (filter: FilterTimelineResult) => ({
$state: {
store: 'appState',
},
meta: {
...filter.meta,
...(filter.meta && filter.meta.field != null ? { params: parseString(filter.meta.field) } : {}),
...(filter.meta && filter.meta.params != null
? { params: parseString(filter.meta.params) }
: {}),
...(filter.meta && filter.meta.value != null ? { value: parseString(filter.meta.value) } : {}),
},
...(filter.exists != null ? { exists: parseString(filter.exists) } : {}),
...(filter.match_all != null ? { exists: parseString(filter.match_all) } : {}),
...(filter.missing != null ? { exists: parseString(filter.missing) } : {}),
...(filter.query != null ? { query: parseString(filter.query) } : {}),
...(filter.range != null ? { range: parseString(filter.range) } : {}),
...(filter.script != null ? { exists: parseString(filter.script) } : {}),
});
const setEventIdToNoteIds = (
duplicate: boolean,
eventIdToNoteIds: NoteResult[] | null | undefined
) =>
duplicate
? {}
: eventIdToNoteIds != null
? eventIdToNoteIds.reduce((acc, note) => {
if (note.eventId != null) {
const eventNotes = getOr([], note.eventId, acc);
return { ...acc, [note.eventId]: [...eventNotes, note.noteId] };
}
return acc;
}, {})
: {};
const setPinnedEventsSaveObject = (
duplicate: boolean,
pinnedEventsSaveObject: PinnedEvent[] | null | undefined
) =>
duplicate
? {}
: pinnedEventsSaveObject != null
? pinnedEventsSaveObject.reduce(
(acc, pinnedEvent) => ({
...acc,
...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}),
}),
{}
)
: {};
const setPinnedEventIds = (duplicate: boolean, pinnedEventIds: string[] | null | undefined) =>
duplicate
? {}
: pinnedEventIds != null
? pinnedEventIds.reduce((acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), {})
: {};
// eslint-disable-next-line complexity
export const defaultTimelineToTimelineModel = (
timeline: TimelineResult,
duplicate: boolean
): TimelineModel => {
return Object.entries({
const isTemplate = timeline.timelineType === TimelineType.template;
const timelineEntries = {
...timeline,
columns:
timeline.columns != null
? timeline.columns.map((col) => {
const timelineCols: ColumnHeaderOptions = {
...col,
columnHeaderType: defaultColumnHeaderType,
id: col.id != null ? col.id : 'unknown',
placeholder: col.placeholder != null ? col.placeholder : undefined,
category: col.category != null ? col.category : undefined,
description: col.description != null ? col.description : undefined,
example: col.example != null ? col.example : undefined,
type: col.type != null ? col.type : undefined,
aggregatable: col.aggregatable != null ? col.aggregatable : undefined,
width:
col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH,
};
return timelineCols;
})
: defaultHeaders,
eventIdToNoteIds: duplicate
? {}
: timeline.eventIdToNoteIds != null
? timeline.eventIdToNoteIds.reduce((acc, note) => {
if (note.eventId != null) {
const eventNotes = getOr([], note.eventId, acc);
return { ...acc, [note.eventId]: [...eventNotes, note.noteId] };
}
return acc;
}, {})
: {},
filters:
timeline.filters != null
? timeline.filters.map((filter) => ({
$state: {
store: 'appState',
},
meta: {
...filter.meta,
...(filter.meta && filter.meta.field != null
? { params: parseString(filter.meta.field) }
: {}),
...(filter.meta && filter.meta.params != null
? { params: parseString(filter.meta.params) }
: {}),
...(filter.meta && filter.meta.value != null
? { value: parseString(filter.meta.value) }
: {}),
},
...(filter.exists != null ? { exists: parseString(filter.exists) } : {}),
...(filter.match_all != null ? { exists: parseString(filter.match_all) } : {}),
...(filter.missing != null ? { exists: parseString(filter.missing) } : {}),
...(filter.query != null ? { query: parseString(filter.query) } : {}),
...(filter.range != null ? { range: parseString(filter.range) } : {}),
...(filter.script != null ? { exists: parseString(filter.script) } : {}),
}))
: [],
columns: timeline.columns != null ? timeline.columns.map(setTimelineColumn) : defaultHeaders,
eventIdToNoteIds: setEventIdToNoteIds(duplicate, timeline.eventIdToNoteIds),
filters: timeline.filters != null ? timeline.filters.map(setTimelineFilters) : [],
isFavorite: duplicate
? false
: timeline.favorite != null
? timeline.favorite.length > 0
: false,
noteIds: duplicate ? [] : timeline.noteIds != null ? timeline.noteIds : [],
pinnedEventIds: duplicate
? {}
: timeline.pinnedEventIds != null
? timeline.pinnedEventIds.reduce(
(acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }),
{}
)
: {},
pinnedEventsSaveObject: duplicate
? {}
: timeline.pinnedEventsSaveObject != null
? timeline.pinnedEventsSaveObject.reduce(
(acc, pinnedEvent) => ({
...acc,
...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}),
}),
{}
)
: {},
pinnedEventIds: setPinnedEventIds(duplicate, timeline.pinnedEventIds),
pinnedEventsSaveObject: setPinnedEventsSaveObject(duplicate, timeline.pinnedEventsSaveObject),
id: duplicate ? '' : timeline.savedObjectId,
status: duplicate ? TimelineStatus.active : timeline.status,
savedObjectId: duplicate ? null : timeline.savedObjectId,
version: duplicate ? null : timeline.version,
title: duplicate ? '' : timeline.title || '',
templateTimelineId: duplicate ? null : timeline.templateTimelineId,
templateTimelineVersion: duplicate ? null : timeline.templateTimelineVersion,
}).reduce((acc: TimelineModel, [key, value]) => (value != null ? set(key, value, acc) : acc), {
...timelineDefaults,
id: '',
});
title: duplicate ? `${timeline.title} - Duplicate` : timeline.title || '',
templateTimelineId: duplicate && isTemplate ? uuid.v4() : timeline.templateTimelineId,
templateTimelineVersion: duplicate && isTemplate ? 1 : timeline.templateTimelineVersion,
};
return Object.entries(timelineEntries).reduce(
(acc: TimelineModel, [key, value]) => (value != null ? set(key, value, acc) : acc),
{
...timelineDefaults,
id: '',
}
);
};
export const formatTimelineResultToModel = (

View file

@ -9,9 +9,9 @@ import React, { useEffect, useState, useCallback } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Dispatch } from 'redux';
import { defaultHeaders } from '../timeline/body/column_headers/default_headers';
import { deleteTimelineMutation } from '../../containers/delete/persist.gql_query';
import { useGetAllTimeline } from '../../containers/all';
import { disableTemplate } from '../../../../common/constants';
import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../../graphql/types';
import { State } from '../../../common/store';
import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model';
@ -21,6 +21,12 @@ import {
createTimeline as dispatchCreateNewTimeline,
updateIsLoading as dispatchUpdateIsLoading,
} from '../../../timelines/store/timeline/actions';
import { deleteTimelineMutation } from '../../containers/delete/persist.gql_query';
import { useGetAllTimeline } from '../../containers/all';
import { defaultHeaders } from '../timeline/body/column_headers/default_headers';
import { OpenTimeline } from './open_timeline';
import { OPEN_TIMELINE_CLASS_NAME, queryTimelineById, dispatchUpdateTimeline } from './helpers';
import { OpenTimelineModalBody } from './open_timeline_modal/open_timeline_modal_body';
@ -42,7 +48,7 @@ import {
} from './types';
import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants';
import { useTimelineTypes } from './use_timeline_types';
import { disableTemplate } from '../../../../common/constants';
import { useTimelineStatus } from './use_timeline_status';
interface OwnProps<TCache = object> {
apolloClient: ApolloClient<TCache>;
@ -106,28 +112,54 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
/** The requested field to sort on */
const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD);
const { timelineType, timelineTabs, timelineFilters } = useTimelineTypes();
const { fetchAllTimeline, timelines, loading, totalCount } = useGetAllTimeline();
const refetch = useCallback(
() =>
fetchAllTimeline({
pageInfo: {
pageIndex: pageIndex + 1,
pageSize,
},
search,
sort: {
sortField: sortField as SortFieldTimeline,
sortOrder: sortDirection as Direction,
},
onlyUserFavorite: onlyFavorites,
timelineType,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[pageIndex, pageSize, search, sortField, sortDirection, timelineType, onlyFavorites]
);
const {
customTemplateTimelineCount,
defaultTimelineCount,
elasticTemplateTimelineCount,
favoriteCount,
fetchAllTimeline,
timelines,
loading,
totalCount,
templateTimelineCount,
} = useGetAllTimeline();
const { timelineType, timelineTabs, timelineFilters } = useTimelineTypes({
defaultTimelineCount,
templateTimelineCount,
});
const { timelineStatus, templateTimelineType, templateTimelineFilter } = useTimelineStatus({
timelineType,
customTemplateTimelineCount,
elasticTemplateTimelineCount,
});
const refetch = useCallback(() => {
fetchAllTimeline({
pageInfo: {
pageIndex: pageIndex + 1,
pageSize,
},
search,
sort: {
sortField: sortField as SortFieldTimeline,
sortOrder: sortDirection as Direction,
},
onlyUserFavorite: onlyFavorites,
timelineType,
templateTimelineType,
status: timelineStatus,
});
}, [
fetchAllTimeline,
pageIndex,
pageSize,
search,
sortField,
sortDirection,
timelineType,
timelineStatus,
templateTimelineType,
onlyFavorites,
]);
/** Invoked when the user presses enters to submit the text in the search input */
const onQueryChange: OnQueryChange = useCallback((query: EuiSearchBarQuery) => {
@ -264,6 +296,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
data-test-subj={'open-timeline'}
deleteTimelines={onDeleteOneTimeline}
defaultPageSize={defaultPageSize}
favoriteCount={favoriteCount}
isLoading={loading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
importDataModalToggle={importDataModalToggle}
@ -285,7 +318,9 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
selectedItems={selectedItems}
sortDirection={sortDirection}
sortField={sortField}
tabs={!disableTemplate ? timelineTabs : undefined}
templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null}
timelineType={timelineType}
timelineFilter={!disableTemplate ? timelineTabs : null}
title={title}
totalSearchResultsCount={totalCount}
/>
@ -294,6 +329,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
data-test-subj={'open-timeline-modal'}
deleteTimelines={onDeleteOneTimeline}
defaultPageSize={defaultPageSize}
favoriteCount={favoriteCount}
hideActions={hideActions}
isLoading={loading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
@ -312,7 +348,9 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
selectedItems={selectedItems}
sortDirection={sortDirection}
sortField={sortField}
tabs={!disableTemplate ? timelineFilters : undefined}
templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null}
timelineType={timelineType}
timelineFilter={!disableTemplate ? timelineFilters : null}
title={title}
totalSearchResultsCount={totalCount}
/>

View file

@ -16,6 +16,7 @@ import { TimelinesTableProps } from './timelines_table';
import { mockTimelineResults } from '../../../common/mock/timeline_results';
import { OpenTimeline } from './open_timeline';
import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from './constants';
import { TimelineType } from '../../../../common/types/timeline';
jest.mock('../../../common/lib/kibana');
@ -46,8 +47,9 @@ describe('OpenTimeline', () => {
selectedItems: [],
sortDirection: DEFAULT_SORT_DIRECTION,
sortField: DEFAULT_SORT_FIELD,
tabs: <div />,
title,
timelineType: TimelineType.default,
templateTimelineFilter: [<div />],
totalSearchResultsCount: mockSearchResults.length,
});

View file

@ -4,17 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiPanel, EuiBasicTable } from '@elastic/eui';
import { EuiPanel, EuiBasicTable, EuiCallOut, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo, useRef } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { OPEN_TIMELINE_CLASS_NAME } from './helpers';
import { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types';
import { SearchRow } from './search_row';
import { TimelinesTable } from './timelines_table';
import { ImportDataModal } from '../../../common/components/import_data_modal';
import * as i18n from './translations';
import { importTimelines } from '../../containers/api';
import { ImportDataModal } from '../../../common/components/import_data_modal';
import {
UtilityBarGroup,
UtilityBarText,
@ -22,14 +16,23 @@ import {
UtilityBarSection,
UtilityBarAction,
} from '../../../common/components/utility_bar';
import { importTimelines } from '../../containers/api';
import { useEditTimelineBatchActions } from './edit_timeline_batch_actions';
import { useEditTimelineActions } from './edit_timeline_actions';
import { EditOneTimelineAction } from './export_timeline';
import { SearchRow } from './search_row';
import { TimelinesTable } from './timelines_table';
import * as i18n from './translations';
import { OPEN_TIMELINE_CLASS_NAME } from './helpers';
import { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types';
export const OpenTimeline = React.memo<OpenTimelineProps>(
({
deleteTimelines,
defaultPageSize,
favoriteCount,
isLoading,
itemIdToExpandedNotesRowMap,
importDataModalToggle,
@ -51,11 +54,12 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
sortDirection,
setImportDataModalToggle,
sortField,
tabs,
timelineType,
timelineFilter,
templateTimelineFilter,
totalSearchResultsCount,
}) => {
const tableRef = useRef<EuiBasicTable<OpenTimelineResult>>();
const {
actionItem,
enableExportTimelineDownloader,
@ -124,6 +128,8 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
[onDeleteSelected, deleteTimelines]
);
const SearchRowContent = useMemo(() => <>{templateTimelineFilter}</>, [templateTimelineFilter]);
return (
<>
<EditOneTimelineAction
@ -151,15 +157,20 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
/>
<EuiPanel className={OPEN_TIMELINE_CLASS_NAME}>
{!!tabs && tabs}
<EuiCallOut size="s" title={i18n.TEMPLATE_CALL_OUT_MESSAGE} />
<EuiSpacer size="m" />
{!!timelineFilter && timelineFilter}
<SearchRow
data-test-subj="search-row"
favoriteCount={favoriteCount}
onlyFavorites={onlyFavorites}
onQueryChange={onQueryChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
query={query}
totalSearchResultsCount={totalSearchResultsCount}
/>
>
{SearchRowContent}
</SearchRow>
<UtilityBar border>
<UtilityBarSection>
@ -206,6 +217,7 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
showExtendedColumns={true}
sortDirection={sortDirection}
sortField={sortField}
timelineType={timelineType}
tableRef={tableRef}
totalSearchResultsCount={totalSearchResultsCount}
/>

View file

@ -16,6 +16,7 @@ import { TimelinesTableProps } from '../timelines_table';
import { mockTimelineResults } from '../../../../common/mock/timeline_results';
import { OpenTimelineModalBody } from './open_timeline_modal_body';
import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants';
import { TimelineType } from '../../../../../common/types/timeline';
jest.mock('../../../../common/lib/kibana');
@ -45,7 +46,8 @@ describe('OpenTimelineModal', () => {
selectedItems: [],
sortDirection: DEFAULT_SORT_DIRECTION,
sortField: DEFAULT_SORT_FIELD,
tabs: <div />,
timelineType: TimelineType.default,
templateTimelineFilter: [<div />],
title,
totalSearchResultsCount: mockSearchResults.length,
});

View file

@ -23,6 +23,7 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
({
deleteTimelines,
defaultPageSize,
favoriteCount,
hideActions = [],
isLoading,
itemIdToExpandedNotesRowMap,
@ -42,7 +43,9 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
selectedItems,
sortDirection,
sortField,
tabs,
timelineFilter,
timelineType,
templateTimelineFilter,
title,
totalSearchResultsCount,
}) => {
@ -54,6 +57,16 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
return actions.filter((action) => !hideActions.includes(action));
}, [onDeleteSelected, deleteTimelines, hideActions]);
const SearchRowContent = useMemo(
() => (
<>
{!!timelineFilter && timelineFilter}
{!!templateTimelineFilter && templateTimelineFilter}
</>
),
[timelineFilter, templateTimelineFilter]
);
return (
<>
<EuiModalHeader>
@ -67,13 +80,15 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
<>
<SearchRow
data-test-subj="search-row"
favoriteCount={favoriteCount}
onlyFavorites={onlyFavorites}
onQueryChange={onQueryChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
query={query}
tabs={tabs}
totalSearchResultsCount={totalSearchResultsCount}
/>
>
{SearchRowContent}
</SearchRow>
</>
</HeaderContainer>
</EuiModalHeader>
@ -96,6 +111,7 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
showExtendedColumns={false}
sortDirection={sortDirection}
sortField={sortField}
timelineType={timelineType}
totalSearchResultsCount={totalSearchResultsCount}
/>
</EuiModalBody>

View file

@ -34,8 +34,13 @@ SearchRowFlexGroup.displayName = 'SearchRowFlexGroup';
type Props = Pick<
OpenTimelineProps,
'onlyFavorites' | 'onQueryChange' | 'onToggleOnlyFavorites' | 'query' | 'totalSearchResultsCount'
> & { tabs?: JSX.Element };
| 'favoriteCount'
| 'onlyFavorites'
| 'onQueryChange'
| 'onToggleOnlyFavorites'
| 'query'
| 'totalSearchResultsCount'
> & { children?: JSX.Element | null };
const searchBox = {
placeholder: i18n.SEARCH_PLACEHOLDER,
@ -47,12 +52,13 @@ const searchBox = {
*/
export const SearchRow = React.memo<Props>(
({
favoriteCount,
onlyFavorites,
onQueryChange,
onToggleOnlyFavorites,
query,
totalSearchResultsCount,
tabs,
children,
}) => {
return (
<SearchRowContainer>
@ -68,10 +74,11 @@ export const SearchRow = React.memo<Props>(
data-test-subj="only-favorites-toggle"
hasActiveFilters={onlyFavorites}
onClick={onToggleOnlyFavorites}
numFilters={favoriteCount ?? undefined}
>
{i18n.ONLY_FAVORITES}
</EuiFilterButton>
{tabs}
{!!children && children}
</>
</EuiFilterGroup>
</EuiFlexItem>

View file

@ -16,6 +16,7 @@ import {
TimelineActionsOverflowColumns,
} from '../types';
import * as i18n from '../translations';
import { TimelineStatus } from '../../../../../common/types/timeline';
/**
* Returns the action columns (e.g. delete, open duplicate timeline)
@ -54,7 +55,9 @@ export const getActionsColumns = ({
onClick: (selectedTimeline: OpenTimelineResult) => {
if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline);
},
enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null,
enabled: (timeline: OpenTimelineResult) => {
return timeline.savedObjectId != null && timeline.status !== TimelineStatus.immutable;
},
description: i18n.EXPORT_SELECTED,
'data-test-subj': 'export-timeline',
};
@ -65,7 +68,8 @@ export const getActionsColumns = ({
onClick: (selectedTimeline: OpenTimelineResult) => {
if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline);
},
enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null,
enabled: ({ savedObjectId, status }: OpenTimelineResult) =>
savedObjectId != null && status !== TimelineStatus.immutable,
description: i18n.DELETE_SELECTED,
'data-test-subj': 'delete-timeline',
};

View file

@ -13,55 +13,68 @@ import { ACTION_COLUMN_WIDTH } from './common_styles';
import { getNotesCount, getPinnedEventCount } from '../helpers';
import * as i18n from '../translations';
import { FavoriteTimelineResult, OpenTimelineResult } from '../types';
import { TimelineTypeLiteralWithNull, TimelineType } from '../../../../../common/types/timeline';
/**
* Returns the columns that have icon headers
*/
export const getIconHeaderColumns = () => [
{
align: 'center',
field: 'pinnedEventIds',
name: (
<EuiToolTip content={i18n.PINNED_EVENTS}>
<EuiIcon data-test-subj="pinned-event-header-icon" size="m" type="pin" />
</EuiToolTip>
),
render: (_: Record<string, boolean> | null | undefined, timelineResult: OpenTimelineResult) => (
<span data-test-subj="pinned-event-count">{`${getPinnedEventCount(timelineResult)}`}</span>
),
sortable: false,
width: ACTION_COLUMN_WIDTH,
},
{
align: 'center',
field: 'eventIdToNoteIds',
name: (
<EuiToolTip content={i18n.NOTES}>
<EuiIcon data-test-subj="notes-count-header-icon" size="m" type="editorComment" />
</EuiToolTip>
),
render: (
_: Record<string, string[]> | null | undefined,
timelineResult: OpenTimelineResult
) => <span data-test-subj="notes-count">{getNotesCount(timelineResult)}</span>,
sortable: false,
width: ACTION_COLUMN_WIDTH,
},
{
align: 'center',
field: 'favorite',
name: (
<EuiToolTip content={i18n.FAVORITES}>
<EuiIcon data-test-subj="favorites-header-icon" size="m" type="starEmpty" />
</EuiToolTip>
),
render: (favorite: FavoriteTimelineResult[] | null | undefined) => {
const isFavorite = favorite != null && favorite.length > 0;
const fill = isFavorite ? 'starFilled' : 'starEmpty';
return <EuiIcon data-test-subj={`favorite-${fill}-star`} type={fill} size="m" />;
export const getIconHeaderColumns = ({
timelineType,
}: {
timelineType: TimelineTypeLiteralWithNull;
}) => {
const columns = {
note: {
align: 'center',
field: 'eventIdToNoteIds',
name: (
<EuiToolTip content={i18n.NOTES}>
<EuiIcon data-test-subj="notes-count-header-icon" size="m" type="editorComment" />
</EuiToolTip>
),
render: (
_: Record<string, string[]> | null | undefined,
timelineResult: OpenTimelineResult
) => <span data-test-subj="notes-count">{getNotesCount(timelineResult)}</span>,
sortable: false,
width: ACTION_COLUMN_WIDTH,
},
sortable: false,
width: ACTION_COLUMN_WIDTH,
},
];
pinnedEvent: {
align: 'center',
field: 'pinnedEventIds',
name: (
<EuiToolTip content={i18n.PINNED_EVENTS}>
<EuiIcon data-test-subj="pinned-event-header-icon" size="m" type="pin" />
</EuiToolTip>
),
render: (
_: Record<string, boolean> | null | undefined,
timelineResult: OpenTimelineResult
) => (
<span data-test-subj="pinned-event-count">{`${getPinnedEventCount(timelineResult)}`}</span>
),
sortable: false,
width: ACTION_COLUMN_WIDTH,
},
favorite: {
align: 'center',
field: 'favorite',
name: (
<EuiToolTip content={i18n.FAVORITES}>
<EuiIcon data-test-subj="favorites-header-icon" size="m" type="starEmpty" />
</EuiToolTip>
),
render: (favorite: FavoriteTimelineResult[] | null | undefined) => {
const isFavorite = favorite != null && favorite.length > 0;
const fill = isFavorite ? 'starFilled' : 'starEmpty';
return <EuiIcon data-test-subj={`favorite-${fill}-star`} type={fill} size="m" />;
},
sortable: false,
width: ACTION_COLUMN_WIDTH,
},
};
const templateColumns = [columns.note, columns.favorite];
const defaultColumns = [columns.pinnedEvent, columns.note, columns.favorite];
return timelineType === TimelineType.template ? templateColumns : defaultColumns;
};

View file

@ -24,6 +24,7 @@ import { getActionsColumns } from './actions_columns';
import { getCommonColumns } from './common_columns';
import { getExtendedColumns } from './extended_columns';
import { getIconHeaderColumns } from './icon_header_columns';
import { TimelineTypeLiteralWithNull } from '../../../../../common/types/timeline';
// there are a number of type mismatches across this file
const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any
@ -58,6 +59,7 @@ export const getTimelinesTableColumns = ({
onOpenTimeline,
onToggleShowNotes,
showExtendedColumns,
timelineType,
}: {
actionTimelineToShow: ActionTimelineToShow[];
deleteTimelines?: DeleteTimelines;
@ -68,6 +70,7 @@ export const getTimelinesTableColumns = ({
onSelectionChange: OnSelectionChange;
onToggleShowNotes: OnToggleShowNotes;
showExtendedColumns: boolean;
timelineType: TimelineTypeLiteralWithNull;
}) => {
return [
...getCommonColumns({
@ -76,7 +79,7 @@ export const getTimelinesTableColumns = ({
onToggleShowNotes,
}),
...getExtendedColumnsIfEnabled(showExtendedColumns),
...getIconHeaderColumns(),
...getIconHeaderColumns({ timelineType }),
...getActionsColumns({
actionTimelineToShow,
deleteTimelines,
@ -105,6 +108,7 @@ export interface TimelinesTableProps {
showExtendedColumns: boolean;
sortDirection: 'asc' | 'desc';
sortField: string;
timelineType: TimelineTypeLiteralWithNull;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tableRef?: React.MutableRefObject<_EuiBasicTable<any> | undefined>;
totalSearchResultsCount: number;
@ -134,6 +138,7 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
sortField,
sortDirection,
tableRef,
timelineType,
totalSearchResultsCount,
}) => {
const pagination = {
@ -174,6 +179,7 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
onSelectionChange,
onToggleShowNotes,
showExtendedColumns,
timelineType,
})}
compressed
data-test-subj="timelines-table"

View file

@ -7,6 +7,7 @@ import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines_page';
import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants';
import { OpenTimelineResult } from '../types';
import { TimelinesTableProps } from '.';
import { TimelineType } from '../../../../../common/types/timeline';
export const getMockTimelinesTableProps = (
mockOpenTimelineResults: OpenTimelineResult[]
@ -28,5 +29,6 @@ export const getMockTimelinesTableProps = (
showExtendedColumns: true,
sortDirection: DEFAULT_SORT_DIRECTION,
sortField: DEFAULT_SORT_FIELD,
timelineType: TimelineType.default,
totalSearchResultsCount: mockOpenTimelineResults.length,
});

View file

@ -220,6 +220,20 @@ export const TAB_TEMPLATES = i18n.translate(
}
);
export const FILTER_ELASTIC_TIMELINES = i18n.translate(
'xpack.securitySolution.timelines.components.templateFilter.elasticTitle',
{
defaultMessage: 'Elastic templates',
}
);
export const FILTER_CUSTOM_TIMELINES = i18n.translate(
'xpack.securitySolution.timelines.components.templateFilter.customizedTitle',
{
defaultMessage: 'Custom templates',
}
);
export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate(
'xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle',
{
@ -230,7 +244,7 @@ export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate(
export const SELECT_TIMELINE = i18n.translate(
'xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription',
{
defaultMessage: 'Select a SIEM timeline (as exported from the Timeline view) to import',
defaultMessage: 'Select a Security timeline (as exported from the Timeline view) to import',
}
);
@ -280,3 +294,10 @@ export const IMPORT_FAILED_DETAILED = (id: string, statusCode: number, message:
defaultMessage: 'Timeline ID: {id}\n Status Code: {statusCode}\n Message: {message}',
}
);
export const TEMPLATE_CALL_OUT_MESSAGE = i18n.translate(
'xpack.securitySolution.timelines.components.templateCallOutMessageTitle',
{
defaultMessage: 'Now you can add timeline templates and link it to rules.',
}
);

View file

@ -8,7 +8,12 @@ import { SetStateAction, Dispatch } from 'react';
import { AllTimelinesVariables } from '../../containers/all';
import { TimelineModel } from '../../store/timeline/model';
import { NoteResult } from '../../../graphql/types';
import { TimelineTypeLiteral } from '../../../../common/types/timeline';
import {
TimelineTypeLiteral,
TimelineTypeLiteralWithNull,
TimelineStatus,
TemplateTimelineTypeLiteral,
} from '../../../../common/types/timeline';
/** The users who added a timeline to favorites */
export interface FavoriteTimelineResult {
@ -46,6 +51,7 @@ export interface OpenTimelineResult {
notes?: TimelineResultNote[] | null;
pinnedEventIds?: Readonly<Record<string, boolean>> | null;
savedObjectId?: string | null;
status?: TimelineStatus | null;
title?: string | null;
templateTimelineId?: string | null;
type?: TimelineTypeLiteral;
@ -118,6 +124,8 @@ export interface OpenTimelineProps {
deleteTimelines?: DeleteTimelines;
/** The default requested size of each page of search results */
defaultPageSize: number;
/** The number of favorite timeline*/
favoriteCount?: number | null | undefined;
/** Displays an indicator that data is loading when true */
isLoading: boolean;
/** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */
@ -160,8 +168,12 @@ export interface OpenTimelineProps {
sortDirection: 'asc' | 'desc';
/** the requested field to sort on */
sortField: string;
/** this affects timeline's behaviour like editable / duplicatible */
timelineType: TimelineTypeLiteralWithNull;
/** when timelineType === template, templatetimelineFilter is a JSX.Element */
templateTimelineFilter: JSX.Element[] | null;
/** timeline / template timeline */
tabs?: JSX.Element;
timelineFilter?: JSX.Element | JSX.Element[] | null;
/** The title of the Open Timeline component */
title: string;
/** The total (server-side) count of the search results */
@ -196,9 +208,19 @@ export enum TimelineTabsStyle {
}
export interface TimelineTab {
id: TimelineTypeLiteral;
name: string;
count: number | undefined;
disabled: boolean;
href: string;
id: TimelineTypeLiteral;
name: string;
onClick: (ev: { preventDefault: () => void }) => void;
withNext: boolean;
}
export interface TemplateTimelineFilter {
id: TemplateTimelineTypeLiteral;
name: string;
disabled: boolean;
withNext: boolean;
count: number | undefined;
}

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useCallback, useMemo } from 'react';
import { EuiFilterButton } from '@elastic/eui';
import {
TimelineStatus,
TimelineType,
TimelineTypeLiteralWithNull,
TemplateTimelineType,
TemplateTimelineTypeLiteralWithNull,
TimelineStatusLiteralWithNull,
} from '../../../../common/types/timeline';
import * as i18n from './translations';
import { TemplateTimelineFilter } from './types';
import { disableTemplate } from '../../../../common/constants';
export const useTimelineStatus = ({
timelineType,
elasticTemplateTimelineCount,
customTemplateTimelineCount,
}: {
timelineType: TimelineTypeLiteralWithNull;
elasticTemplateTimelineCount?: number | null;
customTemplateTimelineCount?: number | null;
}): {
timelineStatus: TimelineStatusLiteralWithNull;
templateTimelineType: TemplateTimelineTypeLiteralWithNull;
templateTimelineFilter: JSX.Element[] | null;
} => {
const [selectedTab, setSelectedTab] = useState<TemplateTimelineTypeLiteralWithNull>(
disableTemplate ? null : TemplateTimelineType.elastic
);
const isTemplateFilterEnabled = useMemo(() => timelineType === TimelineType.template, [
timelineType,
]);
const templateTimelineType = useMemo(
() => (disableTemplate || !isTemplateFilterEnabled ? null : selectedTab),
[selectedTab, isTemplateFilterEnabled]
);
const timelineStatus = useMemo(
() =>
templateTimelineType == null
? null
: templateTimelineType === TemplateTimelineType.elastic
? TimelineStatus.immutable
: TimelineStatus.active,
[templateTimelineType]
);
const filters = useMemo(
() => [
{
id: TemplateTimelineType.elastic,
name: i18n.FILTER_ELASTIC_TIMELINES,
disabled: !isTemplateFilterEnabled,
withNext: true,
count: elasticTemplateTimelineCount ?? undefined,
},
{
id: TemplateTimelineType.custom,
name: i18n.FILTER_CUSTOM_TIMELINES,
disabled: !isTemplateFilterEnabled,
withNext: false,
count: customTemplateTimelineCount ?? undefined,
},
],
[customTemplateTimelineCount, elasticTemplateTimelineCount, isTemplateFilterEnabled]
);
const onFilterClicked = useCallback(
(tabId) => {
if (selectedTab === tabId) {
setSelectedTab(null);
} else {
setSelectedTab(tabId);
}
},
[setSelectedTab, selectedTab]
);
const templateTimelineFilter = useMemo(() => {
return isTemplateFilterEnabled
? filters.map((tab: TemplateTimelineFilter) => (
<EuiFilterButton
hasActiveFilters={tab.id === templateTimelineType}
key={`template-timeline-filter-${tab.id}`}
numFilters={tab.count}
onClick={onFilterClicked.bind(null, tab.id)}
withNext={tab.withNext}
isDisabled={tab.disabled}
>
{tab.name}
</EuiFilterButton>
))
: null;
}, [templateTimelineType, filters, isTemplateFilterEnabled, onFilterClicked]);
return {
timelineStatus,
templateTimelineType,
templateTimelineFilter,
};
};

View file

@ -13,10 +13,16 @@ import { getTimelineTabsUrl, useFormatUrl } from '../../../common/components/lin
import * as i18n from './translations';
import { TimelineTabsStyle, TimelineTab } from './types';
export const useTimelineTypes = (): {
export const useTimelineTypes = ({
defaultTimelineCount,
templateTimelineCount,
}: {
defaultTimelineCount?: number | null;
templateTimelineCount?: number | null;
}): {
timelineType: TimelineTypeLiteralWithNull;
timelineTabs: JSX.Element;
timelineFilters: JSX.Element;
timelineFilters: JSX.Element[];
} => {
const history = useHistory();
const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.timelines);
@ -40,35 +46,52 @@ export const useTimelineTypes = (): {
},
[history, urlSearch]
);
const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = (
timelineTabsStyle: TimelineTabsStyle
) => [
{
id: TimelineType.default,
name:
timelineTabsStyle === TimelineTabsStyle.filter
? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES)
: i18n.TAB_TIMELINES,
href: formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)),
disabled: false,
onClick: goToTimeline,
},
{
id: TimelineType.template,
name:
timelineTabsStyle === TimelineTabsStyle.filter
? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES)
: i18n.TAB_TEMPLATES,
href: formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)),
disabled: false,
onClick: goToTemplateTimeline,
},
];
const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = useCallback(
(timelineTabsStyle: TimelineTabsStyle) => [
{
id: TimelineType.default,
name:
timelineTabsStyle === TimelineTabsStyle.filter
? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES)
: i18n.TAB_TIMELINES,
href: formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)),
disabled: false,
withNext: true,
count:
timelineTabsStyle === TimelineTabsStyle.filter
? defaultTimelineCount ?? undefined
: undefined,
onClick: goToTimeline,
},
{
id: TimelineType.template,
name:
timelineTabsStyle === TimelineTabsStyle.filter
? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES)
: i18n.TAB_TEMPLATES,
href: formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)),
disabled: false,
withNext: false,
count:
timelineTabsStyle === TimelineTabsStyle.filter
? templateTimelineCount ?? undefined
: undefined,
onClick: goToTemplateTimeline,
},
],
[
defaultTimelineCount,
templateTimelineCount,
urlSearch,
formatUrl,
goToTimeline,
goToTemplateTimeline,
]
);
const onFilterClicked = useCallback(
(timelineTabsStyle, tabId) => {
if (timelineTabsStyle === TimelineTabsStyle.filter && tabId === timelineType) {
(tabId) => {
if (tabId === timelineType) {
setTimelineTypes(null);
} else {
setTimelineTypes(tabId);
@ -89,7 +112,7 @@ export const useTimelineTypes = (): {
href={tab.href}
onClick={(ev) => {
tab.onClick(ev);
onFilterClicked(TimelineTabsStyle.tab, tab.id);
onFilterClicked(tab.id);
}}
>
{tab.name}
@ -103,24 +126,21 @@ export const useTimelineTypes = (): {
}, [tabName]);
const timelineFilters = useMemo(() => {
return (
<>
{getFilterOrTabs(TimelineTabsStyle.tab).map((tab: TimelineTab) => (
<EuiFilterButton
hasActiveFilters={tab.id === timelineType}
key={`timeline-${TimelineTabsStyle.filter}-${tab.id}`}
onClick={(ev: { preventDefault: () => void }) => {
tab.onClick(ev);
onFilterClicked.bind(null, TimelineTabsStyle.filter, tab.id);
}}
>
{tab.name}
</EuiFilterButton>
))}
</>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineType]);
return getFilterOrTabs(TimelineTabsStyle.filter).map((tab: TimelineTab) => (
<EuiFilterButton
hasActiveFilters={tab.id === timelineType}
key={`timeline-${TimelineTabsStyle.filter}-${tab.id}`}
numFilters={tab.count}
onClick={(ev: { preventDefault: () => void }) => {
tab.onClick(ev);
onFilterClicked(tab.id);
}}
withNext={tab.withNext}
>
{tab.name}
</EuiFilterButton>
));
}, [timelineType, getFilterOrTabs, onFilterClicked]);
return {
timelineType,

View file

@ -926,6 +926,7 @@ In other use cases the message field can be used to concatenate different values
}
}
start={1521830963132}
status="active"
toggleColumn={[MockFunction]}
usersViewing={
Array [

View file

@ -5,13 +5,24 @@
*/
import { mount } from 'enzyme';
import React from 'react';
import { useSelector } from 'react-redux';
import { TestProviders } from '../../../../../common/mock';
import { TestProviders, mockTimelineModel } from '../../../../../common/mock';
import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants';
import { Actions } from '.';
jest.mock('react-redux', () => {
const origin = jest.requireActual('react-redux');
return {
...origin,
useSelector: jest.fn(),
};
});
describe('Actions', () => {
(useSelector as jest.Mock).mockReturnValue(mockTimelineModel);
test('it renders a checkbox for selecting the event when `showCheckboxes` is `true`', () => {
const wrapper = mount(
<TestProviders>

View file

@ -3,10 +3,15 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
import React from 'react';
import { useSelector } from 'react-redux';
import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
import { Note } from '../../../../../common/lib/note';
import { StoreState } from '../../../../../common/store/types';
import { TimelineModel } from '../../../../store/timeline/model';
import { AssociateNote, UpdateNote } from '../../../notes/helpers';
import { Pin } from '../../pin';
import { NotesButton } from '../../properties/helpers';
@ -79,92 +84,101 @@ export const Actions = React.memo<Props>(
showNotes,
toggleShowNotes,
updateNote,
}) => (
<EventsTdGroupActions
actionsColumnWidth={actionsColumnWidth}
data-test-subj="event-actions-container"
>
{showCheckboxes && (
<EventsTd data-test-subj="select-event-container">
}) => {
const timeline = useSelector<StoreState, TimelineModel>((state) => {
return state.timeline.timelineById['timeline-1'];
});
return (
<EventsTdGroupActions
actionsColumnWidth={actionsColumnWidth}
data-test-subj="event-actions-container"
>
{showCheckboxes && (
<EventsTd data-test-subj="select-event-container">
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
{loadingEventIds.includes(eventId) ? (
<EuiLoadingSpinner size="m" data-test-subj="event-loader" />
) : (
<EuiCheckbox
data-test-subj="select-event"
id={eventId}
checked={checked}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onRowSelected({
eventIds: [eventId],
isSelected: event.currentTarget.checked,
});
}}
/>
)}
</EventsTdContent>
</EventsTd>
)}
<EventsTd>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
{loadingEventIds.includes(eventId) ? (
<EuiLoadingSpinner size="m" data-test-subj="event-loader" />
) : (
<EuiCheckbox
data-test-subj="select-event"
{loading && <EventsLoading />}
{!loading && (
<EuiButtonIcon
aria-label={expanded ? i18n.COLLAPSE : i18n.EXPAND}
data-test-subj="expand-event"
iconType={expanded ? 'arrowDown' : 'arrowRight'}
id={eventId}
checked={checked}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onRowSelected({
eventIds: [eventId],
isSelected: event.currentTarget.checked,
});
}}
onClick={onEventToggled}
/>
)}
</EventsTdContent>
</EventsTd>
)}
<EventsTd>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
{loading && <EventsLoading />}
<>{additionalActions}</>
{!loading && (
<EuiButtonIcon
aria-label={expanded ? i18n.COLLAPSE : i18n.EXPAND}
data-test-subj="expand-event"
iconType={expanded ? 'arrowDown' : 'arrowRight'}
id={eventId}
onClick={onEventToggled}
/>
)}
</EventsTdContent>
</EventsTd>
{!isEventViewer && (
<>
<EventsTd>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
<EuiToolTip
data-test-subj="timeline-action-pin-tool-tip"
content={getPinTooltip({
isPinned: eventIsPinned,
eventHasNotes: eventHasNotes(noteIds),
timelineType: timeline.timelineType,
})}
>
<Pin
allowUnpinning={!eventHasNotes(noteIds)}
data-test-subj="pin-event"
onClick={onPinClicked}
pinned={eventIsPinned}
timelineType={timeline.timelineType}
/>
</EuiToolTip>
</EventsTdContent>
</EventsTd>
<>{additionalActions}</>
{!isEventViewer && (
<>
<EventsTd>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
<EuiToolTip
data-test-subj="timeline-action-pin-tool-tip"
content={getPinTooltip({
isPinned: eventIsPinned,
eventHasNotes: eventHasNotes(noteIds),
})}
>
<Pin
allowUnpinning={!eventHasNotes(noteIds)}
data-test-subj="pin-event"
onClick={onPinClicked}
pinned={eventIsPinned}
<EventsTd>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
<NotesButton
animate={false}
associateNote={associateNote}
data-test-subj="add-note"
getNotesByIds={getNotesByIds}
noteIds={noteIds || emptyNotes}
showNotes={showNotes}
size="s"
status={timeline.status}
timelineType={timeline.timelineType}
toggleShowNotes={toggleShowNotes}
toolTip={timeline.timelineType ? i18n.NOTES_DISABLE_TOOLTIP : i18n.NOTES_TOOLTIP}
updateNote={updateNote}
/>
</EuiToolTip>
</EventsTdContent>
</EventsTd>
<EventsTd>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
<NotesButton
animate={false}
associateNote={associateNote}
data-test-subj="add-note"
getNotesByIds={getNotesByIds}
noteIds={noteIds || emptyNotes}
showNotes={showNotes}
size="s"
toggleShowNotes={toggleShowNotes}
toolTip={i18n.NOTES_TOOLTIP}
updateNote={updateNote}
/>
</EventsTdContent>
</EventsTd>
</>
)}
</EventsTdGroupActions>
),
</EventsTdContent>
</EventsTd>
</>
)}
</EventsTdGroupActions>
);
},
(nextProps, prevProps) => {
return (
prevProps.actionsColumnWidth === nextProps.actionsColumnWidth &&

View file

@ -5,6 +5,7 @@
*/
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useSelector } from 'react-redux';
import uuid from 'uuid';
import VisibilitySensor from 'react-visibility-sensor';
@ -13,7 +14,7 @@ import { TimelineDetailsQuery } from '../../../../containers/details';
import { TimelineItem, DetailItem, TimelineNonEcsData } from '../../../../../graphql/types';
import { requestIdleCallbackViaScheduler } from '../../../../../common/lib/helpers/scheduler';
import { Note } from '../../../../../common/lib/note';
import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model';
import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model';
import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers';
import { SkeletonRow } from '../../skeleton_row';
import {
@ -33,6 +34,7 @@ import { getEventType } from '../helpers';
import { NoteCards } from '../../../notes/note_cards';
import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context';
import { EventColumnView } from './event_column_view';
import { StoreState } from '../../../../../common/store';
interface Props {
actionsColumnWidth: number;
@ -128,7 +130,9 @@ const StatefulEventComponent: React.FC<Props> = ({
const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({});
const [initialRender, setInitialRender] = useState(false);
const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({});
const timeline = useSelector<StoreState, TimelineModel>((state) => {
return state.timeline.timelineById['timeline-1'];
});
const divElement = useRef<HTMLDivElement | null>(null);
const onToggleShowNotes = useCallback(() => {
@ -251,6 +255,7 @@ const StatefulEventComponent: React.FC<Props> = ({
getNotesByIds={getNotesByIds}
noteIds={eventIdToNoteIds[event._id] || emptyNotes}
showAddNote={!!showNotes[event._id]}
status={timeline.status}
toggleShowAddNote={onToggleShowNotes}
updateNote={updateNote}
/>

View file

@ -7,6 +7,7 @@
import { Ecs } from '../../../../graphql/types';
import { eventHasNotes, eventIsPinned, getPinTooltip, stringifyEvent } from './helpers';
import { TimelineType } from '../../../../../common/types/timeline';
describe('helpers', () => {
describe('stringifyEvent', () => {
@ -192,21 +193,37 @@ describe('helpers', () => {
describe('getPinTooltip', () => {
test('it indicates the event may NOT be unpinned when `isPinned` is `true` and the event has notes', () => {
expect(getPinTooltip({ isPinned: true, eventHasNotes: true })).toEqual(
'This event cannot be unpinned because it has notes'
);
expect(
getPinTooltip({ isPinned: true, eventHasNotes: true, timelineType: TimelineType.default })
).toEqual('This event cannot be unpinned because it has notes');
});
test('it indicates the event is pinned when `isPinned` is `true` and the event does NOT have notes', () => {
expect(getPinTooltip({ isPinned: true, eventHasNotes: false })).toEqual('Pinned event');
expect(
getPinTooltip({ isPinned: true, eventHasNotes: false, timelineType: TimelineType.default })
).toEqual('Pinned event');
});
test('it indicates the event is NOT pinned when `isPinned` is `false` and the event has notes', () => {
expect(getPinTooltip({ isPinned: false, eventHasNotes: true })).toEqual('Unpinned event');
expect(
getPinTooltip({ isPinned: false, eventHasNotes: true, timelineType: TimelineType.default })
).toEqual('Unpinned event');
});
test('it indicates the event is NOT pinned when `isPinned` is `false` and the event does NOT have notes', () => {
expect(getPinTooltip({ isPinned: false, eventHasNotes: false })).toEqual('Unpinned event');
expect(
getPinTooltip({ isPinned: false, eventHasNotes: false, timelineType: TimelineType.default })
).toEqual('Unpinned event');
});
test('it indicates the event is disabled if timelineType is template', () => {
expect(
getPinTooltip({
isPinned: false,
eventHasNotes: false,
timelineType: TimelineType.template,
})
).toEqual('This event cannot be pinned because it is filtered by a timeline template');
});
});

View file

@ -15,6 +15,7 @@ import { OnPinEvent, OnUnPinEvent } from '../events';
import { TimelineRowAction, TimelineRowActionOnClick } from './actions';
import * as i18n from './translations';
import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const omitTypenameAndEmpty = (k: string, v: any): any | undefined =>
@ -28,10 +29,19 @@ export const getPinTooltip = ({
isPinned,
// eslint-disable-next-line no-shadow
eventHasNotes,
timelineType,
}: {
isPinned: boolean;
eventHasNotes: boolean;
}) => (isPinned && eventHasNotes ? i18n.PINNED_WITH_NOTES : isPinned ? i18n.PINNED : i18n.UNPINNED);
timelineType: TimelineTypeLiteral;
}) =>
timelineType === TimelineType.template
? i18n.DISABLE_PIN
: isPinned && eventHasNotes
? i18n.PINNED_WITH_NOTES
: isPinned
? i18n.PINNED
: i18n.UNPINNED;
export interface IsPinnedParams {
eventId: string;

View file

@ -5,10 +5,11 @@
*/
import React from 'react';
import { useSelector } from 'react-redux';
import { mockBrowserFields } from '../../../../common/containers/source/mock';
import { Direction } from '../../../../graphql/types';
import { defaultHeaders, mockTimelineData } from '../../../../common/mock';
import { defaultHeaders, mockTimelineData, mockTimelineModel } from '../../../../common/mock';
import { TestProviders } from '../../../../common/mock/test_providers';
import { Body, BodyProps } from '.';
@ -24,6 +25,13 @@ const mockSort: Sort = {
sortDirection: Direction.desc,
};
jest.mock('react-redux', () => {
const origin = jest.requireActual('react-redux');
return {
...origin,
useSelector: jest.fn(),
};
});
jest.mock('../../../../common/components/link_to');
jest.mock(
@ -41,41 +49,43 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({
describe('Body', () => {
const mount = useMountAppended();
const props: BodyProps = {
addNoteToEvent: jest.fn(),
browserFields: mockBrowserFields,
columnHeaders: defaultHeaders,
columnRenderers,
data: mockTimelineData,
eventIdToNoteIds: {},
height: testBodyHeight,
id: 'timeline-test',
isSelectAllChecked: false,
getNotesByIds: mockGetNotesByIds,
loadingEventIds: [],
onColumnRemoved: jest.fn(),
onColumnResized: jest.fn(),
onColumnSorted: jest.fn(),
onFilterChange: jest.fn(),
onPinEvent: jest.fn(),
onRowSelected: jest.fn(),
onSelectAll: jest.fn(),
onUnPinEvent: jest.fn(),
onUpdateColumns: jest.fn(),
pinnedEventIds: {},
rowRenderers,
selectedEventIds: {},
show: true,
sort: mockSort,
showCheckboxes: false,
toggleColumn: jest.fn(),
updateNote: jest.fn(),
};
(useSelector as jest.Mock).mockReturnValue(mockTimelineModel);
describe('rendering', () => {
test('it renders the column headers', () => {
const wrapper = mount(
<TestProviders>
<Body
addNoteToEvent={jest.fn()}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={jest.fn()}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
<Body {...props} />
</TestProviders>
);
@ -85,36 +95,7 @@ describe('Body', () => {
test('it renders the scroll container', () => {
const wrapper = mount(
<TestProviders>
<Body
addNoteToEvent={jest.fn()}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={jest.fn()}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
<Body {...props} />
</TestProviders>
);
@ -124,36 +105,7 @@ describe('Body', () => {
test('it renders events', () => {
const wrapper = mount(
<TestProviders>
<Body
addNoteToEvent={jest.fn()}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={jest.fn()}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
<Body {...props} />
</TestProviders>
);
@ -162,39 +114,10 @@ describe('Body', () => {
test('it renders a tooltip for timestamp', async () => {
const headersJustTimestamp = defaultHeaders.filter((h) => h.id === '@timestamp');
const testProps = { ...props, columnHeaders: headersJustTimestamp };
const wrapper = mount(
<TestProviders>
<Body
addNoteToEvent={jest.fn()}
browserFields={mockBrowserFields}
columnHeaders={headersJustTimestamp}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={jest.fn()}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
<Body {...testProps} />
</TestProviders>
);
wrapper.update();
@ -215,6 +138,11 @@ describe('Body', () => {
describe('action on event', () => {
const dispatchAddNoteToEvent = jest.fn();
const dispatchOnPinEvent = jest.fn();
const testProps = {
...props,
addNoteToEvent: dispatchAddNoteToEvent,
onPinEvent: dispatchOnPinEvent,
};
const addaNoteToEvent = (wrapper: ReturnType<typeof mount>, note: string) => {
wrapper.find('[data-test-subj="add-note"]').first().find('button').simulate('click');
@ -251,36 +179,7 @@ describe('Body', () => {
test('Add a Note to an event', () => {
const wrapper = mount(
<TestProviders>
<Body
addNoteToEvent={dispatchAddNoteToEvent}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={dispatchOnPinEvent}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
<Body {...testProps} />
</TestProviders>
);
addaNoteToEvent(wrapper, 'hello world');
@ -290,44 +189,13 @@ describe('Body', () => {
});
test('Add two Note to an event', () => {
const Proxy = (props: BodyProps) => (
const Proxy = (proxyProps: BodyProps) => (
<TestProviders>
<Body {...props} />
<Body {...proxyProps} />
</TestProviders>
);
const wrapper = mount(
<Proxy
addNoteToEvent={dispatchAddNoteToEvent}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={dispatchOnPinEvent}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
);
const wrapper = mount(<Proxy {...testProps} />);
addaNoteToEvent(wrapper, 'hello world');
dispatchAddNoteToEvent.mockClear();
dispatchOnPinEvent.mockClear();

View file

@ -13,6 +13,13 @@ export const NOTES_TOOLTIP = i18n.translate(
}
);
export const NOTES_DISABLE_TOOLTIP = i18n.translate(
'xpack.securitySolution.timeline.body.notes.disableEventTooltip',
{
defaultMessage: 'Add notes for event filtered by a timeline template is not allowed',
}
);
export const COPY_TO_CLIPBOARD = i18n.translate(
'xpack.securitySolution.timeline.body.copyToClipboardButtonLabel',
{
@ -38,6 +45,13 @@ export const PINNED_WITH_NOTES = i18n.translate(
}
);
export const DISABLE_PIN = i18n.translate(
'xpack.securitySolution.timeline.body.pinning.disablePinnnedTooltip',
{
defaultMessage: 'This event cannot be pinned because it is filtered by a timeline template',
}
);
export const EXPAND = i18n.translate(
'xpack.securitySolution.timeline.body.actions.expandAriaLabel',
{

View file

@ -15,6 +15,7 @@ import { mockDataProviders } from '../data_providers/mock/mock_data_providers';
import { useMountAppended } from '../../../../common/utils/use_mount_appended';
import { TimelineHeader } from '.';
import { TimelineStatus } from '../../../../../common/types/timeline';
const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings;
@ -23,43 +24,32 @@ jest.mock('../../../../common/lib/kibana');
describe('Header', () => {
const indexPattern = mockIndexPattern;
const mount = useMountAppended();
const props = {
browserFields: {},
dataProviders: mockDataProviders,
filterManager: new FilterManager(mockUiSettingsForFilterManager),
id: 'foo',
indexPattern,
onDataProviderEdited: jest.fn(),
onDataProviderRemoved: jest.fn(),
onToggleDataProviderEnabled: jest.fn(),
onToggleDataProviderExcluded: jest.fn(),
show: true,
showCallOutUnauthorizedMsg: false,
status: TimelineStatus.active,
};
describe('rendering', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<TimelineHeader
browserFields={{}}
dataProviders={mockDataProviders}
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
onToggleDataProviderExcluded={jest.fn()}
show={true}
showCallOutUnauthorizedMsg={false}
/>
);
const wrapper = shallow(<TimelineHeader {...props} />);
expect(wrapper).toMatchSnapshot();
});
test('it renders the data providers when show is true', () => {
const testProps = { ...props, show: true };
const wrapper = mount(
<TestProviders>
<TimelineHeader
browserFields={{}}
dataProviders={mockDataProviders}
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
onToggleDataProviderExcluded={jest.fn()}
show={true}
showCallOutUnauthorizedMsg={false}
/>
<TimelineHeader {...testProps} />
</TestProviders>
);
@ -67,21 +57,11 @@ describe('Header', () => {
});
test('it does NOT render the data providers when show is false', () => {
const testProps = { ...props, show: false };
const wrapper = mount(
<TestProviders>
<TimelineHeader
browserFields={{}}
dataProviders={mockDataProviders}
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
onToggleDataProviderExcluded={jest.fn()}
show={false}
showCallOutUnauthorizedMsg={false}
/>
<TimelineHeader {...testProps} />
</TestProviders>
);
@ -89,21 +69,15 @@ describe('Header', () => {
});
test('it renders the unauthorized call out providers', () => {
const testProps = {
...props,
filterManager: new FilterManager(mockUiSettingsForFilterManager),
showCallOutUnauthorizedMsg: true,
};
const wrapper = mount(
<TestProviders>
<TimelineHeader
browserFields={{}}
dataProviders={mockDataProviders}
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
onToggleDataProviderExcluded={jest.fn()}
show={true}
showCallOutUnauthorizedMsg={true}
/>
<TimelineHeader {...testProps} />
</TestProviders>
);

View file

@ -22,6 +22,10 @@ import { StatefulSearchOrFilter } from '../search_or_filter';
import { BrowserFields } from '../../../../common/containers/source';
import * as i18n from './translations';
import {
TimelineStatus,
TimelineStatusLiteralWithNull,
} from '../../../../../common/types/timeline';
interface Props {
browserFields: BrowserFields;
@ -36,6 +40,7 @@ interface Props {
onToggleDataProviderExcluded: OnToggleDataProviderExcluded;
show: boolean;
showCallOutUnauthorizedMsg: boolean;
status: TimelineStatusLiteralWithNull;
}
const TimelineHeaderComponent: React.FC<Props> = ({
@ -51,6 +56,7 @@ const TimelineHeaderComponent: React.FC<Props> = ({
onToggleDataProviderExcluded,
show,
showCallOutUnauthorizedMsg,
status,
}) => (
<>
{showCallOutUnauthorizedMsg && (
@ -62,7 +68,15 @@ const TimelineHeaderComponent: React.FC<Props> = ({
size="s"
/>
)}
{status === TimelineStatus.immutable && (
<EuiCallOut
data-test-subj="timelineImmutableCallOut"
title={i18n.CALL_OUT_IMMUTIABLE}
color="primary"
iconType="info"
size="s"
/>
)}
{show && !showGraphView(graphEventId) && (
<>
<DataProviders
@ -100,5 +114,6 @@ export const TimelineHeader = React.memo(
prevProps.onToggleDataProviderEnabled === nextProps.onToggleDataProviderEnabled &&
prevProps.onToggleDataProviderExcluded === nextProps.onToggleDataProviderExcluded &&
prevProps.show === nextProps.show &&
prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg
prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg &&
prevProps.status === nextProps.status
);

View file

@ -13,3 +13,11 @@ export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate(
'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.',
}
);
export const CALL_OUT_IMMUTIABLE = i18n.translate(
'xpack.securitySolution.timeline.callOut.immutable.message.description',
{
defaultMessage:
'This timeline is immutable, therefore not allowed to save it within the security application, though you may continue to use the timeline to search and filter security events',
}
);

View file

@ -25,6 +25,7 @@ import { Sort } from './body/sort';
import { mockDataProviders } from './data_providers/mock/mock_data_providers';
import { StatefulTimeline, Props as StatefulTimelineProps } from './index';
import { Timeline } from './timeline';
import { TimelineType, TimelineStatus } from '../../../../common/types/timeline';
jest.mock('../../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../../common/lib/kibana');
@ -88,6 +89,8 @@ describe('StatefulTimeline', () => {
showCallOutUnauthorizedMsg: false,
sort,
start: startDate,
status: TimelineStatus.active,
timelineType: TimelineType.default,
updateColumns: timelineActions.updateColumns,
updateDataProviderEnabled: timelineActions.updateDataProviderEnabled,
updateDataProviderExcluded: timelineActions.updateDataProviderExcluded,

View file

@ -57,6 +57,8 @@ const StatefulTimelineComponent = React.memo<Props>(
showCallOutUnauthorizedMsg,
sort,
start,
status,
timelineType,
updateDataProviderEnabled,
updateDataProviderExcluded,
updateItemsPerPage,
@ -189,6 +191,7 @@ const StatefulTimelineComponent = React.memo<Props>(
showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg}
sort={sort!}
start={start}
status={status}
toggleColumn={toggleColumn}
usersViewing={usersViewing}
/>
@ -207,6 +210,8 @@ const StatefulTimelineComponent = React.memo<Props>(
prevProps.show === nextProps.show &&
prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg &&
prevProps.start === nextProps.start &&
prevProps.timelineType === nextProps.timelineType &&
prevProps.status === nextProps.status &&
deepEqual(prevProps.columns, nextProps.columns) &&
deepEqual(prevProps.dataProviders, nextProps.dataProviders) &&
deepEqual(prevProps.filters, nextProps.filters) &&
@ -238,11 +243,12 @@ const makeMapStateToProps = () => {
kqlMode,
show,
sort,
status,
timelineType,
} = timeline;
const kqlQueryExpression = getKqlQueryTimeline(state, id)!;
const timelineFilter = kqlMode === 'filter' ? filters || [] : [];
return {
columns,
dataProviders,
@ -261,6 +267,8 @@ const makeMapStateToProps = () => {
showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state),
sort,
start: input.timerange.from,
status,
timelineType,
};
};
return mapStateToProps;

View file

@ -8,6 +8,8 @@ import { EuiButtonIcon, IconSize } from '@elastic/eui';
import { noop } from 'lodash/fp';
import React from 'react';
import { TimelineType, TimelineTypeLiteral } from '../../../../../common/types/timeline';
import * as i18n from '../body/translations';
export type PinIcon = 'pin' | 'pinFilled';
@ -17,21 +19,25 @@ export const getPinIcon = (pinned: boolean): PinIcon => (pinned ? 'pinFilled' :
interface Props {
allowUnpinning: boolean;
iconSize?: IconSize;
timelineType?: TimelineTypeLiteral;
onClick?: () => void;
pinned: boolean;
}
export const Pin = React.memo<Props>(
({ allowUnpinning, iconSize = 'm', onClick = noop, pinned }) => (
<EuiButtonIcon
aria-label={pinned ? i18n.PINNED : i18n.UNPINNED}
data-test-subj="pin"
iconSize={iconSize}
iconType={getPinIcon(pinned)}
isDisabled={allowUnpinning ? false : true}
onClick={onClick}
/>
)
({ allowUnpinning, iconSize = 'm', onClick = noop, pinned, timelineType }) => {
const isTemplate = timelineType === TimelineType.template;
return (
<EuiButtonIcon
aria-label={pinned ? i18n.PINNED : i18n.UNPINNED}
data-test-subj="pin"
iconSize={iconSize}
iconType={getPinIcon(pinned)}
onClick={onClick}
isDisabled={isTemplate}
/>
);
}
);
Pin.displayName = 'Pin';

View file

@ -27,6 +27,7 @@ import {
TimelineTypeLiteral,
TimelineStatus,
TimelineType,
TimelineStatusLiteral,
TimelineId,
} from '../../../../../common/types/timeline';
import { SecurityPageName } from '../../../../app/types';
@ -262,11 +263,13 @@ interface NotesButtonProps {
getNotesByIds: (noteIds: string[]) => Note[];
noteIds: string[];
size: 's' | 'l';
status: TimelineStatusLiteral;
showNotes: boolean;
toggleShowNotes: () => void;
text?: string;
toolTip?: string;
updateNote: UpdateNote;
timelineType: TimelineTypeLiteral;
}
const getNewNoteId = (): string => uuid.v4();
@ -303,16 +306,24 @@ LargeNotesButton.displayName = 'LargeNotesButton';
interface SmallNotesButtonProps {
noteIds: string[];
toggleShowNotes: () => void;
timelineType: TimelineTypeLiteral;
}
const SmallNotesButton = React.memo<SmallNotesButtonProps>(({ noteIds, toggleShowNotes }) => (
<EuiButtonIcon
aria-label={i18n.NOTES}
data-test-subj="timeline-notes-button-small"
iconType="editorComment"
onClick={() => toggleShowNotes()}
/>
));
const SmallNotesButton = React.memo<SmallNotesButtonProps>(
({ noteIds, toggleShowNotes, timelineType }) => {
const isTemplate = timelineType === TimelineType.template;
return (
<EuiButtonIcon
aria-label={i18n.NOTES}
data-test-subj="timeline-notes-button-small"
iconType="editorComment"
onClick={() => toggleShowNotes()}
isDisabled={isTemplate}
/>
);
}
);
SmallNotesButton.displayName = 'SmallNotesButton';
/**
@ -326,25 +337,32 @@ const NotesButtonComponent = React.memo<NotesButtonProps>(
noteIds,
showNotes,
size,
status,
toggleShowNotes,
text,
updateNote,
timelineType,
}) => (
<ButtonContainer animate={animate} data-test-subj="timeline-notes-button-container">
<>
{size === 'l' ? (
<LargeNotesButton noteIds={noteIds} text={text} toggleShowNotes={toggleShowNotes} />
) : (
<SmallNotesButton noteIds={noteIds} toggleShowNotes={toggleShowNotes} />
<SmallNotesButton
noteIds={noteIds}
toggleShowNotes={toggleShowNotes}
timelineType={timelineType}
/>
)}
{size === 'l' && showNotes ? (
<EuiOverlayMask>
<EuiModal maxWidth={NOTES_PANEL_WIDTH} onClose={toggleShowNotes}>
<Notes
associateNote={associateNote}
getNotesByIds={getNotesByIds}
noteIds={noteIds}
getNewNoteId={getNewNoteId}
getNotesByIds={getNotesByIds}
status={status}
noteIds={noteIds}
updateNote={updateNote}
/>
</EuiModal>
@ -364,6 +382,8 @@ export const NotesButton = React.memo<NotesButtonProps>(
noteIds,
showNotes,
size,
status,
timelineType,
toggleShowNotes,
toolTip,
text,
@ -377,9 +397,11 @@ export const NotesButton = React.memo<NotesButtonProps>(
noteIds={noteIds}
showNotes={showNotes}
size={size}
status={status}
toggleShowNotes={toggleShowNotes}
text={text}
updateNote={updateNote}
timelineType={timelineType}
/>
) : (
<EuiToolTip content={toolTip || ''} data-test-subj="timeline-notes-tool-tip">
@ -390,9 +412,11 @@ export const NotesButton = React.memo<NotesButtonProps>(
noteIds={noteIds}
showNotes={showNotes}
size={size}
status={status}
toggleShowNotes={toggleShowNotes}
text={text}
updateNote={updateNote}
timelineType={timelineType}
/>
</EuiToolTip>
)

View file

@ -6,13 +6,14 @@
import { mount } from 'enzyme';
import React from 'react';
import { TimelineStatus } from '../../../../../common/types/timeline';
import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline';
import {
mockGlobalState,
apolloClientObservable,
SUB_PLUGINS_REDUCER,
createSecuritySolutionStorageMock,
TestProviders,
kibanaObservable,
} from '../../../../common/mock';
import { createStore, State } from '../../../../common/store';
import { useThrottledResizeObserver } from '../../../../common/components/utils';
@ -86,6 +87,7 @@ const defaultProps = {
isDatepickerLocked: false,
isFavorite: false,
title: '',
timelineType: TimelineType.default,
description: '',
getNotesByIds: jest.fn(),
noteIds: [],
@ -103,11 +105,23 @@ describe('Properties', () => {
const { storage } = createSecuritySolutionStorageMock();
let mockedWidth = 1000;
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => {
jest.clearAllMocks();
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
(useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: mockedWidth });
});
@ -130,9 +144,10 @@ describe('Properties', () => {
});
test('renders correctly draft timeline', () => {
const testProps = { ...defaultProps, status: TimelineStatus.draft };
const wrapper = mount(
<TestProviders store={store}>
<Properties {...{ ...defaultProps, status: TimelineStatus.draft }} />
<Properties {...testProps} />
</TestProviders>
);
@ -157,9 +172,11 @@ describe('Properties', () => {
});
test('it renders a filled star icon when it is a favorite', () => {
const testProps = { ...defaultProps, isFavorite: true };
const wrapper = mount(
<TestProviders store={store}>
<Properties {...{ ...defaultProps, isFavorite: true }} />
<Properties {...testProps} />
</TestProviders>
);
@ -168,10 +185,10 @@ describe('Properties', () => {
test('it renders the title of the timeline', () => {
const title = 'foozle';
const testProps = { ...defaultProps, title };
const wrapper = mount(
<TestProviders store={store}>
<Properties {...{ ...defaultProps, title }} />
<Properties {...testProps} />
</TestProviders>
);
@ -194,9 +211,11 @@ describe('Properties', () => {
});
test('it renders the lock icon when isDatepickerLocked is true', () => {
const testProps = { ...defaultProps, isDatepickerLocked: true };
const wrapper = mount(
<TestProviders store={store}>
<Properties {...{ ...defaultProps, isDatepickerLocked: true }} />
<Properties {...testProps} />
</TestProviders>
);
expect(
@ -223,13 +242,16 @@ describe('Properties', () => {
test('it renders a description on the left when the width is at least as wide as the threshold', () => {
const description = 'strange';
const testProps = { ...defaultProps, description };
// mockedWidth = showDescriptionThreshold;
(useThrottledResizeObserver as jest.Mock).mockReset();
(useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: showDescriptionThreshold });
const wrapper = mount(
<TestProviders store={store}>
<Properties {...{ ...defaultProps, description }} />
<Properties {...testProps} />
</TestProviders>
);
@ -244,6 +266,9 @@ describe('Properties', () => {
test('it does NOT render a description on the left when the width is less than the threshold', () => {
const description = 'strange';
const testProps = { ...defaultProps, description };
// mockedWidth = showDescriptionThreshold - 1;
(useThrottledResizeObserver as jest.Mock).mockReset();
(useThrottledResizeObserver as jest.Mock).mockReturnValue({
@ -252,7 +277,7 @@ describe('Properties', () => {
const wrapper = mount(
<TestProviders store={store}>
<Properties {...{ ...defaultProps, description }} />
<Properties {...testProps} />
</TestProviders>
);
@ -313,10 +338,11 @@ describe('Properties', () => {
test('it renders an avatar for the current user viewing the timeline when it has a title', () => {
const title = 'port scan';
const testProps = { ...defaultProps, title };
const wrapper = mount(
<TestProviders store={store}>
<Properties {...{ ...defaultProps, title }} />
<Properties {...testProps} />
</TestProviders>
);
@ -334,9 +360,11 @@ describe('Properties', () => {
});
test('insert timeline - new case', async () => {
const testProps = { ...defaultProps, title: 'coolness' };
const wrapper = mount(
<TestProviders store={store}>
<Properties {...{ ...defaultProps, title: 'coolness' }} />
<Properties {...testProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click');
@ -352,9 +380,11 @@ describe('Properties', () => {
});
test('insert timeline - existing case', async () => {
const testProps = { ...defaultProps, title: 'coolness' };
const wrapper = mount(
<TestProviders store={store}>
<Properties {...{ ...defaultProps, title: 'coolness' }} />
<Properties {...testProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click');

View file

@ -7,7 +7,7 @@
import React, { useState, useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { TimelineStatus, TimelineTypeLiteral } from '../../../../../common/types/timeline';
import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline';
import { useThrottledResizeObserver } from '../../../../common/components/utils';
import { Note } from '../../../../common/lib/note';
import { InputsModelId } from '../../../../common/store/inputs/constants';
@ -52,7 +52,8 @@ interface Props {
isFavorite: boolean;
noteIds: string[];
timelineId: string;
status: TimelineStatus;
timelineType: TimelineTypeLiteral;
status: TimelineStatusLiteral;
title: string;
toggleLock: ToggleLock;
updateDescription: UpdateDescription;
@ -87,6 +88,7 @@ export const Properties = React.memo<Props>(
noteIds,
status,
timelineId,
timelineType,
title,
toggleLock,
updateDescription,
@ -164,10 +166,12 @@ export const Properties = React.memo<Props>(
isFavorite={isFavorite}
noteIds={noteIds}
onToggleShowNotes={onToggleShowNotes}
status={status}
showDescription={width >= showDescriptionThreshold}
showNotes={showNotes}
showNotesFromWidth={width >= showNotesThreshold}
timelineId={timelineId}
timelineType={timelineType}
title={title}
toggleLock={onToggleLock}
updateDescription={updateDescription}
@ -196,6 +200,7 @@ export const Properties = React.memo<Props>(
showUsersView={title.length > 0}
status={status}
timelineId={timelineId}
timelineType={timelineType}
title={title}
updateDescription={updateDescription}
updateNote={updateNote}

View file

@ -11,6 +11,7 @@ import {
mockGlobalState,
apolloClientObservable,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
} from '../../../../common/mock';
import { createStore, State } from '../../../../common/store';
@ -26,7 +27,13 @@ jest.mock('../../../../common/lib/kibana', () => {
describe('NewTemplateTimeline', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mockClosePopover = jest.fn();
const mockTitle = 'NEW_TIMELINE';
let wrapper: ReactWrapper;

View file

@ -10,9 +10,12 @@ import React from 'react';
import styled from 'styled-components';
import { Description, Name, NotesButton, StarIcon } from './helpers';
import { AssociateNote, UpdateNote } from '../../notes/helpers';
import { Note } from '../../../../common/lib/note';
import { SuperDatePicker } from '../../../../common/components/super_date_picker';
import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../common/types/timeline';
import * as i18n from './translations';
type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void;
@ -22,6 +25,7 @@ type UpdateDescription = ({ id, description }: { id: string; description: string
interface Props {
isFavorite: boolean;
timelineId: string;
timelineType: TimelineTypeLiteral;
updateIsFavorite: UpdateIsFavorite;
showDescription: boolean;
description: string;
@ -29,6 +33,7 @@ interface Props {
updateTitle: UpdateTitle;
updateDescription: UpdateDescription;
showNotes: boolean;
status: TimelineStatusLiteral;
associateNote: AssociateNote;
showNotesFromWidth: boolean;
getNotesByIds: (noteIds: string[]) => Note[];
@ -77,8 +82,10 @@ export const PropertiesLeft = React.memo<Props>(
showDescription,
description,
title,
timelineType,
updateTitle,
updateDescription,
status,
showNotes,
showNotesFromWidth,
associateNote,
@ -120,10 +127,12 @@ export const PropertiesLeft = React.memo<Props>(
noteIds={noteIds}
showNotes={showNotes}
size="l"
status={status}
text={i18n.NOTES}
toggleShowNotes={onToggleShowNotes}
toolTip={i18n.NOTES_TOOL_TIP}
updateNote={updateNote}
timelineType={timelineType}
/>
</EuiFlexItem>
) : null}

View file

@ -9,7 +9,7 @@ import React from 'react';
import { PropertiesRight } from './properties_right';
import { useKibana } from '../../../../common/lib/kibana';
import { TimelineStatus } from '../../../../../common/types/timeline';
import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline';
import { disableTemplate } from '../../../../../common/constants';
jest.mock('../../../../common/lib/kibana', () => {
@ -67,6 +67,7 @@ describe('Properties Right', () => {
onOpenTimelineModal: jest.fn(),
status: TimelineStatus.active,
showTimelineModal: false,
timelineType: TimelineType.default,
title: 'title',
updateNote: jest.fn(),
};

View file

@ -17,7 +17,7 @@ import {
import { NewTimeline, Description, NotesButton, NewCase, ExistingCase } from './helpers';
import { disableTemplate } from '../../../../../common/constants';
import { TimelineStatus } from '../../../../../common/types/timeline';
import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline';
import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
import { useKibana } from '../../../../common/lib/kibana';
@ -83,9 +83,10 @@ interface PropertiesRightComponentProps {
showNotesFromWidth: boolean;
showTimelineModal: boolean;
showUsersView: boolean;
status: TimelineStatus;
status: TimelineStatusLiteral;
timelineId: string;
title: string;
timelineType: TimelineTypeLiteral;
updateDescription: UpdateDescription;
updateNote: UpdateNote;
usersViewing: string[];
@ -111,6 +112,7 @@ const PropertiesRightComponent: React.FC<PropertiesRightComponentProps> = ({
showTimelineModal,
showUsersView,
status,
timelineType,
timelineId,
title,
updateDescription,
@ -203,6 +205,8 @@ const PropertiesRightComponent: React.FC<PropertiesRightComponentProps> = ({
noteIds={noteIds}
showNotes={showNotes}
size="l"
status={status}
timelineType={timelineType}
text={i18n.NOTES}
toggleShowNotes={onToggleShowNotes}
toolTip={i18n.NOTES_TOOL_TIP}

View file

@ -112,7 +112,7 @@ export const NEW_TIMELINE = i18n.translate(
export const NEW_TEMPLATE_TIMELINE = i18n.translate(
'xpack.securitySolution.timeline.properties.newTemplateTimelineButtonLabel',
{
defaultMessage: 'Create template timeline',
defaultMessage: 'Create new timeline template',
}
);

View file

@ -30,11 +30,7 @@ describe('SelectableTimeline', () => {
};
});
const {
SelectableTimeline,
ORIGINAL_PAGE_SIZE,
} = jest.requireActual('./');
const { SelectableTimeline, ORIGINAL_PAGE_SIZE } = jest.requireActual('./');
const props = {
hideUntitled: false,
@ -94,8 +90,10 @@ describe('SelectableTimeline', () => {
sortField: SortFieldTimeline.updated,
sortOrder: Direction.desc,
},
status: null,
onlyUserFavorite: false,
timelineType: TimelineType.default,
templateTimelineType: null,
};
beforeAll(() => {
mount(<SelectableTimeline {...props} />);

View file

@ -33,6 +33,7 @@ import * as i18nTimeline from '../../open_timeline/translations';
import { OpenTimelineResult } from '../../open_timeline/types';
import { getEmptyTagValue } from '../../../../common/components/empty_value';
import * as i18n from '../translations';
import { useTimelineStatus } from '../../open_timeline/use_timeline_status';
const MyEuiFlexItem = styled(EuiFlexItem)`
display: inline-block;
@ -118,6 +119,7 @@ const SelectableTimelineComponent: React.FC<SelectableTimelineProps> = ({
const [onlyFavorites, setOnlyFavorites] = useState(false);
const [searchRef, setSearchRef] = useState<HTMLElement | null>(null);
const { fetchAllTimeline, timelines, loading, totalCount: timelineCount } = useGetAllTimeline();
const { timelineStatus, templateTimelineType } = useTimelineStatus({ timelineType });
const onSearchTimeline = useCallback((val) => {
setSearchTimelineValue(val);
@ -249,24 +251,31 @@ const SelectableTimelineComponent: React.FC<SelectableTimelineProps> = ({
},
};
useEffect(
() =>
fetchAllTimeline({
pageInfo: {
pageIndex: 1,
pageSize,
},
search: searchTimelineValue,
sort: {
sortField: SortFieldTimeline.updated,
sortOrder: Direction.desc,
},
onlyUserFavorite: onlyFavorites,
timelineType,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[onlyFavorites, pageSize, searchTimelineValue, timelineType]
);
useEffect(() => {
fetchAllTimeline({
pageInfo: {
pageIndex: 1,
pageSize,
},
search: searchTimelineValue,
sort: {
sortField: SortFieldTimeline.updated,
sortOrder: Direction.desc,
},
onlyUserFavorite: onlyFavorites,
status: timelineStatus,
timelineType,
templateTimelineType,
});
}, [
fetchAllTimeline,
onlyFavorites,
pageSize,
searchTimelineValue,
timelineType,
timelineStatus,
templateTimelineType,
]);
return (
<EuiSelectableContainer isLoading={loading}>

View file

@ -24,6 +24,7 @@ import { TimelineComponent, Props as TimelineComponentProps } from './timeline';
import { Sort } from './body/sort';
import { mockDataProviders } from './data_providers/mock/mock_data_providers';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
import { TimelineStatus } from '../../../../common/types/timeline';
jest.mock('../../../common/lib/kibana');
jest.mock('./properties/properties_right');
@ -96,6 +97,7 @@ describe('Timeline', () => {
showCallOutUnauthorizedMsg: false,
start: startDate,
sort,
status: TimelineStatus.active,
toggleColumn: jest.fn(),
usersViewing: ['elastic'],
};

View file

@ -40,6 +40,7 @@ import {
IIndexPattern,
} from '../../../../../../../src/plugins/data/public';
import { useManageTimeline } from '../manage_timeline';
import { TimelineStatusLiteral } from '../../../../common/types/timeline';
const TimelineContainer = styled.div`
height: 100%;
@ -110,6 +111,7 @@ export interface Props {
showCallOutUnauthorizedMsg: boolean;
start: number;
sort: Sort;
status: TimelineStatusLiteral;
toggleColumn: (column: ColumnHeaderOptions) => void;
usersViewing: string[];
}
@ -141,6 +143,7 @@ export const TimelineComponent: React.FC<Props> = ({
show,
showCallOutUnauthorizedMsg,
start,
status,
sort,
toggleColumn,
usersViewing,
@ -214,6 +217,7 @@ export const TimelineComponent: React.FC<Props> = ({
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
show={show}
showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg}
status={status}
/>
</TimelineHeaderContainer>
</StyledEuiFlyoutHeader>

View file

@ -13,6 +13,8 @@ export const allTimelinesQuery = gql`
$sort: SortTimeline
$onlyUserFavorite: Boolean
$timelineType: TimelineType
$templateTimelineType: TemplateTimelineType
$status: TimelineStatus
) {
getAllTimeline(
pageInfo: $pageInfo
@ -20,8 +22,15 @@ export const allTimelinesQuery = gql`
sort: $sort
onlyUserFavorite: $onlyUserFavorite
timelineType: $timelineType
templateTimelineType: $templateTimelineType
status: $status
) {
totalCount
defaultTimelineCount
templateTimelineCount
elasticTemplateTimelineCount
customTemplateTimelineCount
favoriteCount
timeline {
savedObjectId
description

View file

@ -22,7 +22,11 @@ import { useApolloClient } from '../../../common/utils/apollo_context';
import { allTimelinesQuery } from './index.gql_query';
import * as i18n from '../../pages/translations';
import { TimelineTypeLiteralWithNull } from '../../../../common/types/timeline';
import {
TimelineTypeLiteralWithNull,
TimelineStatusLiteralWithNull,
TemplateTimelineTypeLiteralWithNull,
} from '../../../../common/types/timeline';
export interface AllTimelinesArgs {
fetchAllTimeline: ({
@ -30,11 +34,17 @@ export interface AllTimelinesArgs {
pageInfo,
search,
sort,
status,
timelineType,
}: AllTimelinesVariables) => void;
timelines: OpenTimelineResult[];
loading: boolean;
totalCount: number;
customTemplateTimelineCount: number;
defaultTimelineCount: number;
elasticTemplateTimelineCount: number;
templateTimelineCount: number;
favoriteCount: number;
}
export interface AllTimelinesVariables {
@ -42,7 +52,9 @@ export interface AllTimelinesVariables {
pageInfo: PageInfoTimeline;
search: string;
sort: SortTimeline;
status: TimelineStatusLiteralWithNull;
timelineType: TimelineTypeLiteralWithNull;
templateTimelineType: TemplateTimelineTypeLiteralWithNull;
}
export const ALL_TIMELINE_QUERY_ID = 'FETCH_ALL_TIMELINES';
@ -76,6 +88,7 @@ export const getAllTimeline = memoizeOne(
)
: null,
savedObjectId: timeline.savedObjectId,
status: timeline.status,
title: timeline.title,
updated: timeline.updated,
updatedBy: timeline.updatedBy,
@ -90,27 +103,39 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
loading: false,
totalCount: 0,
timelines: [],
customTemplateTimelineCount: 0,
defaultTimelineCount: 0,
elasticTemplateTimelineCount: 0,
templateTimelineCount: 0,
favoriteCount: 0,
});
const fetchAllTimeline = useCallback(
({ onlyUserFavorite, pageInfo, search, sort, timelineType }: AllTimelinesVariables) => {
async ({
onlyUserFavorite,
pageInfo,
search,
sort,
status,
timelineType,
templateTimelineType,
}: AllTimelinesVariables) => {
let didCancel = false;
const abortCtrl = new AbortController();
const fetchData = async () => {
try {
if (apolloClient != null) {
setAllTimelines({
...allTimelines,
loading: true,
});
setAllTimelines((prevState) => ({ ...prevState, loading: true }));
const variables: GetAllTimeline.Variables = {
onlyUserFavorite,
pageInfo,
search,
sort,
status,
timelineType,
templateTimelineType,
};
const response = await apolloClient.query<
GetAllTimeline.Query,
@ -125,8 +150,16 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
},
},
});
const totalCount = response?.data?.getAllTimeline?.totalCount ?? 0;
const timelines = response?.data?.getAllTimeline?.timeline ?? [];
const getAllTimelineResponse = response?.data?.getAllTimeline;
const totalCount = getAllTimelineResponse?.totalCount ?? 0;
const timelines = getAllTimelineResponse?.timeline ?? [];
const customTemplateTimelineCount =
getAllTimelineResponse?.customTemplateTimelineCount ?? 0;
const defaultTimelineCount = getAllTimelineResponse?.defaultTimelineCount ?? 0;
const elasticTemplateTimelineCount =
getAllTimelineResponse?.elasticTemplateTimelineCount ?? 0;
const templateTimelineCount = getAllTimelineResponse?.templateTimelineCount ?? 0;
const favoriteCount = getAllTimelineResponse?.favoriteCount ?? 0;
if (!didCancel) {
dispatch(
inputsActions.setQuery({
@ -141,6 +174,11 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
loading: false,
totalCount,
timelines: getAllTimeline(JSON.stringify(variables), timelines as TimelineResult[]),
customTemplateTimelineCount,
defaultTimelineCount,
elasticTemplateTimelineCount,
templateTimelineCount,
favoriteCount,
});
}
}
@ -155,6 +193,11 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
loading: false,
totalCount: 0,
timelines: [],
customTemplateTimelineCount: 0,
defaultTimelineCount: 0,
elasticTemplateTimelineCount: 0,
templateTimelineCount: 0,
favoriteCount: 0,
});
}
}
@ -165,7 +208,7 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
abortCtrl.abort();
};
},
[apolloClient, allTimelines, dispatch, dispatchToaster]
[apolloClient, dispatch, dispatchToaster]
);
useEffect(() => {

View file

@ -165,6 +165,7 @@ describe('persistTimeline', () => {
},
},
};
const version = null;
const fetchMock = jest.fn();
const postMock = jest.fn();
@ -180,7 +181,11 @@ describe('persistTimeline', () => {
patch: patchMock.mockReturnValue(mockPatchTimelineResponse),
},
});
api.persistTimeline({ timelineId, timeline: initialDraftTimeline, version });
api.persistTimeline({
timelineId,
timeline: initialDraftTimeline,
version,
});
});
afterAll(() => {

View file

@ -12,6 +12,8 @@ import {
TimelineResponse,
TimelineResponseType,
TimelineStatus,
TimelineErrorResponseType,
TimelineErrorResponse,
} from '../../../common/types/timeline';
import { TimelineInput, TimelineType } from '../../graphql/types';
import {
@ -48,6 +50,12 @@ const decodeTimelineResponse = (respTimeline?: TimelineResponse) =>
fold(throwErrors(createToasterPlainError), identity)
);
const decodeTimelineErrorResponse = (respTimeline?: TimelineErrorResponse) =>
pipe(
TimelineErrorResponseType.decode(respTimeline),
fold(throwErrors(createToasterPlainError), identity)
);
const postTimeline = async ({ timeline }: RequestPostTimeline): Promise<TimelineResponse> => {
const response = await KibanaServices.get().http.post<TimelineResponse>(TIMELINE_URL, {
method: 'POST',
@ -61,12 +69,19 @@ const patchTimeline = async ({
timelineId,
timeline,
version,
}: RequestPatchTimeline): Promise<TimelineResponse> => {
const response = await KibanaServices.get().http.patch<TimelineResponse>(TIMELINE_URL, {
method: 'PATCH',
body: JSON.stringify({ timeline, timelineId, version }),
});
}: RequestPatchTimeline): Promise<TimelineResponse | TimelineErrorResponse> => {
let response = null;
try {
response = await KibanaServices.get().http.patch<TimelineResponse>(TIMELINE_URL, {
method: 'PATCH',
body: JSON.stringify({ timeline, timelineId, version }),
});
} catch (err) {
// For Future developer
// We are not rejecting our promise here because we had issue with our RXJS epic
// the issue we were not able to pass the right object to it so we did manage the error in the success
return Promise.resolve(decodeTimelineErrorResponse(err.body));
}
return decodeTimelineResponse(response);
};
@ -74,17 +89,31 @@ export const persistTimeline = async ({
timelineId,
timeline,
version,
}: RequestPersistTimeline): Promise<TimelineResponse> => {
if (timelineId == null && timeline.status === TimelineStatus.draft) {
const draftTimeline = await cleanDraftTimeline({ timelineType: timeline.timelineType! });
}: RequestPersistTimeline): Promise<TimelineResponse | TimelineErrorResponse> => {
if (timelineId == null && timeline.status === TimelineStatus.draft && timeline) {
const draftTimeline = await cleanDraftTimeline({
timelineType: timeline.timelineType!,
templateTimelineId: timeline.templateTimelineId ?? undefined,
templateTimelineVersion: timeline.templateTimelineVersion ?? undefined,
});
const templateTimelineInfo =
timeline.timelineType! === TimelineType.template
? {
templateTimelineId:
draftTimeline.data.persistTimeline.timeline.templateTimelineId ??
timeline.templateTimelineId,
templateTimelineVersion:
draftTimeline.data.persistTimeline.timeline.templateTimelineVersion ??
timeline.templateTimelineVersion,
}
: {};
return patchTimeline({
timelineId: draftTimeline.data.persistTimeline.timeline.savedObjectId,
timeline: {
...timeline,
templateTimelineId: draftTimeline.data.persistTimeline.timeline.templateTimelineId,
templateTimelineVersion:
draftTimeline.data.persistTimeline.timeline.templateTimelineVersion,
...templateTimelineInfo,
},
version: draftTimeline.data.persistTimeline.timeline.version ?? '',
});
@ -147,12 +176,24 @@ export const getDraftTimeline = async ({
export const cleanDraftTimeline = async ({
timelineType,
templateTimelineId,
templateTimelineVersion,
}: {
timelineType: TimelineType;
templateTimelineId?: string;
templateTimelineVersion?: number;
}): Promise<TimelineResponse> => {
const templateTimelineInfo =
timelineType === TimelineType.template
? {
templateTimelineId,
templateTimelineVersion,
}
: {};
const response = await KibanaServices.get().http.post<TimelineResponse>(TIMELINE_DRAFT_URL, {
body: JSON.stringify({
timelineType,
...templateTimelineInfo,
}),
});

View file

@ -30,3 +30,17 @@ export const ERROR_FETCHING_TIMELINES_TITLE = i18n.translate(
defaultMessage: 'Failed to query all timelines data',
}
);
export const UPDATE_TIMELINE_ERROR_TITLE = i18n.translate(
'xpack.securitySolution.timelines.updateTimelineErrorTitle',
{
defaultMessage: 'Timeline error',
}
);
export const UPDATE_TIMELINE_ERROR_TEXT = i18n.translate(
'xpack.securitySolution.timelines.updateTimelineErrorText',
{
defaultMessage: 'Something went wrong',
}
);

View file

@ -70,6 +70,8 @@ export const createTimeline = actionCreator<{
showCheckboxes?: boolean;
showRowRenderers?: boolean;
timelineType?: TimelineTypeLiteral;
templateTimelineId?: string;
templateTimelineVersion?: number;
}>('CREATE_TIMELINE');
export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT');

View file

@ -33,7 +33,7 @@ import {
Filter,
MatchAllFilter,
} from '../../../../../../.../../../src/plugins/data/public';
import { TimelineStatus } from '../../../../common/types/timeline';
import { TimelineStatus, TimelineErrorResponse } from '../../../../common/types/timeline';
import { inputsModel } from '../../../common/store/inputs';
import {
TimelineType,
@ -43,6 +43,10 @@ import {
} from '../../../graphql/types';
import { addError } from '../../../common/store/app/actions';
import { persistTimeline } from '../../containers/api';
import { ALL_TIMELINE_QUERY_ID } from '../../containers/all';
import * as i18n from '../../pages/translations';
import {
applyKqlFilterQuery,
addProvider,
@ -79,8 +83,6 @@ import { isNotNull } from './helpers';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import { myEpicTimelineId } from './my_epic_timeline_id';
import { ActionTimeline, TimelineEpicDependencies } from './types';
import { persistTimeline } from '../../containers/api';
import { ALL_TIMELINE_QUERY_ID } from '../../containers/all';
const timelineActionsType = [
applyKqlFilterQuery.type,
@ -121,6 +123,7 @@ export const createTimelineEpic = <State>(): Epic<
timelineByIdSelector,
timelineTimeRangeSelector,
apolloClient$,
kibana$,
}
) => {
const timeline$ = state$.pipe(map(timelineByIdSelector), filter(isNotNull));
@ -146,13 +149,24 @@ export const createTimelineEpic = <State>(): Epic<
if (action.type === addError.type) {
return true;
}
if (action.type === createTimeline.type && isItAtimelineAction(timelineId)) {
if (
isItAtimelineAction(timelineId) &&
timelineObj != null &&
timelineObj.status != null &&
TimelineStatus.immutable === timelineObj.status
) {
return false;
} else if (action.type === createTimeline.type && isItAtimelineAction(timelineId)) {
myEpicTimelineId.setTimelineVersion(null);
myEpicTimelineId.setTimelineId(null);
myEpicTimelineId.setTemplateTimelineId(null);
myEpicTimelineId.setTemplateTimelineVersion(null);
} else if (action.type === addTimeline.type && isItAtimelineAction(timelineId)) {
const addNewTimeline: TimelineModel = get('payload.timeline', action);
myEpicTimelineId.setTimelineId(addNewTimeline.savedObjectId);
myEpicTimelineId.setTimelineVersion(addNewTimeline.version);
myEpicTimelineId.setTemplateTimelineId(addNewTimeline.templateTimelineId);
myEpicTimelineId.setTemplateTimelineVersion(addNewTimeline.templateTimelineVersion);
return true;
} else if (
timelineActionsType.includes(action.type) &&
@ -176,6 +190,8 @@ export const createTimelineEpic = <State>(): Epic<
const action: ActionTimeline = get('action', objAction);
const timelineId = myEpicTimelineId.getTimelineId();
const version = myEpicTimelineId.getTimelineVersion();
const templateTimelineId = myEpicTimelineId.getTemplateTimelineId();
const templateTimelineVersion = myEpicTimelineId.getTemplateTimelineVersion();
if (timelineNoteActionsType.includes(action.type)) {
return epicPersistNote(
@ -211,13 +227,37 @@ export const createTimelineEpic = <State>(): Epic<
persistTimeline({
timelineId,
version,
timeline: convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange),
timeline: {
...convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange),
templateTimelineId,
templateTimelineVersion,
},
})
).pipe(
withLatestFrom(timeline$, allTimelineQuery$),
mergeMap(([result, recentTimeline, allTimelineQuery]) => {
withLatestFrom(timeline$, allTimelineQuery$, kibana$),
mergeMap(([result, recentTimeline, allTimelineQuery, kibana]) => {
const error = result as TimelineErrorResponse;
if (error.status_code != null && error.status_code === 405) {
kibana.notifications!.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: error.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
return [
endTimelineSaving({
id: action.payload.id,
}),
];
}
const savedTimeline = recentTimeline[action.payload.id];
const response: ResponseTimeline = get('data.persistTimeline', result);
if (response == null) {
return [
endTimelineSaving({
id: action.payload.id,
}),
];
}
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
if (allTimelineQuery.refetch != null) {
@ -264,6 +304,12 @@ export const createTimelineEpic = <State>(): Epic<
myEpicTimelineId.setTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].version
);
myEpicTimelineId.setTemplateTimelineId(
updatedTimeline[get('payload.id', checkAction)].templateTimelineId
);
myEpicTimelineId.setTemplateTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].templateTimelineVersion
);
return true;
}
return false;

View file

@ -15,6 +15,7 @@ import {
defaultHeaders,
createSecuritySolutionStorageMock,
mockIndexPattern,
kibanaObservable,
} from '../../../common/mock';
import { createStore, State } from '../../../common/store';
@ -38,6 +39,7 @@ import { Direction } from '../../../graphql/types';
import { addTimelineInStorage } from '../../containers/local_storage';
import { isPageTimeline } from './epic_local_storage';
import { TimelineStatus } from '../../../../common/types/timeline';
jest.mock('../../containers/local_storage');
@ -50,7 +52,13 @@ const addTimelineInStorageMock = addTimelineInStorage as jest.Mock;
describe('epicLocalStorage', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
let props = {} as TimelineComponentProps;
const sort: Sort = {
@ -63,7 +71,13 @@ describe('epicLocalStorage', () => {
const indexPattern = mockIndexPattern;
beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
props = {
browserFields: mockBrowserFields,
columns: defaultHeaders,
@ -89,6 +103,7 @@ describe('epicLocalStorage', () => {
show: true,
showCallOutUnauthorizedMsg: false,
start: startDate,
status: TimelineStatus.active,
sort,
toggleColumn: jest.fn(),
usersViewing: ['elastic'],

View file

@ -6,6 +6,7 @@
import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp';
import uuid from 'uuid';
import { Filter } from '../../../../../../../src/plugins/data/public';
import { disableTemplate } from '../../../../common/constants';
@ -19,7 +20,7 @@ import {
} from '../../../timelines/components/timeline/data_providers/data_provider';
import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/model';
import { TimelineNonEcsData } from '../../../graphql/types';
import { TimelineTypeLiteral } from '../../../../common/types/timeline';
import { TimelineTypeLiteral, TimelineType } from '../../../../common/types/timeline';
import { timelineDefaults } from './defaults';
import { ColumnHeaderOptions, KqlMode, TimelineModel, EventType } from './model';
@ -158,28 +159,38 @@ export const addNewTimeline = ({
showRowRenderers = true,
timelineById,
timelineType,
}: AddNewTimelineParams): TimelineById => ({
...timelineById,
[id]: {
id,
...timelineDefaults,
columns,
dataProviders,
dateRange,
filters,
itemsPerPage,
kqlQuery,
sort,
show,
savedObjectId: null,
version: null,
isSaving: false,
isLoading: false,
showCheckboxes,
showRowRenderers,
timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType,
},
});
}: AddNewTimelineParams): TimelineById => {
const templateTimelineInfo =
!disableTemplate && timelineType === TimelineType.template
? {
templateTimelineId: uuid.v4(),
templateTimelineVersion: 1,
}
: {};
return {
...timelineById,
[id]: {
id,
...timelineDefaults,
columns,
dataProviders,
dateRange,
filters,
itemsPerPage,
kqlQuery,
sort,
show,
savedObjectId: null,
version: null,
isSaving: false,
isLoading: false,
showCheckboxes,
showRowRenderers,
timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType,
...templateTimelineInfo,
},
};
};
interface PinTimelineEventParams {
id: string;

View file

@ -7,6 +7,8 @@
export class ManageEpicTimelineId {
private timelineId: string | null = null;
private version: string | null = null;
private templateTimelineId: string | null = null;
private templateVersion: number | null = null;
public getTimelineId(): string | null {
return this.timelineId;
@ -16,6 +18,14 @@ export class ManageEpicTimelineId {
return this.version;
}
public getTemplateTimelineId(): string | null {
return this.templateTimelineId;
}
public getTemplateTimelineVersion(): number | null {
return this.templateVersion;
}
public setTimelineId(timelineId: string | null) {
this.timelineId = timelineId;
}
@ -23,4 +33,12 @@ export class ManageEpicTimelineId {
public setTimelineVersion(version: string | null) {
this.version = version;
}
public setTemplateTimelineId(templateTimelineId: string | null) {
this.templateTimelineId = templateTimelineId;
}
public setTemplateTimelineVersion(templateVersion: number | null) {
this.templateVersion = templateVersion;
}
}

View file

@ -160,7 +160,6 @@ export type SubsetTimelineModel = Readonly<
| 'isLoading'
| 'savedObjectId'
| 'version'
| 'timelineType'
| 'status'
>
>;

Some files were not shown because too many files have changed in this diff Show more