From 1106195706453d6e713f22a456f26120f8d26d74 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Wed, 3 Mar 2021 13:31:34 +0000 Subject: [PATCH] [Security Solution] Case ui enhancement (#91863) (#93387) * ui enhancement * fix actions * unit test * update row actions * add case status all * update find status * fix type * remove all case count from dropdown * fix type error * fix unit test * disable bulk actions on status all * clean up * fix types * fix cypress tests * review * review * update status is only available for individual cases * update available actions on status all * fix unit test * remove lodash get * rename status all * omit status if it is set to all * do not sent status if itis set to all * Remove all status from the backend * Hide actions on all status * fix unit test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Christos Nasikas # Conflicts: # x-pack/plugins/security_solution/public/cases/containers/api.ts --- .../server/routes/api/cases/find_cases.ts | 2 - .../case/server/routes/api/cases/helpers.ts | 2 +- .../integration/cases/creation.spec.ts | 2 + .../cypress/screens/all_cases.ts | 2 + .../cypress/tasks/create_new_case.ts | 6 + .../cases/components/all_cases/actions.tsx | 20 +-- .../cases/components/all_cases/columns.tsx | 4 +- .../cases/components/all_cases/helpers.ts | 12 +- .../cases/components/all_cases/index.test.tsx | 164 ++++++++++++++++++ .../cases/components/all_cases/index.tsx | 30 +++- .../all_cases/status_filter.test.tsx | 2 + .../components/all_cases/status_filter.tsx | 20 ++- .../components/all_cases/table_filters.tsx | 11 +- .../cases/components/bulk_actions/index.tsx | 12 +- .../case_action_bar/status_context_menu.tsx | 5 +- .../public/cases/components/status/config.ts | 31 +--- .../public/cases/components/status/index.ts | 1 + .../public/cases/components/status/status.tsx | 10 +- .../cases/components/status/translations.ts | 4 + .../public/cases/components/status/types.ts | 43 +++++ .../public/cases/containers/api.test.tsx | 1 - .../public/cases/containers/api.ts | 8 +- .../public/cases/containers/types.ts | 5 +- .../public/cases/containers/use_get_cases.tsx | 4 +- 24 files changed, 315 insertions(+), 86 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/components/status/types.ts diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index d04f01eb735379..bc6907f52b9eba 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -37,7 +37,6 @@ export function initFindCasesApi({ caseService, router, logger }: RouteDeps) { CasesFindRequestRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) ); - const queryArgs = { tags: queryParams.tags, reporters: queryParams.reporters, @@ -47,7 +46,6 @@ export function initFindCasesApi({ caseService, router, logger }: RouteDeps) { }; const caseQueries = constructQueryOptions(queryArgs); - const cases = await caseService.findCasesGroupedByID({ client, caseOptions: { ...queryParams, ...caseQueries.case }, diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index a1a7f4f9da8f5b..8659ab02d6d532 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -28,7 +28,7 @@ export const addStatusFilter = ({ appendFilter, type = CASE_SAVED_OBJECT, }: { - status: CaseStatuses | undefined; + status?: CaseStatuses; appendFilter?: string; type?: string; }) => { diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts index 64ce6be9ec457b..f46feae946242c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts @@ -46,6 +46,7 @@ import { backToCases, createCase, fillCasesMandatoryfields, + filterStatusOpen, } from '../../tasks/create_new_case'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; @@ -74,6 +75,7 @@ describe('Cases', () => { attachTimeline(this.mycase); createCase(); backToCases(); + filterStatusOpen(); cy.get(ALL_CASES_PAGE_TITLE).should('have.text', 'Cases'); cy.get(ALL_CASES_OPEN_CASES_STATS).should('have.text', 'Open cases1'); diff --git a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts index e9c5ff89dd8c4b..fa6b6add57bacd 100644 --- a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts +++ b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts @@ -25,6 +25,8 @@ export const ALL_CASES_NAME = '[data-test-subj="case-details-link"]'; export const ALL_CASES_OPEN_CASES_COUNT = '[data-test-subj="case-status-filter"]'; +export const ALL_CASES_OPEN_FILTER = '[data-test-subj="case-status-filter-open"]'; + export const ALL_CASES_OPEN_CASES_STATS = '[data-test-subj="openStatsHeader"]'; export const ALL_CASES_OPENED_ON = '[data-test-subj="case-table-column-createdAt"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts index e67cee4f38734e..ed9174e2a74bb9 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts @@ -11,6 +11,7 @@ import { ServiceNowconnectorOptions, TestCase, } from '../objects/case'; +import { ALL_CASES_OPEN_CASES_COUNT, ALL_CASES_OPEN_FILTER } from '../screens/all_cases'; import { BACK_TO_CASES_BTN, @@ -40,6 +41,11 @@ export const backToCases = () => { cy.get(BACK_TO_CASES_BTN).click({ force: true }); }; +export const filterStatusOpen = () => { + cy.get(ALL_CASES_OPEN_CASES_COUNT).click(); + cy.get(ALL_CASES_OPEN_FILTER).click(); +}; + export const fillCasesMandatoryfields = (newCase: TestCase) => { cy.get(TITLE_INPUT).type(newCase.name, { force: true }); newCase.tags.forEach((tag) => { diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx index 66563deae54227..046da5e833bf82 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx @@ -13,6 +13,7 @@ import { Case, SubCase } from '../../containers/types'; import { UpdateCase } from '../../containers/use_get_cases'; import { statuses } from '../status'; import * as i18n from './translations'; +import { isIndividual } from './helpers'; interface GetActions { caseStatus: string; @@ -20,16 +21,14 @@ interface GetActions { deleteCaseOnClick: (deleteCase: Case) => void; } -const hasSubCases = (subCases: SubCase[] | null | undefined) => - subCases != null && subCases?.length > 0; - export const getActions = ({ caseStatus, dispatchUpdate, deleteCaseOnClick, }: GetActions): Array> => { const openCaseAction = { - available: (item: Case) => caseStatus !== CaseStatuses.open && !hasSubCases(item.subCases), + available: (item: Case | SubCase) => item.status !== CaseStatuses.open, + enabled: (item: Case | SubCase) => isIndividual(item), description: statuses[CaseStatuses.open].actions.single.title, icon: statuses[CaseStatuses.open].icon, name: statuses[CaseStatuses.open].actions.single.title, @@ -45,8 +44,8 @@ export const getActions = ({ }; const makeInProgressAction = { - available: (item: Case) => - caseStatus !== CaseStatuses['in-progress'] && !hasSubCases(item.subCases), + available: (item: Case) => item.status !== CaseStatuses['in-progress'], + enabled: (item: Case | SubCase) => isIndividual(item), description: statuses[CaseStatuses['in-progress']].actions.single.title, icon: statuses[CaseStatuses['in-progress']].icon, name: statuses[CaseStatuses['in-progress']].actions.single.title, @@ -62,7 +61,8 @@ export const getActions = ({ }; const closeCaseAction = { - available: (item: Case) => caseStatus !== CaseStatuses.closed && !hasSubCases(item.subCases), + available: (item: Case | SubCase) => item.status !== CaseStatuses.closed, + enabled: (item: Case | SubCase) => isIndividual(item), description: statuses[CaseStatuses.closed].actions.single.title, icon: statuses[CaseStatuses.closed].icon, name: statuses[CaseStatuses.closed].actions.single.title, @@ -78,6 +78,9 @@ export const getActions = ({ }; return [ + openCaseAction, + makeInProgressAction, + closeCaseAction, { description: i18n.DELETE_CASE, icon: 'trash', @@ -86,8 +89,5 @@ export const getActions = ({ type: 'icon', 'data-test-subj': 'action-delete', }, - openCaseAction, - makeInProgressAction, - closeCaseAction, ]; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx index 47db362c7b4bfe..e69f85c8629620 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx @@ -19,7 +19,7 @@ import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../../case/common/api'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { Case, SubCase } from '../../containers/types'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; @@ -204,7 +204,7 @@ export const getCasesColumns = ( name: i18n.STATUS, render: (theCase: Case) => { if (theCase?.subCases == null || theCase.subCases.length === 0) { - if (theCase.status == null) { + if (theCase.status == null || theCase.type === CaseType.collection) { return getEmptyTagValue(); } return ; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts index 1ab36d3c672250..519be95fcdfef5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts @@ -6,14 +6,24 @@ */ import { filter } from 'lodash/fp'; -import { AssociationType, CaseStatuses } from '../../../../../case/common/api'; +import { AssociationType, CaseStatuses, CaseType } from '../../../../../case/common/api'; import { Case, SubCase } from '../../containers/types'; import { statuses } from '../status'; +export const isSelectedCasesIncludeCollections = (selectedCases: Case[]) => + selectedCases.length > 0 && + selectedCases.some((caseObj: Case) => caseObj.type === CaseType.collection); + export const isSubCase = (theCase: Case | SubCase): theCase is SubCase => (theCase as SubCase).caseParentId !== undefined && (theCase as SubCase).associationType === AssociationType.subCase; +export const isCollection = (theCase: Case | SubCase | null | undefined) => + theCase != null && (theCase as Case).type === CaseType.collection; + +export const isIndividual = (theCase: Case | SubCase | null | undefined) => + theCase != null && (theCase as Case).type === CaseType.individual; + export const getSubCasesStatusCountsBadges = ( subCases: SubCase[] ): Array<{ name: CaseStatuses; color: string; count: number }> => { diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index a145bdf117813e..9654681ce8e32d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -24,6 +24,7 @@ import { useUpdateCases } from '../../containers/use_bulk_update_case'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { getCasesColumns } from './columns'; import { AllCases } from '.'; +import { StatusAll } from '../status'; jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); @@ -111,6 +112,11 @@ describe('AllCases', () => { }); it('should render AllCases', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, + }); + const wrapper = mount( @@ -144,6 +150,11 @@ describe('AllCases', () => { }); it('should render the stats', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed }, + }); + const wrapper = mount( @@ -202,6 +213,7 @@ describe('AllCases', () => { it('should render empty fields', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, data: { ...defaultGetCases.data, cases: [ @@ -240,6 +252,78 @@ describe('AllCases', () => { }); }); + it('should render correct actions for case (with type individual and filter status open)', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, + }); + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]').first().simulate('click'); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="action-open"]').exists()).toBeFalsy(); + expect( + wrapper.find('[data-test-subj="action-in-progress"]').first().props().disabled + ).toBeFalsy(); + expect(wrapper.find('[data-test-subj="action-close"]').first().props().disabled).toBeFalsy(); + expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toBeFalsy(); + }); + }); + + it('should enable correct actions for sub cases', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + data: { + ...defaultGetCases.data, + cases: [ + { + ...defaultGetCases.data.cases[0], + id: 'my-case-with-subcases', + createdAt: null, + createdBy: null, + status: null, + subCases: [ + { + id: 'sub-case-id', + }, + ], + tags: null, + title: null, + totalComment: null, + totalAlerts: null, + type: CaseType.collection, + }, + ], + }, + }); + const wrapper = mount( + + + + ); + await waitFor(() => { + wrapper + .find( + '[data-test-subj="sub-cases-table-my-case-with-subcases"] [data-test-subj="euiCollapsedItemActionsButton"]' + ) + .last() + .simulate('click'); + expect(wrapper.find('[data-test-subj="action-open"]').first().props().disabled).toEqual(true); + expect( + wrapper.find('[data-test-subj="action-in-progress"]').first().props().disabled + ).toEqual(true); + expect(wrapper.find('[data-test-subj="action-close"]').first().props().disabled).toEqual( + true + ); + expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toEqual( + false + ); + }); + }); + it('should not render case link or actions on modal=true', async () => { const wrapper = mount( @@ -297,6 +381,15 @@ describe('AllCases', () => { it('opens case when row action icon clicked', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, + data: { + ...defaultGetCases.data, + cases: [ + { + ...defaultGetCases.data.cases[0], + status: CaseStatuses.closed, + }, + ], + }, filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed }, }); @@ -342,6 +435,7 @@ describe('AllCases', () => { it('Bulk delete', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed }, selectedCases: useGetCasesMockState.data.cases, }); @@ -377,9 +471,78 @@ describe('AllCases', () => { }); }); + it('Renders only bulk delete on status all', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: StatusAll }, + selectedCases: [...useGetCasesMockState.data.cases], + }); + + const wrapper = mount( + + + + ); + await waitFor(() => { + wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); + expect(wrapper.find('[data-test-subj="cases-bulk-open-button"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').exists()).toEqual( + false + ); + expect(wrapper.find('[data-test-subj="cases-bulk-close-button"]').exists()).toEqual(false); + expect( + wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().props().disabled + ).toEqual(false); + }); + }); + + it('Renders correct bulk actoins for case collection when filter status is set to all - enable only bulk delete if any collection is selected', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, + selectedCases: [ + ...useGetCasesMockState.data.cases, + { + ...useGetCasesMockState.data.cases[0], + type: CaseType.collection, + }, + ], + }); + + useDeleteCasesMock + .mockReturnValueOnce({ + ...defaultDeleteCases, + isDisplayConfirmDeleteModal: false, + }) + .mockReturnValue({ + ...defaultDeleteCases, + isDisplayConfirmDeleteModal: true, + }); + + const wrapper = mount( + + + + ); + await waitFor(() => { + wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); + expect(wrapper.find('[data-test-subj="cases-bulk-open-button"]').exists()).toEqual(false); + expect( + wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').first().props().disabled + ).toEqual(true); + expect( + wrapper.find('[data-test-subj="cases-bulk-close-button"]').first().props().disabled + ).toEqual(true); + expect( + wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().props().disabled + ).toEqual(false); + }); + }); + it('Bulk close status update', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, selectedCases: useGetCasesMockState.data.cases, }); @@ -420,6 +583,7 @@ describe('AllCases', () => { it('Bulk in-progress status update', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, selectedCases: useGetCasesMockState.data.cases, }); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index 56dcf3bc28757e..5f0e72564f60e9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -54,7 +54,9 @@ import { SecurityPageName } from '../../../app/types'; import { useKibana } from '../../../common/lib/kibana'; import { APP_ID } from '../../../../common/constants'; import { Stats } from '../status'; +import { SELECTABLE_MESSAGE_COLLECTIONS } from '../../translations'; import { getExpandedRowMap } from './expanded_row'; +import { isSelectedCasesIncludeCollections } from './helpers'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -268,10 +270,17 @@ export const AllCases = React.memo( deleteCasesAction: toggleBulkDeleteModal, selectedCaseIds, updateCaseStatus: handleUpdateCaseStatus, + includeCollections: isSelectedCasesIncludeCollections(selectedCases), })} /> ), - [selectedCaseIds, filterOptions.status, toggleBulkDeleteModal, handleUpdateCaseStatus] + [ + selectedCases, + selectedCaseIds, + filterOptions.status, + toggleBulkDeleteModal, + handleUpdateCaseStatus, + ] ); const handleDispatchUpdate = useCallback( (args: Omit) => { @@ -379,9 +388,8 @@ export const AllCases = React.memo( const euiBasicTableSelectionProps = useMemo>( () => ({ - selectable: (theCase) => isEmpty(theCase.subCases), onSelectionChange: setSelectedCases, - selectableMessage: (selectable) => (!selectable ? i18n.SELECTABLE_MESSAGE_COLLECTIONS : ''), + selectableMessage: (selectable) => (!selectable ? SELECTABLE_MESSAGE_COLLECTIONS : ''), }), [setSelectedCases] ); @@ -410,6 +418,8 @@ export const AllCases = React.memo( [isModal, onRowClick] ); + const enableBuckActions = userCanCrud && !isModal; + return ( <> {!isEmpty(actionsErrors) && ( @@ -506,10 +516,12 @@ export const AllCases = React.memo( {!isModal && ( - - {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} - - {userCanCrud && ( + {enableBuckActions && ( + + {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} + + )} + {enableBuckActions && ( ( ( onChange={tableOnChangeCallback} pagination={memoizedPagination} rowProps={tableRowProps} - selection={userCanCrud && !isModal ? euiBasicTableSelectionProps : undefined} + selection={enableBuckActions ? euiBasicTableSelectionProps : undefined} sorting={sorting} className={classnames({ isModal })} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx index 11d53b6609e74c..9d5b36515182dd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx @@ -11,8 +11,10 @@ import { waitFor } from '@testing-library/react'; import { CaseStatuses } from '../../../../../case/common/api'; import { StatusFilter } from './status_filter'; +import { StatusAll } from '../status'; const stats = { + [StatusAll]: 0, [CaseStatuses.open]: 2, [CaseStatuses['in-progress']]: 5, [CaseStatuses.closed]: 7, diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx index 41997d6f384214..34186a201cc05e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx @@ -7,14 +7,13 @@ import React, { memo } from 'react'; import { EuiSuperSelect, EuiSuperSelectOption, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../case/common/api'; -import { Status, statuses } from '../status'; +import { Status, statuses, StatusAll, CaseStatusWithAllStatus } from '../status'; interface Props { - stats: Record; - selectedStatus: CaseStatuses; - onStatusChanged: (status: CaseStatuses) => void; - disabledStatuses?: CaseStatuses[]; + stats: Record; + selectedStatus: CaseStatusWithAllStatus; + onStatusChanged: (status: CaseStatusWithAllStatus) => void; + disabledStatuses?: CaseStatusWithAllStatus[]; } const StatusFilterComponent: React.FC = ({ @@ -23,15 +22,18 @@ const StatusFilterComponent: React.FC = ({ onStatusChanged, disabledStatuses = [], }) => { - const caseStatuses = Object.keys(statuses) as CaseStatuses[]; - const options: Array> = caseStatuses.map((status) => ({ + const caseStatuses = Object.keys(statuses) as CaseStatusWithAllStatus[]; + const options: Array> = [ + StatusAll, + ...caseStatuses, + ].map((status) => ({ value: status, inputDisplay: ( - {` (${stats[status]})`} + {status !== StatusAll && {` (${stats[status]})`}} ), disabled: disabledStatuses.includes(status), diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx index 61bbbac5a1e847..84b032489f3269 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx @@ -15,6 +15,7 @@ import { FilterOptions } from '../../containers/types'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; import { FilterPopover } from '../filter_popover'; +import { CaseStatusWithAllStatus, StatusAll } from '../status'; import { StatusFilter } from './status_filter'; import * as i18n from './translations'; @@ -42,7 +43,12 @@ const StatusFilterWrapper = styled(EuiFlexItem)` * @param onFilterChanged change listener to be notified on filter changes */ -const defaultInitial = { search: '', reporters: [], status: CaseStatuses.open, tags: [] }; +const defaultInitial = { + search: '', + reporters: [], + status: StatusAll, + tags: [], +}; const CasesTableFiltersComponent = ({ countClosedCases, @@ -126,7 +132,7 @@ const CasesTableFiltersComponent = ({ ); const onStatusChanged = useCallback( - (status: CaseStatuses) => { + (status: CaseStatusWithAllStatus) => { onFilterChanged({ status }); }, [onFilterChanged] @@ -134,6 +140,7 @@ const CasesTableFiltersComponent = ({ const stats = useMemo( () => ({ + [StatusAll]: null, [CaseStatuses.open]: countOpenCases ?? 0, [CaseStatuses['in-progress']]: countInProgressCases ?? 0, [CaseStatuses.closed]: countClosedCases ?? 0, diff --git a/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx b/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx index ec3b391cdcbfee..a6d5a0679df375 100644 --- a/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx @@ -9,15 +9,16 @@ import React from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; import { CaseStatuses } from '../../../../../case/common/api'; -import { statuses } from '../status'; +import { statuses, CaseStatusWithAllStatus } from '../status'; import * as i18n from './translations'; interface GetBulkItems { - caseStatus: CaseStatuses; + caseStatus: CaseStatusWithAllStatus; closePopover: () => void; deleteCasesAction: (cases: string[]) => void; selectedCaseIds: string[]; updateCaseStatus: (status: string) => void; + includeCollections: boolean; } export const getBulkItems = ({ @@ -26,13 +27,14 @@ export const getBulkItems = ({ deleteCasesAction, selectedCaseIds, updateCaseStatus, + includeCollections, }: GetBulkItems) => { let statusMenuItems: JSX.Element[] = []; const openMenuItem = ( { @@ -47,7 +49,7 @@ export const getBulkItems = ({ const inProgressMenuItem = ( { @@ -62,7 +64,7 @@ export const getBulkItems = ({ const closeMenuItem = ( { diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx index e0cdf9dc6d9ebf..7f9ffbd8dc01dd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx @@ -8,8 +8,8 @@ import React, { memo, useCallback, useMemo, useState } from 'react'; import { memoize } from 'lodash/fp'; import { EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../case/common/api'; -import { Status, statuses } from '../status'; +import { caseStatuses, CaseStatuses } from '../../../../../case/common/api'; +import { Status } from '../status'; interface Props { currentStatus: CaseStatuses; @@ -34,7 +34,6 @@ const StatusContextMenuComponent: React.FC = ({ currentStatus, onStatusCh [closePopover, onStatusChanged] ); - const caseStatuses = Object.keys(statuses) as CaseStatuses[]; const panelItems = caseStatuses.map((status: CaseStatuses) => ( ; +export const allCaseStatus: AllCaseStatus = { + [StatusAll]: { color: 'hollow', label: i18n.ALL }, +}; export const statuses: Statuses = { [CaseStatuses.open]: { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/index.ts b/x-pack/plugins/security_solution/public/cases/components/status/index.ts index 2da6cd26d5ab4e..94d7cb6a318302 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/index.ts +++ b/x-pack/plugins/security_solution/public/cases/components/status/index.ts @@ -8,3 +8,4 @@ export * from './status'; export * from './config'; export * from './stats'; +export * from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/status/status.tsx b/x-pack/plugins/security_solution/public/cases/components/status/status.tsx index ba0f9a9cfde00a..de4c979daf4c1a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/status.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/status.tsx @@ -9,12 +9,12 @@ import React, { memo, useMemo } from 'react'; import { noop } from 'lodash/fp'; import { EuiBadge } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../case/common/api'; -import { statuses } from './config'; +import { allCaseStatus, statuses } from './config'; +import { CaseStatusWithAllStatus, StatusAll } from './types'; import * as i18n from './translations'; interface Props { - type: CaseStatuses; + type: CaseStatusWithAllStatus; withArrow?: boolean; onClick?: () => void; } @@ -22,7 +22,7 @@ interface Props { const StatusComponent: React.FC = ({ type, withArrow = false, onClick = noop }) => { const props = useMemo( () => ({ - color: statuses[type].color, + color: type === StatusAll ? allCaseStatus[StatusAll].color : statuses[type].color, ...(withArrow ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), }), [withArrow, type] @@ -35,7 +35,7 @@ const StatusComponent: React.FC = ({ type, withArrow = false, onClick = n iconOnClickAriaLabel={i18n.STATUS_ICON_ARIA} data-test-subj={`status-badge-${type}`} > - {statuses[type].label} + {type === StatusAll ? allCaseStatus[StatusAll].label : statuses[type].label} ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/status/translations.ts b/x-pack/plugins/security_solution/public/cases/components/status/translations.ts index 1220b6beaeb654..00dc5d3333f152 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/status/translations.ts @@ -8,6 +8,10 @@ import { i18n } from '@kbn/i18n'; export * from '../../translations'; +export const ALL = i18n.translate('xpack.securitySolution.case.status.all', { + defaultMessage: 'All', +}); + export const OPEN = i18n.translate('xpack.securitySolution.case.status.open', { defaultMessage: 'Open', }); diff --git a/x-pack/plugins/security_solution/public/cases/components/status/types.ts b/x-pack/plugins/security_solution/public/cases/components/status/types.ts new file mode 100644 index 00000000000000..6f642b281419b8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/status/types.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { CaseStatuses } from '../../../../../case/common/api'; + +export const StatusAll = 'all' as const; +type StatusAllType = typeof StatusAll; + +export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType; + +export type AllCaseStatus = Record; + +export type Statuses = Record< + CaseStatuses, + { + color: string; + label: string; + icon: EuiIconType; + actions: { + bulk: { + title: string; + }; + single: { + title: string; + description?: string; + }; + }; + actionBar: { + title: string; + }; + button: { + label: string; + }; + stats: { + title: string; + }; + } +>; diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index ee63749b494354..01f1ba173d5be2 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -137,7 +137,6 @@ describe('Case Configuration API', () => { ...DEFAULT_QUERY_PARAMS, reporters: [], tags: [], - status: CaseStatuses.open, }, signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index 01ef040aa19cda..a064189854879a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { assign } from 'lodash'; +import { assign, omit } from 'lodash'; import { CasePatchRequest, @@ -14,7 +14,6 @@ import { CasesFindResponse, CasesResponse, CasesStatusResponse, - CaseStatuses, CaseType, CaseUserActionsResponse, CommentRequest, @@ -45,6 +44,7 @@ import { } from '../../../../case/common/api/helpers'; import { KibanaServices } from '../../common/lib/kibana'; +import { StatusAll } from '../components/status'; import { ActionLicense, @@ -169,7 +169,7 @@ export const getCases = async ({ onlyCollectionType: false, search: '', reporters: [], - status: CaseStatuses.open, + status: StatusAll, tags: [], }, queryParams = { @@ -190,7 +190,7 @@ export const getCases = async ({ }; const response = await KibanaServices.get().http.fetch(`${CASES_URL}/_find`, { method: 'GET', - query, + query: query.status === StatusAll ? omit(query, ['status']) : query, signal, }); return convertAllCasesToCamel(decodeCasesFindResponse(response)); diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index 399d8d43ce0655..09c911d93ea474 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -17,11 +17,10 @@ import { CaseType, AssociationType, } from '../../../../case/common/api'; +import { CaseStatusWithAllStatus } from '../components/status'; export { CaseConnector, ActionConnector, CaseStatuses } from '../../../../case/common/api'; -export type AllCaseType = AssociationType & CaseType; - export type Comment = CommentRequest & { associationType: AssociationType; id: string; @@ -96,7 +95,7 @@ export interface QueryParams { export interface FilterOptions { search: string; - status: CaseStatuses; + status: CaseStatusWithAllStatus; tags: string[]; reporters: User[]; onlyCollectionType?: boolean; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx index f2e8e280bf158c..d27bb5ab1b4625 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx @@ -6,12 +6,12 @@ */ import { useCallback, useEffect, useReducer, useRef } from 'react'; -import { CaseStatuses } from '../../../../case/common/api'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case, UpdateByKey } from './types'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import * as i18n from './translations'; import { getCases, patchCase } from './api'; +import { StatusAll } from '../components/status'; export interface UseGetCasesState { data: AllCases; @@ -95,7 +95,7 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS export const DEFAULT_FILTER_OPTIONS: FilterOptions = { search: '', reporters: [], - status: CaseStatuses.open, + status: StatusAll, tags: [], onlyCollectionType: false, };