[SIEM] [Case] All cases page design updates (#59248)
This commit is contained in:
parent
46738cfa0a
commit
91a5b17cfd
38 changed files with 863 additions and 216 deletions
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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, { Dispatch, SetStateAction, useCallback, useState } from 'react';
|
||||
import {
|
||||
EuiFilterButton,
|
||||
EuiFilterSelectItem,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiPopover,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface FilterPopoverProps {
|
||||
buttonLabel: string;
|
||||
onSelectedOptionsChanged: Dispatch<SetStateAction<string[]>>;
|
||||
options: string[];
|
||||
optionsEmptyLabel: string;
|
||||
selectedOptions: string[];
|
||||
}
|
||||
|
||||
const ScrollableDiv = styled.div`
|
||||
max-height: 250px;
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
export const toggleSelectedGroup = (
|
||||
group: string,
|
||||
selectedGroups: string[],
|
||||
setSelectedGroups: Dispatch<SetStateAction<string[]>>
|
||||
): void => {
|
||||
const selectedGroupIndex = selectedGroups.indexOf(group);
|
||||
const updatedSelectedGroups = [...selectedGroups];
|
||||
if (selectedGroupIndex >= 0) {
|
||||
updatedSelectedGroups.splice(selectedGroupIndex, 1);
|
||||
} else {
|
||||
updatedSelectedGroups.push(group);
|
||||
}
|
||||
return setSelectedGroups(updatedSelectedGroups);
|
||||
};
|
||||
|
||||
/**
|
||||
* Popover for selecting a field to filter on
|
||||
*
|
||||
* @param buttonLabel label on dropdwon button
|
||||
* @param onSelectedOptionsChanged change listener to be notified when option selection changes
|
||||
* @param options to display for filtering
|
||||
* @param optionsEmptyLabel shows when options empty
|
||||
* @param selectedOptions manage state of selectedOptions
|
||||
*/
|
||||
export const FilterPopoverComponent = ({
|
||||
buttonLabel,
|
||||
onSelectedOptionsChanged,
|
||||
options,
|
||||
optionsEmptyLabel,
|
||||
selectedOptions,
|
||||
}: FilterPopoverProps) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const setIsPopoverOpenCb = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]);
|
||||
const toggleSelectedGroupCb = useCallback(
|
||||
option => toggleSelectedGroup(option, selectedOptions, onSelectedOptionsChanged),
|
||||
[selectedOptions, onSelectedOptionsChanged]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
ownFocus
|
||||
button={
|
||||
<EuiFilterButton
|
||||
data-test-subj={`options-filter-popover-button-${buttonLabel}`}
|
||||
iconType="arrowDown"
|
||||
onClick={setIsPopoverOpenCb}
|
||||
isSelected={isPopoverOpen}
|
||||
numFilters={options.length}
|
||||
hasActiveFilters={selectedOptions.length > 0}
|
||||
numActiveFilters={selectedOptions.length}
|
||||
>
|
||||
{buttonLabel}
|
||||
</EuiFilterButton>
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={setIsPopoverOpenCb}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<ScrollableDiv>
|
||||
{options.map((option, index) => (
|
||||
<EuiFilterSelectItem
|
||||
checked={selectedOptions.includes(option) ? 'on' : undefined}
|
||||
key={`${index}-${option}`}
|
||||
onClick={toggleSelectedGroupCb.bind(null, option)}
|
||||
>
|
||||
{option}
|
||||
</EuiFilterSelectItem>
|
||||
))}
|
||||
</ScrollableDiv>
|
||||
{options.length === 0 && (
|
||||
<EuiFlexGroup gutterSize="m" justifyContent="spaceAround">
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiPanel>
|
||||
<EuiText>{optionsEmptyLabel}</EuiText>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
||||
|
||||
FilterPopoverComponent.displayName = 'FilterPopoverComponent';
|
||||
|
||||
export const FilterPopover = React.memo(FilterPopoverComponent);
|
||||
|
||||
FilterPopover.displayName = 'FilterPopover';
|
|
@ -8,7 +8,7 @@ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
|||
import { mount, shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { TestProviders } from '../../mock';
|
||||
import {
|
||||
UtilityBar,
|
||||
UtilityBarAction,
|
|
@ -7,7 +7,7 @@
|
|||
import { mount, shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { TestProviders } from '../../mock';
|
||||
import { UtilityBarAction } from './index';
|
||||
|
||||
describe('UtilityBarAction', () => {
|
|
@ -7,7 +7,7 @@
|
|||
import { EuiPopover } from '@elastic/eui';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { LinkIcon, LinkIconProps } from '../../link_icon';
|
||||
import { LinkIcon, LinkIconProps } from '../link_icon';
|
||||
import { BarAction } from './styles';
|
||||
|
||||
const Popover = React.memo<UtilityBarActionProps>(
|
|
@ -7,7 +7,7 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { TestProviders } from '../../mock';
|
||||
import { UtilityBarGroup, UtilityBarText } from './index';
|
||||
|
||||
describe('UtilityBarGroup', () => {
|
|
@ -7,7 +7,7 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { TestProviders } from '../../mock';
|
||||
import { UtilityBarGroup, UtilityBarSection, UtilityBarText } from './index';
|
||||
|
||||
describe('UtilityBarSection', () => {
|
|
@ -7,7 +7,7 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { TestProviders } from '../../mock';
|
||||
import { UtilityBarText } from './index';
|
||||
|
||||
describe('UtilityBarText', () => {
|
|
@ -35,6 +35,7 @@ export const getCase = async (caseId: string, includeComments: boolean = true):
|
|||
export const getCases = async ({
|
||||
filterOptions = {
|
||||
search: '',
|
||||
state: 'open',
|
||||
tags: [],
|
||||
},
|
||||
queryParams = {
|
||||
|
@ -44,7 +45,12 @@ export const getCases = async ({
|
|||
sortOrder: 'desc',
|
||||
},
|
||||
}: FetchCasesProps): Promise<AllCases> => {
|
||||
const tags = [...(filterOptions.tags?.map(t => `case-workflow.attributes.tags: ${t}`) ?? [])];
|
||||
const stateFilter = `case-workflow.attributes.state: ${filterOptions.state}`;
|
||||
const tags = [
|
||||
...(filterOptions.tags?.reduce((acc, t) => [...acc, `case-workflow.attributes.tags: ${t}`], [
|
||||
stateFilter,
|
||||
]) ?? [stateFilter]),
|
||||
];
|
||||
const query = {
|
||||
...queryParams,
|
||||
filter: tags.join(' AND '),
|
||||
|
|
|
@ -13,4 +13,5 @@ export const FETCH_SUCCESS = 'FETCH_SUCCESS';
|
|||
export const POST_NEW_CASE = 'POST_NEW_CASE';
|
||||
export const POST_NEW_COMMENT = 'POST_NEW_COMMENT';
|
||||
export const UPDATE_FILTER_OPTIONS = 'UPDATE_FILTER_OPTIONS';
|
||||
export const UPDATE_TABLE_SELECTIONS = 'UPDATE_TABLE_SELECTIONS';
|
||||
export const UPDATE_QUERY_PARAMS = 'UPDATE_QUERY_PARAMS';
|
||||
|
|
|
@ -71,6 +71,7 @@ export interface QueryParams {
|
|||
|
||||
export interface FilterOptions {
|
||||
search: string;
|
||||
state: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
|
@ -89,7 +90,6 @@ export interface AllCases {
|
|||
}
|
||||
export enum SortFieldCase {
|
||||
createdAt = 'createdAt',
|
||||
state = 'state',
|
||||
updatedAt = 'updatedAt',
|
||||
}
|
||||
|
||||
|
|
|
@ -4,58 +4,87 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { isEqual } from 'lodash/fp';
|
||||
import {
|
||||
DEFAULT_TABLE_ACTIVE_PAGE,
|
||||
DEFAULT_TABLE_LIMIT,
|
||||
FETCH_FAILURE,
|
||||
FETCH_INIT,
|
||||
FETCH_SUCCESS,
|
||||
UPDATE_QUERY_PARAMS,
|
||||
UPDATE_FILTER_OPTIONS,
|
||||
} from './constants';
|
||||
import { AllCases, SortFieldCase, FilterOptions, QueryParams } from './types';
|
||||
import { getTypedPayload } from './utils';
|
||||
import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants';
|
||||
import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case } from './types';
|
||||
import { errorToToaster } from '../../components/ml/api/error_to_toaster';
|
||||
import { useStateToaster } from '../../components/toasters';
|
||||
import * as i18n from './translations';
|
||||
import { getCases } from './api';
|
||||
import { UpdateByKey } from './use_update_case';
|
||||
import { getCases, updateCaseProperty } from './api';
|
||||
|
||||
export interface UseGetCasesState {
|
||||
caseCount: CaseCount;
|
||||
data: AllCases;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
queryParams: QueryParams;
|
||||
filterOptions: FilterOptions;
|
||||
isError: boolean;
|
||||
loading: string[];
|
||||
queryParams: QueryParams;
|
||||
selectedCases: Case[];
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
type: string;
|
||||
payload?: AllCases | Partial<QueryParams> | FilterOptions;
|
||||
export interface CaseCount {
|
||||
open: number;
|
||||
closed: number;
|
||||
}
|
||||
|
||||
export interface UpdateCase extends UpdateByKey {
|
||||
caseId: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| { type: 'FETCH_INIT'; payload: string }
|
||||
| { type: 'FETCH_CASE_COUNT_SUCCESS'; payload: Partial<CaseCount> }
|
||||
| { type: 'FETCH_CASES_SUCCESS'; payload: AllCases }
|
||||
| { type: 'FETCH_FAILURE'; payload: string }
|
||||
| { type: 'FETCH_UPDATE_CASE_SUCCESS' }
|
||||
| { type: 'UPDATE_FILTER_OPTIONS'; payload: FilterOptions }
|
||||
| { type: 'UPDATE_QUERY_PARAMS'; payload: Partial<QueryParams> }
|
||||
| { type: 'UPDATE_TABLE_SELECTIONS'; payload: Case[] };
|
||||
|
||||
const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesState => {
|
||||
switch (action.type) {
|
||||
case FETCH_INIT:
|
||||
case 'FETCH_INIT':
|
||||
return {
|
||||
...state,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
loading: [...state.loading.filter(e => e !== action.payload), action.payload],
|
||||
};
|
||||
case FETCH_SUCCESS:
|
||||
case 'FETCH_UPDATE_CASE_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
loading: state.loading.filter(e => e !== 'caseUpdate'),
|
||||
};
|
||||
case 'FETCH_CASE_COUNT_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
caseCount: {
|
||||
...state.caseCount,
|
||||
...action.payload,
|
||||
},
|
||||
loading: state.loading.filter(e => e !== 'caseCount'),
|
||||
};
|
||||
case 'FETCH_CASES_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: getTypedPayload<AllCases>(action.payload),
|
||||
data: action.payload,
|
||||
loading: state.loading.filter(e => e !== 'cases'),
|
||||
};
|
||||
case FETCH_FAILURE:
|
||||
case 'FETCH_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
loading: state.loading.filter(e => e !== action.payload),
|
||||
};
|
||||
case UPDATE_QUERY_PARAMS:
|
||||
case 'UPDATE_FILTER_OPTIONS':
|
||||
return {
|
||||
...state,
|
||||
filterOptions: action.payload,
|
||||
};
|
||||
case 'UPDATE_QUERY_PARAMS':
|
||||
return {
|
||||
...state,
|
||||
queryParams: {
|
||||
|
@ -63,10 +92,10 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS
|
|||
...action.payload,
|
||||
},
|
||||
};
|
||||
case UPDATE_FILTER_OPTIONS:
|
||||
case 'UPDATE_TABLE_SELECTIONS':
|
||||
return {
|
||||
...state,
|
||||
filterOptions: getTypedPayload<FilterOptions>(action.payload),
|
||||
selectedCases: action.payload,
|
||||
};
|
||||
default:
|
||||
throw new Error();
|
||||
|
@ -74,51 +103,64 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS
|
|||
};
|
||||
|
||||
const initialData: AllCases = {
|
||||
cases: [],
|
||||
page: 0,
|
||||
perPage: 0,
|
||||
total: 0,
|
||||
cases: [],
|
||||
};
|
||||
export const useGetCases = (): [
|
||||
UseGetCasesState,
|
||||
Dispatch<SetStateAction<Partial<QueryParams>>>,
|
||||
Dispatch<SetStateAction<FilterOptions>>
|
||||
] => {
|
||||
interface UseGetCases extends UseGetCasesState {
|
||||
dispatchUpdateCaseProperty: Dispatch<UpdateCase>;
|
||||
getCaseCount: Dispatch<keyof CaseCount>;
|
||||
setFilters: Dispatch<SetStateAction<FilterOptions>>;
|
||||
setQueryParams: Dispatch<SetStateAction<Partial<QueryParams>>>;
|
||||
setSelectedCases: Dispatch<Case[]>;
|
||||
}
|
||||
export const useGetCases = (): UseGetCases => {
|
||||
const [state, dispatch] = useReducer(dataFetchReducer, {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
caseCount: {
|
||||
open: 0,
|
||||
closed: 0,
|
||||
},
|
||||
data: initialData,
|
||||
filterOptions: {
|
||||
search: '',
|
||||
state: 'open',
|
||||
tags: [],
|
||||
},
|
||||
isError: false,
|
||||
loading: [],
|
||||
queryParams: {
|
||||
page: DEFAULT_TABLE_ACTIVE_PAGE,
|
||||
perPage: DEFAULT_TABLE_LIMIT,
|
||||
sortField: SortFieldCase.createdAt,
|
||||
sortOrder: 'desc',
|
||||
},
|
||||
selectedCases: [],
|
||||
});
|
||||
const [queryParams, setQueryParams] = useState<Partial<QueryParams>>(state.queryParams);
|
||||
const [filterQuery, setFilters] = useState<FilterOptions>(state.filterOptions);
|
||||
const [, dispatchToaster] = useStateToaster();
|
||||
const [filterQuery, setFilters] = useState<FilterOptions>(state.filterOptions);
|
||||
const [queryParams, setQueryParams] = useState<Partial<QueryParams>>(state.queryParams);
|
||||
|
||||
const setSelectedCases = useCallback((mySelectedCases: Case[]) => {
|
||||
dispatch({ type: 'UPDATE_TABLE_SELECTIONS', payload: mySelectedCases });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(queryParams, state.queryParams)) {
|
||||
dispatch({ type: UPDATE_QUERY_PARAMS, payload: queryParams });
|
||||
dispatch({ type: 'UPDATE_QUERY_PARAMS', payload: queryParams });
|
||||
}
|
||||
}, [queryParams, state.queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(filterQuery, state.filterOptions)) {
|
||||
dispatch({ type: UPDATE_FILTER_OPTIONS, payload: filterQuery });
|
||||
dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: filterQuery });
|
||||
}
|
||||
}, [filterQuery, state.filterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCases = useCallback(() => {
|
||||
let didCancel = false;
|
||||
const fetchData = async () => {
|
||||
dispatch({ type: FETCH_INIT });
|
||||
dispatch({ type: 'FETCH_INIT', payload: 'cases' });
|
||||
try {
|
||||
const response = await getCases({
|
||||
filterOptions: state.filterOptions,
|
||||
|
@ -126,14 +168,14 @@ export const useGetCases = (): [
|
|||
});
|
||||
if (!didCancel) {
|
||||
dispatch({
|
||||
type: FETCH_SUCCESS,
|
||||
type: 'FETCH_CASES_SUCCESS',
|
||||
payload: response,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!didCancel) {
|
||||
errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
|
||||
dispatch({ type: FETCH_FAILURE });
|
||||
dispatch({ type: 'FETCH_FAILURE', payload: 'cases' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -142,5 +184,73 @@ export const useGetCases = (): [
|
|||
didCancel = true;
|
||||
};
|
||||
}, [state.queryParams, state.filterOptions]);
|
||||
return [state, setQueryParams, setFilters];
|
||||
useEffect(() => fetchCases(), [state.queryParams, state.filterOptions]);
|
||||
|
||||
const getCaseCount = useCallback((caseState: keyof CaseCount) => {
|
||||
let didCancel = false;
|
||||
const fetchData = async () => {
|
||||
dispatch({ type: 'FETCH_INIT', payload: 'caseCount' });
|
||||
try {
|
||||
const response = await getCases({
|
||||
filterOptions: { search: '', state: caseState, tags: [] },
|
||||
});
|
||||
if (!didCancel) {
|
||||
dispatch({
|
||||
type: 'FETCH_CASE_COUNT_SUCCESS',
|
||||
payload: { [caseState]: response.total },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!didCancel) {
|
||||
errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
|
||||
dispatch({ type: 'FETCH_FAILURE', payload: 'caseCount' });
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
return () => {
|
||||
didCancel = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dispatchUpdateCaseProperty = useCallback(
|
||||
({ updateKey, updateValue, caseId, version }: UpdateCase) => {
|
||||
let didCancel = false;
|
||||
const fetchData = async () => {
|
||||
dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' });
|
||||
try {
|
||||
await updateCaseProperty(
|
||||
caseId,
|
||||
{ [updateKey]: updateValue },
|
||||
version ?? '' // saved object versions are typed as string | undefined, hope that's not true
|
||||
);
|
||||
if (!didCancel) {
|
||||
dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' });
|
||||
fetchCases();
|
||||
getCaseCount('open');
|
||||
getCaseCount('closed');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!didCancel) {
|
||||
errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
|
||||
dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' });
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
return () => {
|
||||
didCancel = true;
|
||||
};
|
||||
},
|
||||
[filterQuery, state.filterOptions]
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
dispatchUpdateCaseProperty,
|
||||
getCaseCount,
|
||||
setFilters,
|
||||
setQueryParams,
|
||||
setSelectedCases,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -22,7 +22,7 @@ interface NewCaseState {
|
|||
updateKey: UpdateKey | null;
|
||||
}
|
||||
|
||||
interface UpdateByKey {
|
||||
export interface UpdateByKey {
|
||||
updateKey: UpdateKey;
|
||||
updateValue: Case[UpdateKey];
|
||||
}
|
||||
|
|
|
@ -6,29 +6,13 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { EuiButton, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { CaseHeaderPage } from './components/case_header_page';
|
||||
import { WrapperPage } from '../../components/wrapper_page';
|
||||
import { AllCases } from './components/all_cases';
|
||||
import { SpyRoute } from '../../utils/route/spy_routes';
|
||||
import * as i18n from './translations';
|
||||
import { getCreateCaseUrl, getConfigureCasesUrl } from '../../components/link_to';
|
||||
|
||||
export const CasesPage = React.memo(() => (
|
||||
<>
|
||||
<WrapperPage>
|
||||
<CaseHeaderPage subtitle={i18n.PAGE_SUBTITLE} title={i18n.PAGE_TITLE}>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton fill href={getCreateCaseUrl()} iconType="plusInCircle">
|
||||
{i18n.CREATE_TITLE}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon href={getConfigureCasesUrl()} iconType="gear" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</CaseHeaderPage>
|
||||
<AllCases />
|
||||
</WrapperPage>
|
||||
<SpyRoute />
|
||||
|
|
|
@ -75,7 +75,12 @@ export const useGetCasesMockState: UseGetCasesState = {
|
|||
perPage: 5,
|
||||
total: 10,
|
||||
},
|
||||
isLoading: false,
|
||||
caseCount: {
|
||||
open: 0,
|
||||
closed: 0,
|
||||
},
|
||||
loading: [],
|
||||
selectedCases: [],
|
||||
isError: false,
|
||||
queryParams: {
|
||||
page: 1,
|
||||
|
@ -83,5 +88,5 @@ export const useGetCasesMockState: UseGetCasesState = {
|
|||
sortField: SortFieldCase.createdAt,
|
||||
sortOrder: 'desc',
|
||||
},
|
||||
filterOptions: { search: '', tags: [] },
|
||||
filterOptions: { search: '', tags: [], state: 'open' },
|
||||
};
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
|
||||
import { Dispatch } from 'react';
|
||||
import { Case } from '../../../../containers/case/types';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { UpdateCase } from '../../../../containers/case/use_get_cases';
|
||||
|
||||
interface GetActions {
|
||||
caseStatus: string;
|
||||
dispatchUpdate: Dispatch<UpdateCase>;
|
||||
}
|
||||
|
||||
export const getActions = ({
|
||||
caseStatus,
|
||||
dispatchUpdate,
|
||||
}: GetActions): Array<DefaultItemIconButtonAction<Case>> => [
|
||||
{
|
||||
description: i18n.DELETE,
|
||||
icon: 'trash',
|
||||
name: i18n.DELETE,
|
||||
// eslint-disable-next-line no-console
|
||||
onClick: ({ caseId }: Case) => console.log('TO DO Delete case', caseId),
|
||||
type: 'icon',
|
||||
'data-test-subj': 'action-delete',
|
||||
},
|
||||
caseStatus === 'open'
|
||||
? {
|
||||
description: i18n.CLOSE_CASE,
|
||||
icon: 'magnet',
|
||||
name: i18n.CLOSE_CASE,
|
||||
onClick: (theCase: Case) =>
|
||||
dispatchUpdate({
|
||||
updateKey: 'state',
|
||||
updateValue: 'closed',
|
||||
caseId: theCase.caseId,
|
||||
version: theCase.version,
|
||||
}),
|
||||
type: 'icon',
|
||||
'data-test-subj': 'action-close',
|
||||
}
|
||||
: {
|
||||
description: i18n.REOPEN_CASE,
|
||||
icon: 'magnet',
|
||||
name: i18n.REOPEN_CASE,
|
||||
onClick: (theCase: Case) =>
|
||||
dispatchUpdate({
|
||||
updateKey: 'state',
|
||||
updateValue: 'open',
|
||||
caseId: theCase.caseId,
|
||||
version: theCase.version,
|
||||
}),
|
||||
type: 'icon',
|
||||
'data-test-subj': 'action-open',
|
||||
},
|
||||
];
|
|
@ -4,7 +4,15 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EuiBadge, EuiTableFieldDataColumnType, EuiTableComputedColumnType } from '@elastic/eui';
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiTableFieldDataColumnType,
|
||||
EuiTableComputedColumnType,
|
||||
EuiTableActionsColumnType,
|
||||
EuiAvatar,
|
||||
} from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
|
||||
import { getEmptyTagValue } from '../../../../components/empty_value';
|
||||
import { Case } from '../../../../containers/case/types';
|
||||
import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date';
|
||||
|
@ -12,17 +20,61 @@ import { CaseDetailsLink } from '../../../../components/links';
|
|||
import { TruncatableText } from '../../../../components/truncatable_text';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export type CasesColumns = EuiTableFieldDataColumnType<Case> | EuiTableComputedColumnType<Case>;
|
||||
export type CasesColumns =
|
||||
| EuiTableFieldDataColumnType<Case>
|
||||
| EuiTableComputedColumnType<Case>
|
||||
| EuiTableActionsColumnType<Case>;
|
||||
|
||||
const renderStringField = (field: string, dataTestSubj: string) =>
|
||||
field != null ? <span data-test-subj={dataTestSubj}>{field}</span> : getEmptyTagValue();
|
||||
const MediumShadeText = styled.p`
|
||||
color: ${({ theme }) => theme.eui.euiColorMediumShade};
|
||||
`;
|
||||
|
||||
export const getCasesColumns = (): CasesColumns[] => [
|
||||
const Spacer = styled.span`
|
||||
margin-left: ${({ theme }) => theme.eui.paddingSizes.s};
|
||||
`;
|
||||
|
||||
const TempNumberComponent = () => <span>{1}</span>;
|
||||
TempNumberComponent.displayName = 'TempNumberComponent';
|
||||
|
||||
export const getCasesColumns = (
|
||||
actions: Array<DefaultItemIconButtonAction<Case>>
|
||||
): CasesColumns[] => [
|
||||
{
|
||||
name: i18n.NAME,
|
||||
render: (theCase: Case) => {
|
||||
if (theCase.caseId != null && theCase.title != null) {
|
||||
return <CaseDetailsLink detailName={theCase.caseId}>{theCase.title}</CaseDetailsLink>;
|
||||
const caseDetailsLinkComponent = (
|
||||
<CaseDetailsLink detailName={theCase.caseId}>{theCase.title}</CaseDetailsLink>
|
||||
);
|
||||
return theCase.state === 'open' ? (
|
||||
caseDetailsLinkComponent
|
||||
) : (
|
||||
<>
|
||||
<MediumShadeText>
|
||||
{caseDetailsLinkComponent}
|
||||
<Spacer>{i18n.CLOSED}</Spacer>
|
||||
</MediumShadeText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return getEmptyTagValue();
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createdBy',
|
||||
name: i18n.REPORTER,
|
||||
render: (createdBy: Case['createdBy']) => {
|
||||
if (createdBy != null) {
|
||||
return (
|
||||
<>
|
||||
<EuiAvatar
|
||||
className="userAction__circle"
|
||||
name={createdBy.fullName ? createdBy.fullName : createdBy.username}
|
||||
size="s"
|
||||
/>
|
||||
<Spacer data-test-subj="case-table-column-createdBy">{createdBy.username}</Spacer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return getEmptyTagValue();
|
||||
},
|
||||
|
@ -50,9 +102,16 @@ export const getCasesColumns = (): CasesColumns[] => [
|
|||
},
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
field: 'commentCount', // TO DO once we have commentCount returned in the API: https://github.com/elastic/kibana/issues/58525
|
||||
name: i18n.COMMENTS,
|
||||
sortable: true,
|
||||
render: TempNumberComponent,
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
name: i18n.CREATED_AT,
|
||||
name: i18n.OPENED_ON,
|
||||
sortable: true,
|
||||
render: (createdAt: Case['createdAt']) => {
|
||||
if (createdAt != null) {
|
||||
|
@ -67,31 +126,7 @@ export const getCasesColumns = (): CasesColumns[] => [
|
|||
},
|
||||
},
|
||||
{
|
||||
field: 'createdBy.username',
|
||||
name: i18n.REPORTER,
|
||||
render: (createdBy: Case['createdBy']['username']) =>
|
||||
renderStringField(createdBy, `case-table-column-username`),
|
||||
},
|
||||
{
|
||||
field: 'updatedAt',
|
||||
name: i18n.LAST_UPDATED,
|
||||
sortable: true,
|
||||
render: (updatedAt: Case['updatedAt']) => {
|
||||
if (updatedAt != null) {
|
||||
return (
|
||||
<FormattedRelativePreferenceDate
|
||||
value={updatedAt}
|
||||
data-test-subj={`case-table-column-updatedAt`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return getEmptyTagValue();
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'state',
|
||||
name: i18n.STATE,
|
||||
sortable: true,
|
||||
render: (state: Case['state']) => renderStringField(state, `case-table-column-state`),
|
||||
name: 'Actions',
|
||||
actions,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -13,13 +13,21 @@ import { useGetCasesMockState } from './__mock__';
|
|||
import * as apiHook from '../../../../containers/case/use_get_cases';
|
||||
|
||||
describe('AllCases', () => {
|
||||
const setQueryParams = jest.fn();
|
||||
const setFilters = jest.fn();
|
||||
const setQueryParams = jest.fn();
|
||||
const setSelectedCases = jest.fn();
|
||||
const getCaseCount = jest.fn();
|
||||
const dispatchUpdateCaseProperty = jest.fn();
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.spyOn(apiHook, 'useGetCases')
|
||||
.mockReturnValue([useGetCasesMockState, setQueryParams, setFilters]);
|
||||
jest.spyOn(apiHook, 'useGetCases').mockReturnValue({
|
||||
...useGetCasesMockState,
|
||||
dispatchUpdateCaseProperty,
|
||||
getCaseCount,
|
||||
setFilters,
|
||||
setQueryParams,
|
||||
setSelectedCases,
|
||||
});
|
||||
moment.tz.setDefault('UTC');
|
||||
});
|
||||
it('should render AllCases', () => {
|
||||
|
@ -40,12 +48,6 @@ describe('AllCases', () => {
|
|||
.first()
|
||||
.text()
|
||||
).toEqual(useGetCasesMockState.data.cases[0].title);
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="case-table-column-state"]`)
|
||||
.first()
|
||||
.text()
|
||||
).toEqual(useGetCasesMockState.data.cases[0].state);
|
||||
expect(
|
||||
wrapper
|
||||
.find(`span[data-test-subj="case-table-column-tags-0"]`)
|
||||
|
@ -54,7 +56,7 @@ describe('AllCases', () => {
|
|||
).toEqual(useGetCasesMockState.data.cases[0].tags[0]);
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="case-table-column-username"]`)
|
||||
.find(`[data-test-subj="case-table-column-createdBy"]`)
|
||||
.first()
|
||||
.text()
|
||||
).toEqual(useGetCasesMockState.data.cases[0].createdBy.username);
|
||||
|
@ -64,13 +66,6 @@ describe('AllCases', () => {
|
|||
.first()
|
||||
.prop('value')
|
||||
).toEqual(useGetCasesMockState.data.cases[0].createdAt);
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="case-table-column-updatedAt"]`)
|
||||
.first()
|
||||
.prop('value')
|
||||
).toEqual(useGetCasesMockState.data.cases[0].updatedAt);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="case-table-case-count"]`)
|
||||
|
@ -85,12 +80,13 @@ describe('AllCases', () => {
|
|||
</TestProviders>
|
||||
);
|
||||
wrapper
|
||||
.find('[data-test-subj="tableHeaderCell_state_5"] [data-test-subj="tableHeaderSortButton"]')
|
||||
.find('[data-test-subj="tableHeaderSortButton"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
expect(setQueryParams).toBeCalledWith({
|
||||
page: 1,
|
||||
perPage: 5,
|
||||
sortField: 'state',
|
||||
sortField: 'createdAt',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,45 +8,85 @@ import React, { useCallback, useMemo } from 'react';
|
|||
import {
|
||||
EuiBasicTable,
|
||||
EuiButton,
|
||||
EuiButtonIcon,
|
||||
EuiContextMenuPanel,
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLoadingContent,
|
||||
EuiProgress,
|
||||
EuiTableSortingType,
|
||||
} from '@elastic/eui';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types';
|
||||
import styled, { css } from 'styled-components';
|
||||
import * as i18n from './translations';
|
||||
|
||||
import { getCasesColumns } from './columns';
|
||||
import { SortFieldCase, Case, FilterOptions } from '../../../../containers/case/types';
|
||||
import { Case, FilterOptions, SortFieldCase } from '../../../../containers/case/types';
|
||||
|
||||
import { useGetCases } from '../../../../containers/case/use_get_cases';
|
||||
import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types';
|
||||
import { Panel } from '../../../../components/panel';
|
||||
import { HeaderSection } from '../../../../components/header_section';
|
||||
import { CasesTableFilters } from './table_filters';
|
||||
|
||||
import {
|
||||
UtilityBar,
|
||||
UtilityBarAction,
|
||||
UtilityBarGroup,
|
||||
UtilityBarSection,
|
||||
UtilityBarText,
|
||||
} from '../../../../components/detection_engine/utility_bar';
|
||||
import { getCreateCaseUrl } from '../../../../components/link_to';
|
||||
} from '../../../../components/utility_bar';
|
||||
import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to';
|
||||
import { getBulkItems } from '../bulk_actions';
|
||||
import { CaseHeaderPage } from '../case_header_page';
|
||||
import { OpenClosedStats } from '../open_closed_stats';
|
||||
import { getActions } from './actions';
|
||||
|
||||
const Div = styled.div`
|
||||
margin-top: ${({ theme }) => theme.eui.paddingSizes.m};
|
||||
`;
|
||||
const FlexItemDivider = styled(EuiFlexItem)`
|
||||
${({ theme }) => css`
|
||||
.euiFlexGroup--gutterMedium > &.euiFlexItem {
|
||||
border-right: ${theme.eui.euiBorderThin};
|
||||
padding-right: ${theme.eui.euiSize};
|
||||
margin-right: ${theme.eui.euiSize};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ProgressLoader = styled(EuiProgress)`
|
||||
${({ theme }) => css`
|
||||
.euiFlexGroup--gutterMedium > &.euiFlexItem {
|
||||
top: 2px;
|
||||
border-radius: ${theme.eui.euiBorderRadius};
|
||||
z-index: ${theme.eui.euiZHeader};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const getSortField = (field: string): SortFieldCase => {
|
||||
if (field === SortFieldCase.createdAt) {
|
||||
return SortFieldCase.createdAt;
|
||||
} else if (field === SortFieldCase.state) {
|
||||
return SortFieldCase.state;
|
||||
} else if (field === SortFieldCase.updatedAt) {
|
||||
return SortFieldCase.updatedAt;
|
||||
}
|
||||
return SortFieldCase.createdAt;
|
||||
};
|
||||
export const AllCases = React.memo(() => {
|
||||
const [
|
||||
{ data, isLoading, queryParams, filterOptions },
|
||||
setQueryParams,
|
||||
const {
|
||||
caseCount,
|
||||
data,
|
||||
dispatchUpdateCaseProperty,
|
||||
filterOptions,
|
||||
getCaseCount,
|
||||
loading,
|
||||
queryParams,
|
||||
selectedCases,
|
||||
setFilters,
|
||||
] = useGetCases();
|
||||
setQueryParams,
|
||||
setSelectedCases,
|
||||
} = useGetCases();
|
||||
|
||||
const tableOnChangeCallback = useCallback(
|
||||
({ page, sort }: EuiBasicTableOnChange) => {
|
||||
|
@ -77,7 +117,13 @@ export const AllCases = React.memo(() => {
|
|||
[filterOptions, setFilters]
|
||||
);
|
||||
|
||||
const memoizedGetCasesColumns = useMemo(() => getCasesColumns(), []);
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
getActions({ caseStatus: filterOptions.state, dispatchUpdate: dispatchUpdateCaseProperty }),
|
||||
[filterOptions.state, dispatchUpdateCaseProperty]
|
||||
);
|
||||
|
||||
const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [filterOptions.state]);
|
||||
const memoizedPagination = useMemo(
|
||||
() => ({
|
||||
pageIndex: queryParams.page - 1,
|
||||
|
@ -88,55 +134,132 @@ export const AllCases = React.memo(() => {
|
|||
[data, queryParams]
|
||||
);
|
||||
|
||||
const getBulkItemsPopoverContent = useCallback(
|
||||
(closePopover: () => void) => (
|
||||
<EuiContextMenuPanel
|
||||
items={getBulkItems({
|
||||
closePopover,
|
||||
selectedCases,
|
||||
caseStatus: filterOptions.state,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
[selectedCases, filterOptions.state]
|
||||
);
|
||||
|
||||
const sorting: EuiTableSortingType<Case> = {
|
||||
sort: { field: queryParams.sortField, direction: queryParams.sortOrder },
|
||||
};
|
||||
const euiBasicTableSelectionProps = useMemo<EuiTableSelectionType<Case>>(
|
||||
() => ({
|
||||
selectable: (item: Case) => true,
|
||||
onSelectionChange: setSelectedCases,
|
||||
}),
|
||||
[selectedCases]
|
||||
);
|
||||
const isCasesLoading = useMemo(
|
||||
() => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1,
|
||||
[loading]
|
||||
);
|
||||
const isDataEmpty = useMemo(() => data.total === 0, [data]);
|
||||
|
||||
return (
|
||||
<Panel loading={isLoading}>
|
||||
<HeaderSection split title={i18n.ALL_CASES}>
|
||||
<>
|
||||
<CaseHeaderPage title={i18n.PAGE_TITLE}>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="m" responsive={false} wrap={true}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<OpenClosedStats
|
||||
caseCount={caseCount}
|
||||
caseState={'open'}
|
||||
getCaseCount={getCaseCount}
|
||||
isLoading={loading.indexOf('caseCount') > -1}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<FlexItemDivider grow={false}>
|
||||
<OpenClosedStats
|
||||
caseCount={caseCount}
|
||||
caseState={'closed'}
|
||||
getCaseCount={getCaseCount}
|
||||
isLoading={loading.indexOf('caseCount') > -1}
|
||||
/>
|
||||
</FlexItemDivider>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton fill href={getCreateCaseUrl()} iconType="plusInCircle">
|
||||
{i18n.CREATE_TITLE}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.CONFIGURE_CASES_BUTTON}
|
||||
href={getConfigureCasesUrl()}
|
||||
iconType="gear"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</CaseHeaderPage>
|
||||
{isCasesLoading && !isDataEmpty && <ProgressLoader size="xs" color="accent" />}
|
||||
<Panel loading={isCasesLoading}>
|
||||
<CasesTableFilters
|
||||
onFilterChanged={onFilterChangedCallback}
|
||||
initial={{ search: filterOptions.search, tags: filterOptions.tags }}
|
||||
initial={{
|
||||
search: filterOptions.search,
|
||||
tags: filterOptions.tags,
|
||||
state: filterOptions.state,
|
||||
}}
|
||||
/>
|
||||
</HeaderSection>
|
||||
{isLoading && isEmpty(data.cases) && (
|
||||
<EuiLoadingContent data-test-subj="initialLoadingPanelAllCases" lines={10} />
|
||||
)}
|
||||
{!isLoading && !isEmpty(data.cases) && (
|
||||
<>
|
||||
<UtilityBar border>
|
||||
<UtilityBarSection>
|
||||
<UtilityBarGroup>
|
||||
<UtilityBarText data-test-subj="case-table-case-count">
|
||||
{i18n.SHOWING_CASES(data.total ?? 0)}
|
||||
</UtilityBarText>
|
||||
</UtilityBarGroup>
|
||||
</UtilityBarSection>
|
||||
</UtilityBar>
|
||||
<EuiBasicTable
|
||||
columns={memoizedGetCasesColumns}
|
||||
itemId="id"
|
||||
items={data.cases}
|
||||
noItemsMessage={
|
||||
<EuiEmptyPrompt
|
||||
title={<h3>{i18n.NO_CASES}</h3>}
|
||||
titleSize="xs"
|
||||
body={i18n.NO_CASES_BODY}
|
||||
actions={
|
||||
<EuiButton fill size="s" href={getCreateCaseUrl()} iconType="plusInCircle">
|
||||
{i18n.ADD_NEW_CASE}
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
onChange={tableOnChangeCallback}
|
||||
pagination={memoizedPagination}
|
||||
sorting={sorting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
{isCasesLoading && isDataEmpty ? (
|
||||
<Div>
|
||||
<EuiLoadingContent data-test-subj="initialLoadingPanelAllCases" lines={10} />
|
||||
</Div>
|
||||
) : (
|
||||
<Div>
|
||||
<UtilityBar border>
|
||||
<UtilityBarSection>
|
||||
<UtilityBarGroup>
|
||||
<UtilityBarText data-test-subj="case-table-case-count">
|
||||
{i18n.SHOWING_CASES(data.total ?? 0)}
|
||||
</UtilityBarText>
|
||||
</UtilityBarGroup>
|
||||
<UtilityBarGroup>
|
||||
<UtilityBarText data-test-subj="case-table-selected-case-count">
|
||||
{i18n.SELECTED_CASES(selectedCases.length)}
|
||||
</UtilityBarText>
|
||||
<UtilityBarAction
|
||||
iconSide="right"
|
||||
iconType="arrowDown"
|
||||
popoverContent={getBulkItemsPopoverContent}
|
||||
>
|
||||
{i18n.BULK_ACTIONS}
|
||||
</UtilityBarAction>
|
||||
</UtilityBarGroup>
|
||||
</UtilityBarSection>
|
||||
</UtilityBar>
|
||||
<EuiBasicTable
|
||||
columns={memoizedGetCasesColumns}
|
||||
isSelectable
|
||||
itemId="caseId"
|
||||
items={data.cases}
|
||||
noItemsMessage={
|
||||
<EuiEmptyPrompt
|
||||
title={<h3>{i18n.NO_CASES}</h3>}
|
||||
titleSize="xs"
|
||||
body={i18n.NO_CASES_BODY}
|
||||
actions={
|
||||
<EuiButton fill size="s" href={getCreateCaseUrl()} iconType="plusInCircle">
|
||||
{i18n.ADD_NEW_CASE}
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
onChange={tableOnChangeCallback}
|
||||
pagination={memoizedPagination}
|
||||
selection={euiBasicTableSelectionProps}
|
||||
sorting={sorting}
|
||||
/>
|
||||
</Div>
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -6,20 +6,22 @@
|
|||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { isEqual } from 'lodash/fp';
|
||||
import { EuiFieldSearch, EuiFilterGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import {
|
||||
EuiFieldSearch,
|
||||
EuiFilterButton,
|
||||
EuiFilterGroup,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import * as i18n from './translations';
|
||||
|
||||
import { FilterOptions } from '../../../../containers/case/types';
|
||||
import { useGetTags } from '../../../../containers/case/use_get_tags';
|
||||
import { TagsFilterPopover } from '../../../../pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover';
|
||||
import { FilterPopover } from '../../../../components/filter_popover';
|
||||
|
||||
interface Initial {
|
||||
search: string;
|
||||
tags: string[];
|
||||
}
|
||||
interface CasesTableFiltersProps {
|
||||
onFilterChanged: (filterOptions: Partial<FilterOptions>) => void;
|
||||
initial: Initial;
|
||||
initial: FilterOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -31,17 +33,18 @@ interface CasesTableFiltersProps {
|
|||
|
||||
const CasesTableFiltersComponent = ({
|
||||
onFilterChanged,
|
||||
initial = { search: '', tags: [] },
|
||||
initial = { search: '', tags: [], state: 'open' },
|
||||
}: CasesTableFiltersProps) => {
|
||||
const [search, setSearch] = useState(initial.search);
|
||||
const [selectedTags, setSelectedTags] = useState(initial.tags);
|
||||
const [{ isLoading, data }] = useGetTags();
|
||||
const [showOpenCases, setShowOpenCases] = useState(initial.state === 'open');
|
||||
const [{ data }] = useGetTags();
|
||||
|
||||
const handleSelectedTags = useCallback(
|
||||
newTags => {
|
||||
if (!isEqual(newTags, selectedTags)) {
|
||||
setSelectedTags(newTags);
|
||||
onFilterChanged({ search, tags: newTags });
|
||||
onFilterChanged({ tags: newTags });
|
||||
}
|
||||
},
|
||||
[search, selectedTags]
|
||||
|
@ -51,12 +54,20 @@ const CasesTableFiltersComponent = ({
|
|||
const trimSearch = newSearch.trim();
|
||||
if (!isEqual(trimSearch, search)) {
|
||||
setSearch(trimSearch);
|
||||
onFilterChanged({ tags: selectedTags, search: trimSearch });
|
||||
onFilterChanged({ search: trimSearch });
|
||||
}
|
||||
},
|
||||
[search, selectedTags]
|
||||
);
|
||||
|
||||
const handleToggleFilter = useCallback(
|
||||
showOpen => {
|
||||
if (showOpen !== showOpenCases) {
|
||||
setShowOpenCases(showOpen);
|
||||
onFilterChanged({ state: showOpen ? 'open' : 'closed' });
|
||||
}
|
||||
},
|
||||
[showOpenCases]
|
||||
);
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="m" justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={true}>
|
||||
|
@ -71,11 +82,32 @@ const CasesTableFiltersComponent = ({
|
|||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFilterGroup>
|
||||
<TagsFilterPopover
|
||||
isLoading={isLoading}
|
||||
onSelectedTagsChanged={handleSelectedTags}
|
||||
selectedTags={selectedTags}
|
||||
tags={data}
|
||||
<EuiFilterButton
|
||||
withNext
|
||||
hasActiveFilters={showOpenCases}
|
||||
onClick={handleToggleFilter.bind(null, true)}
|
||||
>
|
||||
{i18n.OPEN_CASES}
|
||||
</EuiFilterButton>
|
||||
<EuiFilterButton
|
||||
hasActiveFilters={!showOpenCases}
|
||||
onClick={handleToggleFilter.bind(null, false)}
|
||||
>
|
||||
{i18n.CLOSED_CASES}
|
||||
</EuiFilterButton>
|
||||
<FilterPopover
|
||||
buttonLabel={i18n.REPORTER}
|
||||
onSelectedOptionsChanged={() => {}}
|
||||
selectedOptions={[]}
|
||||
options={[]}
|
||||
optionsEmptyLabel={i18n.NO_REPORTERS_AVAILABLE}
|
||||
/>
|
||||
<FilterPopover
|
||||
buttonLabel={i18n.TAGS}
|
||||
onSelectedOptionsChanged={handleSelectedTags}
|
||||
selectedOptions={selectedTags}
|
||||
options={data}
|
||||
optionsEmptyLabel={i18n.NO_TAGS_AVAILABLE}
|
||||
/>
|
||||
</EuiFilterGroup>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -8,9 +8,6 @@ import { i18n } from '@kbn/i18n';
|
|||
|
||||
export * from '../../translations';
|
||||
|
||||
export const ALL_CASES = i18n.translate('xpack.siem.case.caseTable.title', {
|
||||
defaultMessage: 'All Cases',
|
||||
});
|
||||
export const NO_CASES = i18n.translate('xpack.siem.case.caseTable.noCases.title', {
|
||||
defaultMessage: 'No Cases',
|
||||
});
|
||||
|
@ -21,6 +18,12 @@ export const ADD_NEW_CASE = i18n.translate('xpack.siem.case.caseTable.addNewCase
|
|||
defaultMessage: 'Add New Case',
|
||||
});
|
||||
|
||||
export const SELECTED_CASES = (totalRules: number) =>
|
||||
i18n.translate('xpack.siem.case.caseTable.selectedCasesTitle', {
|
||||
values: { totalRules },
|
||||
defaultMessage: 'Selected {totalRules} {totalRules, plural, =1 {case} other {cases}}',
|
||||
});
|
||||
|
||||
export const SHOWING_CASES = (totalRules: number) =>
|
||||
i18n.translate('xpack.siem.case.caseTable.showingCasesTitle', {
|
||||
values: { totalRules },
|
||||
|
@ -33,16 +36,36 @@ export const UNIT = (totalCount: number) =>
|
|||
defaultMessage: `{totalCount, plural, =1 {case} other {cases}}`,
|
||||
});
|
||||
|
||||
export const SEARCH_CASES = i18n.translate(
|
||||
'xpack.siem.detectionEngine.case.caseTable.searchAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Search cases',
|
||||
}
|
||||
);
|
||||
export const SEARCH_CASES = i18n.translate('xpack.siem.case.caseTable.searchAriaLabel', {
|
||||
defaultMessage: 'Search cases',
|
||||
});
|
||||
|
||||
export const SEARCH_PLACEHOLDER = i18n.translate(
|
||||
'xpack.siem.detectionEngine.case.caseTable.searchPlaceholder',
|
||||
{
|
||||
defaultMessage: 'e.g. case name',
|
||||
}
|
||||
);
|
||||
export const BULK_ACTIONS = i18n.translate('xpack.siem.case.caseTable.bulkActions', {
|
||||
defaultMessage: 'Bulk actions',
|
||||
});
|
||||
|
||||
export const SEARCH_PLACEHOLDER = i18n.translate('xpack.siem.case.caseTable.searchPlaceholder', {
|
||||
defaultMessage: 'e.g. case name',
|
||||
});
|
||||
export const OPEN_CASES = i18n.translate('xpack.siem.case.caseTable.openCases', {
|
||||
defaultMessage: 'Open cases',
|
||||
});
|
||||
export const CLOSED_CASES = i18n.translate('xpack.siem.case.caseTable.closedCases', {
|
||||
defaultMessage: 'Closed cases',
|
||||
});
|
||||
|
||||
export const CLOSED = i18n.translate('xpack.siem.case.caseTable.closed', {
|
||||
defaultMessage: 'Closed',
|
||||
});
|
||||
export const DELETE = i18n.translate('xpack.siem.case.caseTable.delete', {
|
||||
defaultMessage: 'Delete',
|
||||
});
|
||||
export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', {
|
||||
defaultMessage: 'Reopen case',
|
||||
});
|
||||
export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', {
|
||||
defaultMessage: 'Close case',
|
||||
});
|
||||
export const DUPLICATE_CASE = i18n.translate('xpack.siem.case.caseTable.duplicateCase', {
|
||||
defaultMessage: 'Duplicate case',
|
||||
});
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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 { EuiContextMenuItem } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import * as i18n from './translations';
|
||||
import { Case } from '../../../../containers/case/types';
|
||||
|
||||
interface GetBulkItems {
|
||||
// cases: Case[];
|
||||
closePopover: () => void;
|
||||
// dispatch: Dispatch<Action>;
|
||||
// dispatchToaster: Dispatch<ActionToaster>;
|
||||
// reFetchCases: (refreshPrePackagedCase?: boolean) => void;
|
||||
selectedCases: Case[];
|
||||
caseStatus: string;
|
||||
}
|
||||
|
||||
export const getBulkItems = ({
|
||||
// cases,
|
||||
closePopover,
|
||||
caseStatus,
|
||||
// dispatch,
|
||||
// dispatchToaster,
|
||||
// reFetchCases,
|
||||
selectedCases,
|
||||
}: GetBulkItems) => {
|
||||
return [
|
||||
caseStatus === 'open' ? (
|
||||
<EuiContextMenuItem
|
||||
key={i18n.BULK_ACTION_CLOSE_SELECTED}
|
||||
icon="magnet"
|
||||
disabled={true} // TO DO
|
||||
onClick={async () => {
|
||||
closePopover();
|
||||
// await deleteCasesAction(selectedCases, dispatch, dispatchToaster);
|
||||
// reFetchCases(true);
|
||||
}}
|
||||
>
|
||||
{i18n.BULK_ACTION_CLOSE_SELECTED}
|
||||
</EuiContextMenuItem>
|
||||
) : (
|
||||
<EuiContextMenuItem
|
||||
key={i18n.BULK_ACTION_OPEN_SELECTED}
|
||||
icon="magnet"
|
||||
disabled={true} // TO DO
|
||||
onClick={async () => {
|
||||
closePopover();
|
||||
// await deleteCasesAction(selectedCases, dispatch, dispatchToaster);
|
||||
// reFetchCases(true);
|
||||
}}
|
||||
>
|
||||
{i18n.BULK_ACTION_OPEN_SELECTED}
|
||||
</EuiContextMenuItem>
|
||||
),
|
||||
<EuiContextMenuItem
|
||||
key={i18n.BULK_ACTION_DELETE_SELECTED}
|
||||
icon="trash"
|
||||
disabled={true} // TO DO
|
||||
onClick={async () => {
|
||||
closePopover();
|
||||
// await deleteCasesAction(selectedCases, dispatch, dispatchToaster);
|
||||
// reFetchCases(true);
|
||||
}}
|
||||
>
|
||||
{i18n.BULK_ACTION_DELETE_SELECTED}
|
||||
</EuiContextMenuItem>,
|
||||
];
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const BULK_ACTION_CLOSE_SELECTED = i18n.translate(
|
||||
'xpack.siem.case.caseTable.bulkActions.closeSelectedTitle',
|
||||
{
|
||||
defaultMessage: 'Close selected',
|
||||
}
|
||||
);
|
||||
|
||||
export const BULK_ACTION_OPEN_SELECTED = i18n.translate(
|
||||
'xpack.siem.case.caseTable.bulkActions.openSelectedTitle',
|
||||
{
|
||||
defaultMessage: 'Open selected',
|
||||
}
|
||||
);
|
||||
|
||||
export const BULK_ACTION_DELETE_SELECTED = i18n.translate(
|
||||
'xpack.siem.case.caseTable.bulkActions.deleteSelectedTitle',
|
||||
{
|
||||
defaultMessage: 'Delete selected',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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, { Dispatch, useEffect, useMemo } from 'react';
|
||||
import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import * as i18n from '../all_cases/translations';
|
||||
import { CaseCount } from '../../../../containers/case/use_get_cases';
|
||||
|
||||
export interface Props {
|
||||
caseCount: CaseCount;
|
||||
caseState: 'open' | 'closed';
|
||||
getCaseCount: Dispatch<keyof CaseCount>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const OpenClosedStats = React.memo<Props>(
|
||||
({ caseCount, caseState, getCaseCount, isLoading }) => {
|
||||
useEffect(() => {
|
||||
getCaseCount(caseState);
|
||||
}, [caseState]);
|
||||
|
||||
const openClosedStats = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: caseState === 'open' ? i18n.OPEN_CASES : i18n.CLOSED_CASES,
|
||||
description: isLoading ? <EuiLoadingSpinner /> : caseCount[caseState],
|
||||
},
|
||||
],
|
||||
[caseCount, caseState, isLoading]
|
||||
);
|
||||
return <EuiDescriptionList textStyle="reverse" listItems={openClosedStats} />;
|
||||
}
|
||||
);
|
||||
|
||||
OpenClosedStats.displayName = 'OpenClosedStats';
|
|
@ -18,8 +18,8 @@ export const NAME = i18n.translate('xpack.siem.case.caseView.name', {
|
|||
defaultMessage: 'Name',
|
||||
});
|
||||
|
||||
export const CREATED_AT = i18n.translate('xpack.siem.case.caseView.createdAt', {
|
||||
defaultMessage: 'Created at',
|
||||
export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', {
|
||||
defaultMessage: 'Opened on',
|
||||
});
|
||||
|
||||
export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', {
|
||||
|
@ -88,6 +88,21 @@ export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', {
|
|||
defaultMessage: 'Tags',
|
||||
});
|
||||
|
||||
export const NO_TAGS_AVAILABLE = i18n.translate('xpack.siem.case.allCases.noTagsAvailable', {
|
||||
defaultMessage: 'No tags available',
|
||||
});
|
||||
|
||||
export const NO_REPORTERS_AVAILABLE = i18n.translate(
|
||||
'xpack.siem.case.caseView.noReportersAvailable',
|
||||
{
|
||||
defaultMessage: 'No reporters available.',
|
||||
}
|
||||
);
|
||||
|
||||
export const COMMENTS = i18n.translate('xpack.siem.case.allCases.comments', {
|
||||
defaultMessage: 'Comments',
|
||||
});
|
||||
|
||||
export const TAGS_HELP = i18n.translate('xpack.siem.case.createCase.fieldTagsHelpText', {
|
||||
defaultMessage:
|
||||
'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.',
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
UtilityBarGroup,
|
||||
UtilityBarSection,
|
||||
UtilityBarText,
|
||||
} from '../../../../components/detection_engine/utility_bar';
|
||||
} from '../../../../components/utility_bar';
|
||||
import { columns } from './columns';
|
||||
import { ColumnTypes, PageTypes, SortTypes } from './types';
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
UtilityBarGroup,
|
||||
UtilityBarSection,
|
||||
UtilityBarText,
|
||||
} from '../../../../../components/detection_engine/utility_bar';
|
||||
} from '../../../../../components/utility_bar';
|
||||
import * as i18n from './translations';
|
||||
import { useUiSetting$ } from '../../../../../lib/kibana';
|
||||
import { DEFAULT_NUMBER_FORMAT } from '../../../../../../common/constants';
|
||||
|
|
|
@ -30,7 +30,7 @@ import {
|
|||
UtilityBarGroup,
|
||||
UtilityBarSection,
|
||||
UtilityBarText,
|
||||
} from '../../../../components/detection_engine/utility_bar';
|
||||
} from '../../../../components/utility_bar';
|
||||
import { useStateToaster } from '../../../../components/toasters';
|
||||
import { Loader } from '../../../../components/loader';
|
||||
import { Panel } from '../../../../components/panel';
|
||||
|
|
Loading…
Reference in a new issue