[SIEM] [Case] All cases page design updates (#59248)

This commit is contained in:
Steph Milovic 2020-03-05 11:29:44 -07:00 committed by GitHub
parent 46738cfa0a
commit 91a5b17cfd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 863 additions and 216 deletions

View file

@ -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';

View file

@ -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,

View file

@ -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', () => {

View file

@ -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>(

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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 '),

View file

@ -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';

View file

@ -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',
}

View file

@ -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,
};
};

View file

@ -22,7 +22,7 @@ interface NewCaseState {
updateKey: UpdateKey | null;
}
interface UpdateByKey {
export interface UpdateByKey {
updateKey: UpdateKey;
updateValue: Case[UpdateKey];
}

View file

@ -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 />

View file

@ -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' },
};

View file

@ -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',
},
];

View file

@ -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,
},
];

View file

@ -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',
});
});

View file

@ -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>
</>
);
});

View file

@ -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>

View file

@ -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',
});

View file

@ -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>,
];
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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',
}
);

View file

@ -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';

View file

@ -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.',

View file

@ -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';

View file

@ -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';

View file

@ -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';