diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index d45c9fb3777e6..601599104641b 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -120,18 +120,21 @@ export const GENERAL_CASES_OWNER = APP_ID; export const OWNER_INFO = { [SECURITY_SOLUTION_OWNER]: { + id: SECURITY_SOLUTION_OWNER, appId: 'securitySolutionUI', label: 'Security', iconType: 'logoSecurity', appRoute: '/app/security', }, [OBSERVABILITY_OWNER]: { + id: OBSERVABILITY_OWNER, appId: 'observability-overview', label: 'Observability', iconType: 'logoObservability', appRoute: '/app/observability', }, [GENERAL_CASES_OWNER]: { + id: GENERAL_CASES_OWNER, appId: 'management', label: 'Stack', iconType: 'casesApp', diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 909bb1dd24ea0..e1e6e5ec42de8 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -426,6 +426,20 @@ describe.skip('AllCasesListGeneric', () => { }); }); + it('should render only Name, CreatedOn and Severity columns when isSelectorView=true', async () => { + const wrapper = mount( + + + + ); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="tableHeaderCell_title_0"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tableHeaderCell_createdAt_1"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tableHeaderCell_severity_2"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tableHeaderCell_assignees_1"]').exists()).toBe(false); + }); + }); + it('should sort by severity', async () => { const result = appMockRenderer.render(); @@ -698,12 +712,12 @@ describe.skip('AllCasesListGeneric', () => { queryParams: DEFAULT_QUERY_PARAMS, }); - userEvent.click(getByTestId('options-filter-popover-button-Solution')); + userEvent.click(getByTestId('solution-filter-popover-button')); await waitForEuiPopoverOpen(); userEvent.click( - getByTestId(`options-filter-popover-item-${SECURITY_SOLUTION_OWNER}`), + getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`), undefined, { skipPointerEventsCheck: true, @@ -725,7 +739,7 @@ describe.skip('AllCasesListGeneric', () => { }); userEvent.click( - getByTestId(`options-filter-popover-item-${SECURITY_SOLUTION_OWNER}`), + getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`), undefined, { skipPointerEventsCheck: true, @@ -754,7 +768,7 @@ describe.skip('AllCasesListGeneric', () => { ); - expect(queryByTestId('options-filter-popover-button-Solution')).toBeFalsy(); + expect(queryByTestId('solution-filter-popover-button')).toBeFalsy(); }); it('should call useGetCases with the correct owner on initial render', async () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index ab50f16083f8b..7666d518fe9b4 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -14,11 +14,13 @@ import styled, { css } from 'styled-components'; import type { Case, CaseStatusWithAllStatus, FilterOptions } from '../../../common/ui/types'; import { SortFieldCase, StatusAll } from '../../../common/ui/types'; import { CaseStatuses, caseStatuses } from '../../../common/api'; +import { OWNER_INFO } from '../../../common/constants'; +import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { useCasesColumns } from './use_cases_columns'; import { CasesTableFilters } from './table_filters'; -import type { EuiBasicTableOnChange } from './types'; +import type { EuiBasicTableOnChange, Solution } from './types'; import { CasesTable } from './table'; import { useCasesContext } from '../cases_context/use_cases_context'; @@ -48,6 +50,17 @@ const getSortField = (field: string): SortFieldCase => // @ts-ignore SortFieldCase[field] ?? SortFieldCase.title; +const isValidSolution = (solution: string): solution is CasesOwners => + Object.keys(OWNER_INFO).includes(solution); + +const mapToReadableSolutionName = (solution: string): Solution => { + if (isValidSolution(solution)) { + return OWNER_INFO[solution]; + } + + return { id: solution, label: solution, iconType: '' }; +}; + export interface AllCasesListProps { hiddenStatuses?: CaseStatusWithAllStatus[]; isSelectorView?: boolean; @@ -228,6 +241,10 @@ export const AllCasesList = React.memo( [] ); + const availableSolutionsLabels = availableSolutions.map((solution) => + mapToReadableSolutionName(solution) + ); + return ( <> ( countOpenCases={data.countOpenCases} countInProgressCases={data.countInProgressCases} onFilterChanged={onFilterChangedCallback} - availableSolutions={hasOwner ? [] : availableSolutions} + availableSolutions={hasOwner ? [] : availableSolutionsLabels} initial={{ search: filterOptions.search, searchFields: filterOptions.searchFields, @@ -254,8 +271,8 @@ export const AllCasesList = React.memo( severity: filterOptions.severity, }} hiddenStatuses={hiddenStatuses} - displayCreateCaseButton={isSelectorView} onCreateCasePressed={onRowClick} + isSelectorView={isSelectorView} isLoading={isLoadingCurrentUserProfile} currentUserProfile={currentUserProfile} /> diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx index 0ba5a65bf3207..c04ea59dfebc5 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx @@ -7,7 +7,7 @@ import React, { useState, useCallback } from 'react'; import { - EuiButton, + EuiButtonEmpty, EuiModal, EuiModalBody, EuiModalFooter, @@ -29,7 +29,7 @@ export interface AllCasesSelectorModalProps { const Modal = styled(EuiModal)` ${({ theme }) => ` - min-width: ${theme.eui.euiBreakpoints.l}; + min-width: ${theme.eui.euiBreakpoints.m}; max-width: ${theme.eui.euiBreakpoints.xl}; `} `; @@ -68,13 +68,13 @@ export const AllCasesSelectorModal = React.memo( /> - {i18n.CANCEL} - + diff --git a/x-pack/plugins/cases/public/components/all_cases/solution_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/solution_filter.test.tsx new file mode 100644 index 0000000000000..dcb469448b03f --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/solution_filter.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 React from 'react'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import type { Solution } from './types'; +import { + OWNER_INFO, + SECURITY_SOLUTION_OWNER, + OBSERVABILITY_OWNER, +} from '../../../common/constants'; + +import { SolutionFilter } from './solution_filter'; +import userEvent from '@testing-library/user-event'; + +describe('SolutionFilter ', () => { + let appMockRender: AppMockRenderer; + const onSelectedOptionsChanged = jest.fn(); + const solutions: Solution[] = [ + { + id: SECURITY_SOLUTION_OWNER, + label: OWNER_INFO[SECURITY_SOLUTION_OWNER].label, + iconType: OWNER_INFO[SECURITY_SOLUTION_OWNER].iconType, + }, + { + id: OBSERVABILITY_OWNER, + label: OWNER_INFO[OBSERVABILITY_OWNER].label, + iconType: OWNER_INFO[OBSERVABILITY_OWNER].iconType, + }, + ]; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders button correctly', () => { + const { getByTestId } = appMockRender.render( + + ); + + expect(getByTestId('solution-filter-popover-button')).toBeInTheDocument(); + }); + + it('renders empty label correctly', async () => { + const { getByTestId, getByText } = appMockRender.render( + + ); + + userEvent.click(getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); + + expect(getByText('No options available')).toBeInTheDocument(); + }); + + it('renders options correctly', async () => { + const { getByTestId } = appMockRender.render( + + ); + + expect(getByTestId('solution-filter-popover-button')).toBeInTheDocument(); + + userEvent.click(getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); + + expect(getByTestId(`solution-filter-popover-item-${solutions[0].id}`)).toBeInTheDocument(); + expect(getByTestId(`solution-filter-popover-item-${solutions[0].id}`)).toBeInTheDocument(); + }); + + it('should call onSelectionChange with selected solution id', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); + + userEvent.click(getByTestId(`solution-filter-popover-item-${solutions[0].id}`)); + + expect(onSelectedOptionsChanged).toHaveBeenCalledWith([solutions[0].id]); + }); + + it('should call onSelectionChange with empty array when solution option is deselected', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); + + userEvent.click(getByTestId(`solution-filter-popover-item-${solutions[1].id}`)); + + expect(onSelectedOptionsChanged).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/solution_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/solution_filter.tsx new file mode 100644 index 0000000000000..b776895e2fe9e --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/solution_filter.tsx @@ -0,0 +1,126 @@ +/* + * 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 React, { useCallback, useState } from 'react'; +import { + EuiFilterButton, + EuiFilterSelectItem, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiPopover, + EuiText, + EuiIcon, +} from '@elastic/eui'; +import styled from 'styled-components'; + +import * as i18n from './translations'; +import type { Solution } from './types'; + +interface FilterPopoverProps { + onSelectedOptionsChanged: (value: string[]) => void; + options: Solution[]; + optionsEmptyLabel?: string; + selectedOptions: string[]; +} + +const ScrollableDiv = styled.div` + max-height: 250px; + overflow: auto; +`; + +const toggleSelectedGroup = (group: string, selectedGroups: string[]): string[] => { + const selectedGroupIndex = selectedGroups.indexOf(group); + if (selectedGroupIndex >= 0) { + return [ + ...selectedGroups.slice(0, selectedGroupIndex), + ...selectedGroups.slice(selectedGroupIndex + 1), + ]; + } + return [...selectedGroups, group]; +}; + +/** + * 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 SolutionFilterComponent = ({ + onSelectedOptionsChanged, + options, + optionsEmptyLabel, + selectedOptions, +}: FilterPopoverProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const setIsPopoverOpenCb = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); + const toggleSelectedGroupCb = useCallback( + (option) => onSelectedOptionsChanged(toggleSelectedGroup(option, selectedOptions)), + [selectedOptions, onSelectedOptionsChanged] + ); + + return ( + 0} + numActiveFilters={selectedOptions.length} + aria-label={i18n.SOLUTION} + > + {i18n.SOLUTION} + + } + isOpen={isPopoverOpen} + closePopover={setIsPopoverOpenCb} + panelPaddingSize="none" + repositionOnScroll + > + + {options.map((option, index) => ( + + + + + + {option.label} + + + ))} + + {options.length === 0 && optionsEmptyLabel != null && ( + + + + {optionsEmptyLabel} + + + + )} + + ); +}; + +SolutionFilterComponent.displayName = 'SolutionFilterComponent'; + +export const SolutionFilter = React.memo(SolutionFilterComponent); + +SolutionFilter.displayName = 'SolutionFilter'; diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index 6c0ce7569b1c6..ccfcf715a67ba 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -6,16 +6,20 @@ */ import React from 'react'; -import { mount } from 'enzyme'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { waitForComponentToUpdate } from '../../common/test_utils'; import { CaseStatuses } from '../../../common/api'; -import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; +import { + OWNER_INFO, + SECURITY_SOLUTION_OWNER, + OBSERVABILITY_OWNER, +} from '../../../common/constants'; import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer, TestProviders } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases'; import { CasesTableFilters } from './table_filters'; import { useGetTags } from '../../containers/use_get_tags'; @@ -52,37 +56,31 @@ describe('CasesTableFilters ', () => { }); it('should render the case status filter dropdown', () => { - const wrapper = mount( - - - - ); + appMockRender.render(); - expect(wrapper.find(`[data-test-subj="case-status-filter"]`).first().exists()).toBeTruthy(); + expect(screen.getByTestId('case-status-filter')).toBeInTheDocument(); }); it('should render the case severity filter dropdown', () => { - const result = appMockRender.render(); - expect(result.getByTestId('case-severity-filter')).toBeTruthy(); + appMockRender.render(); + expect(screen.getByTestId('case-severity-filter')).toBeTruthy(); }); it('should call onFilterChange when the severity filter changes', async () => { - const result = appMockRender.render(); - userEvent.click(result.getByTestId('case-severity-filter')); + appMockRender.render(); + userEvent.click(screen.getByTestId('case-severity-filter')); await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('case-severity-filter-high')); + userEvent.click(screen.getByTestId('case-severity-filter-high')); expect(onFilterChanged).toBeCalledWith({ severity: 'high' }); }); - it('should call onFilterChange when selected tags change', () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="options-filter-popover-button-Tags"]`).last().simulate('click'); - wrapper.find(`[data-test-subj="options-filter-popover-item-coke"]`).last().simulate('click'); + it('should call onFilterChange when selected tags change', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('options-filter-popover-button-Tags')); + await waitForEuiPopoverOpen(); + userEvent.click(screen.getByTestId('options-filter-popover-item-coke')); expect(onFilterChanged).toBeCalledWith({ tags: ['coke'] }); }); @@ -109,29 +107,21 @@ describe('CasesTableFilters ', () => { `); }); - it('should call onFilterChange when search changes', () => { - const wrapper = mount( - - - - ); - - wrapper - .find(`[data-test-subj="search-cases"]`) - .last() - .simulate('keyup', { key: 'Enter', target: { value: 'My search' } }); + it('should call onFilterChange when search changes', async () => { + appMockRender.render(); + + await userEvent.type(screen.getByTestId('search-cases'), 'My search{enter}'); + expect(onFilterChanged).toBeCalledWith({ search: 'My search' }); }); - it('should call onFilterChange when changing status', () => { - const wrapper = mount( - - - - ); + it('should call onFilterChange when changing status', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('case-status-filter')); + await waitForEuiPopoverOpen(); + userEvent.click(screen.getByTestId('case-status-filter-closed')); - wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); - wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click'); expect(onFilterChanged).toBeCalledWith({ status: CaseStatuses.closed }); }); @@ -143,11 +133,8 @@ describe('CasesTableFilters ', () => { tags: ['pepsi', 'rc'], }, }; - mount( - - - - ); + + appMockRender.render(); expect(onFilterChanged).toHaveBeenCalledWith({ tags: ['pepsi'] }); }); @@ -186,165 +173,104 @@ describe('CasesTableFilters ', () => { }); it('StatusFilterWrapper should have a fixed width of 180px', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="status-filter-wrapper"]').first()).toHaveStyleRule( - 'flex-basis', - '180px', - { - modifier: '&&', - } - ); + appMockRender.render(); + + expect(screen.getByTestId('status-filter-wrapper')).toHaveStyleRule('flex-basis', '180px', { + modifier: '&&', + }); }); describe('Solution filter', () => { + const securitySolution = { + id: SECURITY_SOLUTION_OWNER, + label: OWNER_INFO[SECURITY_SOLUTION_OWNER].label, + iconType: OWNER_INFO[SECURITY_SOLUTION_OWNER].iconType, + }; + const observabilitySolution = { + id: OBSERVABILITY_OWNER, + label: OWNER_INFO[OBSERVABILITY_OWNER].label, + iconType: OWNER_INFO[OBSERVABILITY_OWNER].iconType, + }; + it('shows Solution filter when provided more than 1 availableSolutions', () => { - const wrapper = mount( - - - + appMockRender.render( + ); - expect( - wrapper.find(`[data-test-subj="options-filter-popover-button-Solution"]`).exists() - ).toBeTruthy(); + expect(screen.getByTestId('solution-filter-popover-button')).toBeInTheDocument(); }); it('does not show Solution filter when provided less than 1 availableSolutions', () => { - const wrapper = mount( - - - + appMockRender.render( + ); - expect( - wrapper.find(`[data-test-subj="options-filter-popover-button-Solution"]`).exists() - ).toBeFalsy(); + expect(screen.queryByTestId('solution-filter-popover-button')).not.toBeInTheDocument(); }); - it('should call onFilterChange when selected solution changes', () => { - const wrapper = mount( - - - + it('should call onFilterChange when selected solution changes', async () => { + appMockRender.render( + ); - wrapper - .find(`[data-test-subj="options-filter-popover-button-Solution"]`) - .last() - .simulate('click'); + userEvent.click(screen.getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); - wrapper - .find(`[data-test-subj="options-filter-popover-item-${SECURITY_SOLUTION_OWNER}"]`) - .last() - .simulate('click'); + userEvent.click( + screen.getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`) + ); expect(onFilterChanged).toBeCalledWith({ owner: [SECURITY_SOLUTION_OWNER] }); }); - it('should deselect all solutions', () => { - const wrapper = mount( - - - + it('should deselect all solutions', async () => { + appMockRender.render( + ); - wrapper - .find(`[data-test-subj="options-filter-popover-button-Solution"]`) - .last() - .simulate('click'); + userEvent.click(screen.getByTestId('solution-filter-popover-button')); - wrapper - .find(`[data-test-subj="options-filter-popover-item-${SECURITY_SOLUTION_OWNER}"]`) - .last() - .simulate('click'); + await waitForEuiPopoverOpen(); + + userEvent.click( + screen.getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`) + ); expect(onFilterChanged).toBeCalledWith({ owner: [SECURITY_SOLUTION_OWNER] }); - wrapper - .find(`[data-test-subj="options-filter-popover-item-${SECURITY_SOLUTION_OWNER}"]`) - .last() - .simulate('click'); + userEvent.click( + screen.getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`) + ); expect(onFilterChanged).toBeCalledWith({ owner: [] }); }); it('does not select a solution on initial render', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find(`[data-test-subj="options-filter-popover-button-Solution"]`).first().props() - ).toEqual(expect.objectContaining({ hasActiveFilters: false })); - }); - }); - - describe('create case button', () => { - it('should not render the create case button when displayCreateCaseButton and onCreateCasePressed are not passed', () => { - const wrapper = mount( - - - + appMockRender.render( + ); - expect(wrapper.find(`[data-test-subj="cases-table-add-case-filter-bar"]`).length).toBe(0); - }); - - it('should render the create case button when displayCreateCaseButton and onCreateCasePressed are passed', () => { - const onCreateCasePressed = jest.fn(); - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="cases-table-add-case-filter-bar"]`)).toBeTruthy(); - }); - it('should call the onCreateCasePressed when create case is clicked', () => { - const onCreateCasePressed = jest.fn(); - const wrapper = mount( - - - + expect(screen.getByTestId('solution-filter-popover-button')).not.toHaveAttribute( + 'hasActiveFilters' ); - wrapper - .find(`button[data-test-subj="cases-table-add-case-filter-bar"]`) - .first() - .simulate('click'); - wrapper.update(); - // NOTE: intentionally checking no arguments are passed - expect(onCreateCasePressed).toHaveBeenCalledWith(); }); }); describe('assignees filter', () => { it('should hide the assignees filters on basic license', async () => { - const result = appMockRender.render(); + appMockRender.render(); - expect(result.queryByTestId('options-filter-popover-button-assignees')).toBeNull(); + expect(screen.queryByTestId('options-filter-popover-button-assignees')).toBeNull(); }); it('should show the assignees filters on platinum license', async () => { @@ -353,9 +279,45 @@ describe('CasesTableFilters ', () => { }); appMockRender = createAppMockRenderer({ license }); - const result = appMockRender.render(); + appMockRender.render(); + + expect(screen.getByTestId('options-filter-popover-button-assignees')).toBeInTheDocument(); + }); + }); + + describe('create case button', () => { + it('should not render the create case button when isSelectorView is false and onCreateCasePressed are not passed', () => { + appMockRender.render(); + expect(screen.queryByTestId('cases-table-add-case-filter-bar')).not.toBeInTheDocument(); + }); + + it('should render the create case button when isSelectorView is true and onCreateCasePressed are passed', () => { + const onCreateCasePressed = jest.fn(); + appMockRender.render( + + ); + expect(screen.getByTestId('cases-table-add-case-filter-bar')).toBeInTheDocument(); + }); - expect(result.getByTestId('options-filter-popover-button-assignees')).toBeInTheDocument(); + it('should call the onCreateCasePressed when create case is clicked', async () => { + const onCreateCasePressed = jest.fn(); + appMockRender.render( + + ); + + userEvent.click(screen.getByTestId('cases-table-add-case-filter-bar')); + + await waitForComponentToUpdate(); + // NOTE: intentionally checking no arguments are passed + expect(onCreateCasePressed).toHaveBeenCalledWith(); }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx index 5032922a06d12..41c46d5137e98 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -15,6 +15,7 @@ import { StatusAll } from '../../../common/ui/types'; import { CaseStatuses } from '../../../common/api'; import type { FilterOptions } from '../../containers/types'; import { FilterPopover } from '../filter_popover'; +import { SolutionFilter } from './solution_filter'; import { StatusFilter } from './status_filter'; import * as i18n from './translations'; import { SeverityFilter } from './severity_filter'; @@ -24,6 +25,7 @@ import { AssigneesFilterPopover } from './assignees_filter'; import type { CurrentUserProfile } from '../types'; import { useCasesFeatures } from '../../common/use_cases_features'; import type { AssigneesFilteringSelection } from '../user_profiles/types'; +import type { Solution } from './types'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -32,8 +34,8 @@ interface CasesTableFiltersProps { onFilterChanged: (filterOptions: Partial) => void; initial: FilterOptions; hiddenStatuses?: CaseStatusWithAllStatus[]; - availableSolutions: string[]; - displayCreateCaseButton?: boolean; + availableSolutions: Solution[]; + isSelectorView?: boolean; onCreateCasePressed?: () => void; isLoading: boolean; currentUserProfile: CurrentUserProfile; @@ -60,7 +62,7 @@ const CasesTableFiltersComponent = ({ initial = DEFAULT_FILTER_OPTIONS, hiddenStatuses, availableSolutions, - displayCreateCaseButton, + isSelectorView = false, onCreateCasePressed, isLoading, currentUserProfile, @@ -156,6 +158,18 @@ const CasesTableFiltersComponent = ({ + {isSelectorView && onCreateCasePressed ? ( + + + {i18n.CREATE_CASE_TITLE} + + + ) : null} - {caseAssignmentAuthorized ? ( + {caseAssignmentAuthorized && !isSelectorView ? ( {availableSolutions.length > 1 && ( - - {displayCreateCaseButton && onCreateCasePressed ? ( - - - {i18n.CREATE_CASE_TITLE} - - - ) : null} ); }; diff --git a/x-pack/plugins/cases/public/components/all_cases/types.ts b/x-pack/plugins/cases/public/components/all_cases/types.ts index 5014522177570..7cf9c410ec073 100644 --- a/x-pack/plugins/cases/public/components/all_cases/types.ts +++ b/x-pack/plugins/cases/public/components/all_cases/types.ts @@ -24,3 +24,8 @@ export interface EuiBasicTableOnChange { }; sort?: EuiBasicTableSortTypes; } +export interface Solution { + id: string; + label: string; + iconType: string; +} diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx index f11794bcf13e2..060ac64cafb08 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx @@ -97,7 +97,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -148,38 +147,7 @@ describe('useCasesColumns ', () => { "name": "Name", "render": [Function], "sortable": true, - "width": undefined, - }, - Object { - "field": "assignees", - "name": "Assignees", - "render": [Function], - "width": undefined, - }, - Object { - "field": "tags", - "name": "Tags", - "render": [Function], - "width": undefined, - }, - Object { - "align": "right", - "field": "totalAlerts", - "name": "Alerts", - "render": [Function], - "width": "55px", - }, - Object { - "align": "right", - "field": "owner", - "name": "Solution", - "render": [Function], - }, - Object { - "align": "right", - "field": "totalComment", - "name": "Comments", - "render": [Function], + "width": "55%", }, Object { "field": "createdAt", @@ -187,24 +155,6 @@ describe('useCasesColumns ', () => { "render": [Function], "sortable": true, }, - Object { - "field": "updatedAt", - "name": "Updated on", - "render": [Function], - "sortable": true, - "width": "80px", - }, - Object { - "name": "External Incident", - "render": [Function], - "width": "80px", - }, - Object { - "field": "status", - "name": "Status", - "render": [Function], - "sortable": true, - }, Object { "field": "severity", "name": "Severity", @@ -280,7 +230,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -365,7 +314,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -445,7 +393,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -530,7 +477,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -575,32 +521,7 @@ describe('useCasesColumns ', () => { "name": "Name", "render": [Function], "sortable": true, - "width": undefined, - }, - Object { - "field": "tags", - "name": "Tags", - "render": [Function], - "width": undefined, - }, - Object { - "align": "right", - "field": "totalAlerts", - "name": "Alerts", - "render": [Function], - "width": "55px", - }, - Object { - "align": "right", - "field": "owner", - "name": "Solution", - "render": [Function], - }, - Object { - "align": "right", - "field": "totalComment", - "name": "Comments", - "render": [Function], + "width": "55%", }, Object { "field": "createdAt", @@ -608,24 +529,6 @@ describe('useCasesColumns ', () => { "render": [Function], "sortable": true, }, - Object { - "field": "updatedAt", - "name": "Updated on", - "render": [Function], - "sortable": true, - "width": "80px", - }, - Object { - "name": "External Incident", - "render": [Function], - "width": "80px", - }, - Object { - "field": "status", - "name": "Status", - "render": [Function], - "sortable": true, - }, Object { "field": "severity", "name": "Severity", @@ -657,32 +560,7 @@ describe('useCasesColumns ', () => { "name": "Name", "render": [Function], "sortable": true, - "width": undefined, - }, - Object { - "field": "tags", - "name": "Tags", - "render": [Function], - "width": undefined, - }, - Object { - "align": "right", - "field": "totalAlerts", - "name": "Alerts", - "render": [Function], - "width": "55px", - }, - Object { - "align": "right", - "field": "owner", - "name": "Solution", - "render": [Function], - }, - Object { - "align": "right", - "field": "totalComment", - "name": "Comments", - "render": [Function], + "width": "55%", }, Object { "field": "createdAt", @@ -690,24 +568,6 @@ describe('useCasesColumns ', () => { "render": [Function], "sortable": true, }, - Object { - "field": "updatedAt", - "name": "Updated on", - "render": [Function], - "sortable": true, - "width": "80px", - }, - Object { - "name": "External Incident", - "render": [Function], - "width": "80px", - }, - Object { - "field": "status", - "name": "Status", - "render": [Function], - "sortable": true, - }, Object { "field": "severity", "name": "Severity", @@ -776,7 +636,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx index a2760c71a8ef5..1f267eca40a84 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx @@ -118,7 +118,7 @@ export const useCasesColumns = ({ render: (title: string, theCase: Case) => { if (theCase.id != null && theCase.title != null) { const caseDetailsLinkComponent = isSelectorView ? ( - + theCase.title ) : ( @@ -137,70 +137,72 @@ export const useCasesColumns = ({ } return getEmptyTagValue(); }, - width: !isSelectorView ? '20%' : undefined, + width: !isSelectorView ? '20%' : '55%', }, ]; - if (caseAssignmentAuthorized) { + if (caseAssignmentAuthorized && !isSelectorView) { columns.push({ field: 'assignees', name: i18n.ASSIGNEES, render: (assignees: Case['assignees']) => ( ), - width: !isSelectorView ? '180px' : undefined, + width: '180px', }); } - columns.push({ - field: 'tags', - name: i18n.TAGS, - render: (tags: Case['tags']) => { - if (tags != null && tags.length > 0) { - const clampedBadges = ( - - {tags.map((tag: string, i: number) => ( - - {tag} - - ))} - - ); + if (!isSelectorView) { + columns.push({ + field: 'tags', + name: i18n.TAGS, + render: (tags: Case['tags']) => { + if (tags != null && tags.length > 0) { + const clampedBadges = ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); - const unclampedBadges = ( - - {tags.map((tag: string, i: number) => ( - - {tag} - - ))} - - ); + const unclampedBadges = ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); - return ( - - {clampedBadges} - - ); - } - return getEmptyTagValue(); - }, - width: !isSelectorView ? '15%' : undefined, - }); + return ( + + {clampedBadges} + + ); + } + return getEmptyTagValue(); + }, + width: '15%', + }); + } - if (isAlertsEnabled) { + if (isAlertsEnabled && !isSelectorView) { columns.push({ align: RIGHT_ALIGNMENT, field: 'totalAlerts', @@ -213,7 +215,7 @@ export const useCasesColumns = ({ }); } - if (showSolutionColumn) { + if (showSolutionColumn && !isSelectorView) { columns.push({ align: RIGHT_ALIGNMENT, field: 'owner', @@ -234,15 +236,17 @@ export const useCasesColumns = ({ }); } - columns.push({ - align: RIGHT_ALIGNMENT, - field: 'totalComment', - name: i18n.COMMENTS, - render: (totalComment: Case['totalComment']) => - totalComment != null - ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) - : getEmptyTagValue(), - }); + if (!isSelectorView) { + columns.push({ + align: RIGHT_ALIGNMENT, + field: 'totalComment', + name: i18n.COMMENTS, + render: (totalComment: Case['totalComment']) => + totalComment != null + ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) + : getEmptyTagValue(), + }); + } if (filterStatus === CaseStatuses.closed) { columns.push({ @@ -278,67 +282,70 @@ export const useCasesColumns = ({ }); } + if (!isSelectorView) { + columns.push({ + field: 'updatedAt', + name: i18n.UPDATED_ON, + sortable: true, + render: (updatedAt: Case['updatedAt']) => { + if (updatedAt != null) { + return ( + + + + ); + } + return getEmptyTagValue(); + }, + }); + } + + if (!isSelectorView) { + columns.push( + { + name: i18n.EXTERNAL_INCIDENT, + render: (theCase: Case) => { + if (theCase.id != null) { + return ; + } + return getEmptyTagValue(); + }, + width: isSelectorView ? '80px' : undefined, + }, + { + field: 'status', + name: i18n.STATUS, + sortable: true, + render: (status: Case['status']) => { + if (status != null) { + return ; + } + + return getEmptyTagValue(); + }, + } + ); + } columns.push({ - field: 'updatedAt', - name: i18n.UPDATED_ON, + field: 'severity', + name: i18n.SEVERITY, sortable: true, - render: (updatedAt: Case['updatedAt']) => { - if (updatedAt != null) { + render: (severity: Case['severity']) => { + if (severity != null) { + const severityData = severities[severity ?? CaseSeverity.LOW]; return ( - - - + + {severityData.label} + ); } return getEmptyTagValue(); }, - width: isSelectorView ? '80px' : undefined, }); - columns.push( - { - name: i18n.EXTERNAL_INCIDENT, - render: (theCase: Case) => { - if (theCase.id != null) { - return ; - } - return getEmptyTagValue(); - }, - width: isSelectorView ? '80px' : undefined, - }, - { - field: 'status', - name: i18n.STATUS, - sortable: true, - render: (status: Case['status']) => { - if (status != null) { - return ; - } - - return getEmptyTagValue(); - }, - }, - { - field: 'severity', - name: i18n.SEVERITY, - sortable: true, - render: (severity: Case['severity']) => { - if (severity != null) { - const severityData = severities[severity ?? CaseSeverity.LOW]; - return ( - - {severityData.label} - - ); - } - return getEmptyTagValue(); - }, - } - ); - if (isSelectorView) { columns.push({ align: RIGHT_ALIGNMENT, @@ -351,7 +358,6 @@ export const useCasesColumns = ({ assignCaseAction(theCase); }} size="s" - fill={true} > {i18n.SELECT} diff --git a/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx b/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx new file mode 100644 index 0000000000000..a6c6de8f19770 --- /dev/null +++ b/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 React from 'react'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +import { FilterPopover } from '.'; +import userEvent from '@testing-library/user-event'; + +describe('FilterPopover ', () => { + let appMockRender: AppMockRenderer; + const onSelectedOptionsChanged = jest.fn(); + const tags: string[] = ['coke', 'pepsi']; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders button label correctly', () => { + const { getByTestId } = appMockRender.render( + + ); + + expect(getByTestId('options-filter-popover-button-Tags')).toBeInTheDocument(); + }); + + it('renders empty label correctly', async () => { + const { getByTestId, getByText } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + expect(getByText('No options available')).toBeInTheDocument(); + }); + + it('renders string type options correctly', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + expect(getByTestId(`options-filter-popover-item-${tags[0]}`)).toBeInTheDocument(); + expect(getByTestId(`options-filter-popover-item-${tags[1]}`)).toBeInTheDocument(); + }); + + it('should call onSelectionChange with selected option', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + userEvent.click(getByTestId(`options-filter-popover-item-${tags[0]}`)); + + expect(onSelectedOptionsChanged).toHaveBeenCalledWith([tags[0]]); + }); + + it('should call onSelectionChange with empty array when option is deselected', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + userEvent.click(getByTestId(`options-filter-popover-item-${tags[0]}`)); + + expect(onSelectedOptionsChanged).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/cases/attach_timeline.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/cases/attach_timeline.cy.ts index f74be26963d45..2012d756d6354 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/cases/attach_timeline.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/cases/attach_timeline.cy.ts @@ -85,9 +85,7 @@ describe('attach timeline to case', () => { it('modal can be re-opened once closed', function () { visitTimeline(this.timelineId); attachTimelineToExistingCase(); - cy.get('[data-test-subj="all-cases-modal"] .euiButton') - .contains('Cancel') - .click({ force: true }); + cy.get('[data-test-subj="all-cases-modal-cancel-button"]').click({ force: true }); cy.get('[data-test-subj="all-cases-modal"]').should('not.exist'); attachTimelineToExistingCase(); diff --git a/x-pack/test/functional/services/cases/create.ts b/x-pack/test/functional/services/cases/create.ts index 71aec28374f7a..a1fd58d817e66 100644 --- a/x-pack/test/functional/services/cases/create.ts +++ b/x-pack/test/functional/services/cases/create.ts @@ -119,6 +119,7 @@ export function CasesCreateViewServiceProvider( async createCaseFromModal(params: CreateCaseParams) { await casesCommon.assertCaseModalVisible(true); await testSubjects.click('cases-table-add-case-filter-bar'); + await casesCommon.assertCaseModalVisible(false); await this.creteCaseFromFlyout(params); }, diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts index 39713897afaba..1e420cd37368c 100644 --- a/x-pack/test/functional/services/cases/list.ts +++ b/x-pack/test/functional/services/cases/list.ts @@ -165,11 +165,11 @@ export function CasesTableServiceProvider( async filterByOwner(owner: string) { await common.clickAndValidate( - 'options-filter-popover-button-Solution', - `options-filter-popover-item-${owner}` + 'solution-filter-popover-button', + `solution-filter-popover-item-${owner}` ); - await testSubjects.click(`options-filter-popover-item-${owner}`); + await testSubjects.click(`solution-filter-popover-item-${owner}`); }, async refreshTable() { diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/attachment_framework.ts b/x-pack/test/functional_with_es_ssl/apps/cases/attachment_framework.ts index 193b305d707ad..207434635829f 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/attachment_framework.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/attachment_framework.ts @@ -302,11 +302,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('renders different solutions', async () => { await openModal(); - await testSubjects.existOrFail('options-filter-popover-button-Solution'); + await testSubjects.existOrFail('solution-filter-popover-button'); - for (const [owner, caseId] of createdCases.entries()) { - await testSubjects.existOrFail(`cases-table-row-${caseId}`); - await testSubjects.existOrFail(`case-table-column-owner-icon-${owner}`); + for (const [, currentCaseId] of createdCases.entries()) { + await testSubjects.existOrFail(`cases-table-row-${currentCaseId}`); } await closeModal();