diff --git a/x-pack/plugins/cases/common/index.ts b/x-pack/plugins/cases/common/index.ts index 4283adf4c081a..94485585c5876 100644 --- a/x-pack/plugins/cases/common/index.ts +++ b/x-pack/plugins/cases/common/index.ts @@ -57,7 +57,6 @@ export { export type { AttachmentAttributes } from './types/domain'; export { ConnectorTypes, AttachmentType, ExternalReferenceStorageType } from './types/domain'; export { getCasesFromAlertsUrl, getCaseFindUserActionsUrl, throwErrors } from './api'; -export { StatusAll } from './ui/types'; export { createUICapabilities, type CasesUiCapabilities } from './utils/capabilities'; export { getApiTags, type CasesApiTags } from './utils/api_tags'; export { CaseMetricsFeature } from './types/api'; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index fcfe01d77fe78..dca2b6c6549d1 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -65,14 +65,6 @@ export interface CasesUiConfigType { }; } -export const StatusAll = 'all' as const; -export type StatusAllType = typeof StatusAll; - -export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType; - -export const SeverityAll = 'all' as const; -export type CaseSeverityWithAll = CaseSeverity | typeof SeverityAll; - export const UserActionTypeAll = 'all' as const; export type CaseUserActionTypeWithAll = UserActionFindRequestTypes | typeof UserActionTypeAll; @@ -156,8 +148,8 @@ export type LocalStorageQueryParams = Partial>; export interface FilterOptions { search: string; searchFields: string[]; - severity: CaseSeverityWithAll[]; - status: CaseStatusWithAllStatus[]; + severity: CaseSeverity[]; + status: CaseStatuses[]; tags: string[]; assignees: Array | null; reporters: User[]; 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 54066d5363275..416e9d3b238be 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 @@ -22,7 +22,7 @@ import { } from '../../common/mock'; import { useGetCasesMockState, connectorsMock } from '../../containers/mock'; -import { SortFieldCase, StatusAll } from '../../../common/ui/types'; +import { SortFieldCase } from '../../../common/ui/types'; import { CaseSeverity, CaseStatuses } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; @@ -393,7 +393,8 @@ describe('AllCasesListGeneric', () => { it('should sort by status', async () => { appMockRenderer.render(); - userEvent.click(screen.getByTitle('Status')); + // 0 is the status filter button label + userEvent.click(screen.getAllByTitle('Status')[1]); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -414,14 +415,16 @@ describe('AllCasesListGeneric', () => { expect(screen.getByTitle('Name')).toBeInTheDocument(); expect(screen.getByTitle('Category')).toBeInTheDocument(); expect(screen.getByTitle('Created on')).toBeInTheDocument(); - expect(screen.getByTitle('Severity')).toBeInTheDocument(); + // 0 is the severity filter button label + expect(screen.getAllByTitle('Severity')[1]).toBeInTheDocument(); }); }); it('should sort by severity', async () => { appMockRenderer.render(); - userEvent.click(screen.getByTitle('Severity')); + // 0 is the severity filter button label + userEvent.click(screen.getAllByTitle('Severity')[1]); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -493,7 +496,7 @@ describe('AllCasesListGeneric', () => { it('should filter by category', async () => { appMockRenderer.render(); - userEvent.click(screen.getByTestId('options-filter-popover-button-Categories')); + userEvent.click(screen.getByTestId('options-filter-popover-button-category')); await waitForEuiPopoverOpen(); userEvent.click(screen.getByTestId('options-filter-popover-item-twix')); @@ -510,72 +513,22 @@ describe('AllCasesListGeneric', () => { }); }); - it('should filter by status: closed', async () => { - appMockRenderer.render(); - userEvent.click(screen.getByTestId('case-status-filter')); - await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('case-status-filter-closed')); - await waitFor(() => { - expect(useGetCasesMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - queryParams: { ...DEFAULT_QUERY_PARAMS, sortField: SortFieldCase.closedAt }, - }) - ); - }); - }); - - it('should filter by status: in-progress', async () => { - appMockRenderer.render(); - userEvent.click(screen.getByTestId('case-status-filter')); - await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('case-status-filter-in-progress')); - await waitFor(() => { - expect(useGetCasesMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - queryParams: DEFAULT_QUERY_PARAMS, - }) - ); - }); - }); - - it('should filter by status: open', async () => { - appMockRenderer.render(); - userEvent.click(screen.getByTestId('case-status-filter')); - await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('case-status-filter-in-progress')); - await waitFor(() => { - expect(useGetCasesMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - queryParams: DEFAULT_QUERY_PARAMS, - }) - ); - }); - }); - it('should show the correct count on stats', async () => { appMockRenderer.render(); - userEvent.click(screen.getByTestId('case-status-filter')); + userEvent.click(screen.getByTestId('options-filter-popover-button-status')); await waitFor(() => { - expect(screen.getByTestId('case-status-filter-open')).toHaveTextContent('Open (20)'); - expect(screen.getByTestId('case-status-filter-in-progress')).toHaveTextContent( + expect(screen.getByTestId('options-filter-popover-item-open')).toHaveTextContent('Open (20)'); + expect(screen.getByTestId('options-filter-popover-item-in-progress')).toHaveTextContent( 'In progress (40)' ); - expect(screen.getByTestId('case-status-filter-closed')).toHaveTextContent('Closed (130)'); + expect(screen.getByTestId('options-filter-popover-item-closed')).toHaveTextContent( + 'Closed (130)' + ); }); }); - it('renders the first available status when hiddenStatus is given', async () => { - appMockRenderer.render( - - ); - - await waitFor(() => - expect(screen.getAllByTestId('case-status-badge-in-progress')[0]).toBeInTheDocument() - ); - }); - it('shows Solution column if there are no set owners', async () => { render( @@ -632,9 +585,9 @@ describe('AllCasesListGeneric', () => { expect(checkbox).toBeChecked(); } - userEvent.click(screen.getByTestId('case-status-filter')); + userEvent.click(screen.getByTestId('options-filter-popover-button-status')); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('case-status-filter-closed')); + userEvent.click(screen.getByTestId('options-filter-popover-item-open')); for (const checkbox of checkboxes) { expect(checkbox).not.toBeChecked(); @@ -692,9 +645,9 @@ describe('AllCasesListGeneric', () => { filterOptions: { search: '', searchFields: ['title', 'description'], - severity: ['all'], + severity: [], reporters: [], - status: ['all'], + status: [], tags: [], assignees: [], owner: ['securitySolution', 'observability'], @@ -719,9 +672,9 @@ describe('AllCasesListGeneric', () => { filterOptions: { search: '', searchFields: ['title', 'description'], - severity: ['all'], + severity: [], reporters: [], - status: ['all'], + status: [], tags: [], assignees: [], owner: ['securitySolution'], @@ -742,9 +695,9 @@ describe('AllCasesListGeneric', () => { filterOptions: { search: '', searchFields: ['title', 'description'], - severity: ['all'], + severity: [], reporters: [], - status: ['all'], + status: [], tags: [], assignees: [], owner: ['securitySolution', 'observability'], @@ -775,9 +728,9 @@ describe('AllCasesListGeneric', () => { filterOptions: { search: '', searchFields: ['title', 'description'], - severity: ['all'], + severity: [], reporters: [], - status: ['all'], + status: [], tags: [], assignees: [], owner: ['securitySolution'], 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 62f6b64644206..485b5ebb5134f 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 @@ -11,17 +11,13 @@ import { EuiProgress } from '@elastic/eui'; import { difference, head, isEmpty } from 'lodash/fp'; import styled, { css } from 'styled-components'; -import type { - CaseUI, - CaseStatusWithAllStatus, - FilterOptions, - CasesUI, -} from '../../../common/ui/types'; +import type { CaseUI, FilterOptions, CasesUI } from '../../../common/ui/types'; import type { CasesOwners } from '../../client/helpers/can_use_cases'; import type { EuiBasicTableOnChange, Solution } from './types'; -import { SortFieldCase, StatusAll } from '../../../common/ui/types'; -import { CaseStatuses, caseStatuses } from '../../../common/types/domain'; +import { SortFieldCase } from '../../../common/ui/types'; +import type { CaseStatuses } from '../../../common/types/domain'; +import { caseStatuses } from '../../../common/types/domain'; import { OWNER_INFO } from '../../../common/constants'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { useCasesColumns } from './use_cases_columns'; @@ -67,7 +63,7 @@ const mapToReadableSolutionName = (solution: string): Solution => { }; export interface AllCasesListProps { - hiddenStatuses?: CaseStatusWithAllStatus[]; + hiddenStatuses?: CaseStatuses[]; isSelectorView?: boolean; onRowClick?: (theCase?: CaseUI, isCreateCase?: boolean) => void; } @@ -160,22 +156,6 @@ export const AllCasesList = React.memo( const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { - if (newFilterOptions?.status) { - if ( - newFilterOptions.status[0] === CaseStatuses.closed && - queryParams.sortField === SortFieldCase.createdAt - ) { - setQueryParams({ sortField: SortFieldCase.closedAt }); - } else if ( - [CaseStatuses.open, CaseStatuses['in-progress'], StatusAll].includes( - newFilterOptions.status[0] - ) && - queryParams.sortField === SortFieldCase.closedAt - ) { - setQueryParams({ sortField: SortFieldCase.createdAt }); - } - } - deselectCases(); setFilterOptions({ ...newFilterOptions, @@ -199,19 +179,11 @@ export const AllCasesList = React.memo( : {}), }); }, - [ - queryParams.sortField, - deselectCases, - setFilterOptions, - hasOwner, - availableSolutions, - owner, - setQueryParams, - ] + [deselectCases, setFilterOptions, hasOwner, availableSolutions, owner] ); const { columns } = useCasesColumns({ - filterStatus: filterOptions.status ?? StatusAll, + filterStatus: filterOptions.status ?? [], userProfiles: userProfiles ?? new Map(), isSelectorView, connectors, @@ -270,22 +242,12 @@ export const AllCasesList = React.memo( countInProgressCases={data.countInProgressCases} onFilterChanged={onFilterChangedCallback} availableSolutions={hasOwner ? [] : availableSolutionsLabels} - initial={{ - search: filterOptions.search, - searchFields: filterOptions.searchFields, - assignees: filterOptions.assignees, - reporters: filterOptions.reporters, - tags: filterOptions.tags, - status: filterOptions.status, - owner: filterOptions.owner, - severity: filterOptions.severity, - category: filterOptions.category, - }} hiddenStatuses={hiddenStatuses} onCreateCasePressed={onCreateCasePressed} isSelectorView={isSelectorView} isLoading={isLoadingCurrentUserProfile} currentUserProfile={currentUserProfile} + filterOptions={filterOptions} /> { + it('should render the amount of options available', async () => { + const onChange = jest.fn(); + const props = { + id: 'tags' as keyof FilterOptions, + buttonLabel: 'Tags', + options: ['tag a', 'tag b', 'tag c', 'tag d'], + onChange, + }; + + render(); + + userEvent.click(screen.getByRole('button', { name: 'Tags' })); + await waitForEuiPopoverOpen(); + + expect(screen.getByText('4 options')).toBeInTheDocument(); + }); + + it('hides the limit reached warning when a selected tag is removed', async () => { + const onChange = jest.fn(); + const props = { + id: 'tags' as keyof FilterOptions, + buttonLabel: 'Tags', + options: ['tag a', 'tag b'], + onChange, + selectedOptions: ['tag a'], + limit: 1, + limitReachedMessage: 'Limit reached', + }; + + const { rerender } = render(); + + userEvent.click(screen.getByRole('button', { name: 'Tags' })); + await waitForEuiPopoverOpen(); + + expect(screen.getByText('Limit reached')).toBeInTheDocument(); + + userEvent.click(screen.getByRole('option', { name: 'tag a' })); + + expect(onChange).toHaveBeenCalledWith({ filterId: 'tags', options: [] }); + rerender(); + + expect(screen.queryByText('Limit reached')).not.toBeInTheDocument(); + }); + + it('displays the limit reached warning when the maximum number of tags is selected', async () => { + const onChange = jest.fn(); + const props = { + id: 'tags' as keyof FilterOptions, + buttonLabel: 'Tags', + options: ['tag a', 'tag b'], + onChange, + selectedOptions: ['tag a'], + limit: 2, + limitReachedMessage: 'Limit reached', + }; + + const { rerender } = render(); + + userEvent.click(screen.getByRole('button', { name: 'Tags' })); + await waitForEuiPopoverOpen(); + + expect(screen.queryByText('Limit reached')).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('option', { name: 'tag b' })); + + expect(onChange).toHaveBeenCalledWith({ filterId: 'tags', options: ['tag a', 'tag b'] }); + rerender(); + + expect(screen.getByText('Limit reached')).toBeInTheDocument(); + }); + + it('should not call onChange when the limit has been reached', async () => { + const onChange = jest.fn(); + const props = { + id: 'tags' as keyof FilterOptions, + buttonLabel: 'Tags', + options: ['tag a', 'tag b'], + onChange, + selectedOptions: ['tag a'], + limit: 1, + limitReachedMessage: 'Limit reached', + }; + + render(); + + userEvent.click(screen.getByRole('button', { name: 'Tags' })); + await waitForEuiPopoverOpen(); + + expect(screen.getByText('Limit reached')).toBeInTheDocument(); + + userEvent.click(screen.getByRole('option', { name: 'tag b' })); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should remove selected option if it suddenly disappeared from the list', async () => { + const onChange = jest.fn(); + const props = { + id: 'tags' as keyof FilterOptions, + buttonLabel: 'Tags', + options: ['tag a', 'tag b'], + onChange, + selectedOptions: ['tag b'], + }; + + const { rerender } = render(); + rerender(); + expect(onChange).toHaveBeenCalledWith({ filterId: 'tags', options: [] }); + }); + + it('activates custom renderOption when set', async () => { + const TEST_ID = 'test-render-option-id'; + const onChange = jest.fn(); + const renderOption = () =>
; + const props = { + id: 'tags' as keyof FilterOptions, + buttonLabel: 'Tags', + options: ['tag a', 'tag b'], + onChange, + renderOption, + }; + + render(); + userEvent.click(screen.getByRole('button', { name: 'Tags' })); + await waitForEuiPopoverOpen(); + expect(screen.getAllByTestId(TEST_ID).length).toBe(2); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.tsx new file mode 100644 index 0000000000000..552753105f1ab --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.tsx @@ -0,0 +1,161 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { css } from '@emotion/react'; +import type { EuiSelectableOption } from '@elastic/eui'; +import { + EuiPopoverTitle, + EuiCallOut, + EuiHorizontalRule, + EuiPopover, + EuiSelectable, + EuiFilterButton, + EuiTextColor, + EuiSpacer, + useEuiTheme, +} from '@elastic/eui'; +import { isEqual } from 'lodash/fp'; +import type { FilterOptions } from '../../../common/ui/types'; +import * as i18n from './translations'; + +const fromRawOptionsToEuiSelectableOptions = (options: string[], selectedOptions: string[]) => { + return options.map((option) => { + const selectableOption: EuiSelectableOption = { label: option }; + if (selectedOptions.includes(option)) { + selectableOption.checked = 'on'; + } + selectableOption['data-test-subj'] = `options-filter-popover-item-${option + .split(' ') + .join('-')}`; + return selectableOption; + }); +}; + +const fromEuiSelectableOptionToRawOption = (options: EuiSelectableOption[]) => + options.map((option) => option.label); + +const getEuiSelectableCheckedOptions = (options: EuiSelectableOption[]) => + options.filter((option) => option.checked === 'on'); + +interface UseFilterParams { + buttonLabel?: string; + id: keyof FilterOptions; + limit?: number; + limitReachedMessage?: string; + onChange: ({ filterId, options }: { filterId: keyof FilterOptions; options: string[] }) => void; + options: string[]; + selectedOptions?: string[]; + renderOption?: (option: T) => React.ReactNode; +} +export const MultiSelectFilter = ({ + buttonLabel, + id, + limit, + limitReachedMessage, + onChange, + options: rawOptions, + selectedOptions = [], + renderOption, +}: UseFilterParams) => { + const { euiTheme } = useEuiTheme(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const toggleIsPopoverOpen = () => setIsPopoverOpen((prevValue) => !prevValue); + const isInvalid = Boolean(limit && limitReachedMessage && selectedOptions.length >= limit); + const options = fromRawOptionsToEuiSelectableOptions(rawOptions, selectedOptions); + + useEffect(() => { + const trimmedSelectedOptions = selectedOptions.filter((option) => rawOptions.includes(option)); + if (!isEqual(trimmedSelectedOptions, selectedOptions)) { + onChange({ + filterId: id, + options: trimmedSelectedOptions, + }); + } + }, [selectedOptions, rawOptions, id, onChange]); + + const _onChange = (newOptions: EuiSelectableOption[]) => { + const newSelectedOptions = getEuiSelectableCheckedOptions(newOptions); + if (isInvalid && limit && newSelectedOptions.length >= limit) { + return; + } + + onChange({ + filterId: id, + options: fromEuiSelectableOptionToRawOption(newSelectedOptions), + }); + }; + + return ( + 0} + numActiveFilters={selectedOptions.length} + aria-label={buttonLabel} + > + {buttonLabel} + + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + panelPaddingSize="none" + repositionOnScroll + > + {isInvalid && ( + <> + + + + + )} + + options={options} + searchable + searchProps={{ placeholder: buttonLabel, compressed: false }} + emptyMessage={i18n.EMPTY_FILTER_MESSAGE} + onChange={_onChange} + singleSelection={false} + renderOption={renderOption} + > + {(list, search) => ( +
+ {search} +
+ {i18n.OPTIONS(options.length)} +
+ + {list} +
+ )} + +
+ ); +}; + +MultiSelectFilter.displayName = 'MultiSelectFilter'; 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 c03ba9e059478..7cda95df5e75e 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 @@ -16,12 +16,13 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import type { CaseUI, CaseStatusWithAllStatus } from '../../../../common/ui/types'; +import type { CaseStatuses } from '../../../../common/types/domain'; +import type { CaseUI } from '../../../../common/ui/types'; import * as i18n from '../../../common/translations'; import { AllCasesList } from '../all_cases_list'; export interface AllCasesSelectorModalProps { - hiddenStatuses?: CaseStatusWithAllStatus[]; + hiddenStatuses?: CaseStatuses[]; onRowClick?: (theCase?: CaseUI) => void; onClose?: (theCase?: CaseUI, isCreateCase?: boolean) => void; onCreateCaseClicked?: () => void; diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index 2cfaacc8383ca..d15aa1178ce31 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -11,7 +11,6 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import AllCasesSelectorModal from '.'; import type { CaseUI } from '../../../../common'; -import { StatusAll } from '../../../../common'; import { CaseStatuses } from '../../../../common/types/domain'; import type { AppMockRenderer } from '../../../common/mock'; import { allCasesPermissions, createAppMockRenderer } from '../../../common/mock'; @@ -117,7 +116,7 @@ describe('use cases add to existing case modal hook', () => { expect.objectContaining({ type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, payload: expect.objectContaining({ - hiddenStatuses: [CaseStatuses.closed, StatusAll], + hiddenStatuses: [CaseStatuses.closed], }), }) ); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index 35d15fa7e89e8..f311fdad66a47 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -10,7 +10,6 @@ import { CaseStatuses } from '../../../../common/types/domain'; import type { AllCasesSelectorModalProps } from '.'; import { useCasesToast } from '../../../common/use_cases_toast'; import type { CaseUI } from '../../../containers/types'; -import { StatusAll } from '../../../containers/types'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesContext } from '../../cases_context/use_cases_context'; import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout'; @@ -128,7 +127,7 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingCaseModalProp type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, payload: { ...props, - hiddenStatuses: [CaseStatuses.closed, StatusAll], + hiddenStatuses: [CaseStatuses.closed], onRowClick: (theCase?: CaseUI) => { handleOnRowClick(theCase, getAttachments); }, diff --git a/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx index 30d5c75e63589..1fd8891ea8669 100644 --- a/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx @@ -10,52 +10,46 @@ import React from 'react'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import userEvent from '@testing-library/user-event'; -import { waitFor } from '@testing-library/dom'; +import { screen, waitFor } from '@testing-library/dom'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { SeverityFilter } from './severity_filter'; describe('Severity form field', () => { - const onSeverityChange = jest.fn(); + const onChange = jest.fn(); let appMockRender: AppMockRenderer; const props = { - isLoading: false, - selectedSeverity: CaseSeverity.LOW, - isDisabled: false, - onSeverityChange, + selectedOptions: [], + onChange, }; + beforeEach(() => { appMockRender = createAppMockRenderer(); }); - it('renders', () => { - const result = appMockRender.render(); - expect(result.getByTestId('case-severity-filter')).not.toHaveAttribute('disabled'); - }); - // default to LOW in this test configuration - it('defaults to the correct value', () => { - const result = appMockRender.render(); - // Popver span and ID was removed here: - // https://github.com/elastic/eui/pull/6630#discussion_r1123655995 - expect(result.getAllByTestId('case-severity-filter-low').length).toBe(1); - }); + it('renders', async () => { + appMockRender.render(); + expect(screen.getByTestId('options-filter-popover-button-severity')).toBeInTheDocument(); + expect(screen.getByTestId('options-filter-popover-button-severity')).not.toBeDisabled(); - it('selects the correct value when changed', async () => { - const result = appMockRender.render(); - userEvent.click(result.getByTestId('case-severity-filter')); + userEvent.click(screen.getByRole('button', { name: 'Severity' })); await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('case-severity-filter-high')); - await waitFor(() => { - expect(onSeverityChange).toHaveBeenCalledWith('high'); - }); + + expect(screen.getByRole('option', { name: CaseSeverity.LOW })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: CaseSeverity.MEDIUM })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: CaseSeverity.HIGH })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: CaseSeverity.CRITICAL })).toBeInTheDocument(); + expect(screen.getAllByRole('option').length).toBe(4); }); - it('selects the correct value when changed (all)', async () => { - const result = appMockRender.render(); - userEvent.click(result.getByTestId('case-severity-filter')); + it('selects the correct value when changed', async () => { + appMockRender.render(); + + userEvent.click(screen.getByRole('button', { name: 'Severity' })); await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('case-severity-filter-all')); + userEvent.click(screen.getByRole('option', { name: 'high' })); + await waitFor(() => { - expect(onSeverityChange).toHaveBeenCalledWith('all'); + expect(onChange).toHaveBeenCalledWith({ filterId: 'severity', options: ['high'] }); }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx index f421d8828dc20..f17af3de75686 100644 --- a/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx @@ -5,61 +5,45 @@ * 2.0. */ -import type { EuiSuperSelectOption } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiHealth } from '@elastic/eui'; import React from 'react'; -import type { CaseSeverityWithAll } from '../../containers/types'; -import { SeverityAll } from '../../containers/types'; -import { severitiesWithAll } from '../severity/config'; +import type { CaseSeverity } from '../../../common/types/domain'; +import type { FilterOptions } from '../../containers/types'; +import { severities } from '../severity/config'; +import { MultiSelectFilter } from './multi_select_filter'; +import * as i18n from './translations'; + +interface SeverityOption { + label: CaseSeverity; +} interface Props { - selectedSeverity: CaseSeverityWithAll; - onSeverityChange: (status: CaseSeverityWithAll) => void; - isLoading: boolean; - isDisabled: boolean; + selectedOptions: CaseSeverity[]; + onChange: ({ filterId, options }: { filterId: keyof FilterOptions; options: string[] }) => void; } -export const SeverityFilter: React.FC = ({ - selectedSeverity, - onSeverityChange, - isLoading, - isDisabled, -}) => { - const caseSeverities = Object.keys(severitiesWithAll) as CaseSeverityWithAll[]; - const options: Array> = caseSeverities.map( - (severity) => { - const severityData = severitiesWithAll[severity]; - return { - value: severity, - inputDisplay: ( - - - {severity === SeverityAll ? ( - {severityData.label} - ) : ( - {severityData.label} - )} - - - ), - }; - } - ); +const options = Object.keys(severities) as CaseSeverity[]; + +export const SeverityFilter: React.FC = ({ selectedOptions, onChange }) => { + const renderOption = (option: SeverityOption) => { + const severityData = severities[option.label]; + return ( + + + {severityData.label} + + + ); + }; return ( - + buttonLabel={i18n.SEVERITY} + id={'severity'} + onChange={onChange} options={options} - valueOfSelected={selectedSeverity} - onChange={onSeverityChange} - data-test-subj="case-severity-filter" + renderOption={renderOption} + selectedOptions={selectedOptions} /> ); }; diff --git a/x-pack/plugins/cases/public/components/all_cases/status_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/status_filter.test.tsx index e58f885972d47..d668b94ea3238 100644 --- a/x-pack/plugins/cases/public/components/all_cases/status_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/status_filter.test.tsx @@ -6,78 +6,61 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { waitFor } from '@testing-library/react'; - -import { StatusAll } from '../../../common/ui/types'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { CaseStatuses } from '../../../common/types/domain'; import { StatusFilter } from './status_filter'; - -const stats = { - [StatusAll]: 0, - [CaseStatuses.open]: 2, - [CaseStatuses['in-progress']]: 5, - [CaseStatuses.closed]: 7, -}; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; describe('StatusFilter', () => { - const onStatusChanged = jest.fn(); + const onChange = jest.fn(); const defaultProps = { - selectedStatus: CaseStatuses.open, - onStatusChanged, - stats, + selectedOptions: [], + countClosedCases: 7, + countInProgressCases: 5, + countOpenCases: 2, + onChange, }; - it('should render', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="case-status-filter"]').exists()).toBeTruthy(); + afterEach(() => { + jest.clearAllMocks(); }); - it('should call onStatusChanged when changing status to open', async () => { - const wrapper = mount(); + it('should render', async () => { + render(); - wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); - wrapper.find('button[data-test-subj="case-status-filter-open"]').simulate('click'); - await waitFor(() => { - expect(onStatusChanged).toBeCalledWith('open'); - }); - }); + expect(screen.getByTestId('options-filter-popover-button-status')).toBeInTheDocument(); + expect(screen.getByTestId('options-filter-popover-button-status')).not.toBeDisabled(); - it('should call onStatusChanged when changing status to in-progress', async () => { - const wrapper = mount(); + userEvent.click(screen.getByRole('button', { name: 'Status' })); + await waitForEuiPopoverOpen(); - wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); - wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').simulate('click'); - await waitFor(() => { - expect(onStatusChanged).toBeCalledWith('in-progress'); - }); + expect(screen.getByRole('option', { name: CaseStatuses.open })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: CaseStatuses['in-progress'] })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: CaseStatuses.closed })).toBeInTheDocument(); + expect(screen.getAllByRole('option').length).toBe(3); }); - it('should call onStatusChanged when changing status to closed', async () => { - const wrapper = mount(); + it('should call onStatusChanged when changing status to open', async () => { + render(); + + userEvent.click(screen.getByRole('button', { name: 'Status' })); + await waitForEuiPopoverOpen(); + userEvent.click(screen.getByRole('option', { name: CaseStatuses.open })); - wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); - wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click'); await waitFor(() => { - expect(onStatusChanged).toBeCalledWith('closed'); + expect(onChange).toHaveBeenCalledWith({ filterId: 'status', options: [CaseStatuses.open] }); }); }); - it('should not render hidden statuses', () => { - const wrapper = mount( - - ); - - wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); - - expect(wrapper.find(`[data-test-subj="case-status-filter-all"]`).exists()).toBeFalsy(); - expect(wrapper.find('button[data-test-subj="case-status-filter-closed"]').exists()).toBeFalsy(); + it('should not render hidden statuses', async () => { + render(); - expect(wrapper.find('button[data-test-subj="case-status-filter-open"]').exists()).toBeTruthy(); + userEvent.click(screen.getByRole('button', { name: 'Status' })); + await waitForEuiPopoverOpen(); - expect( - wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').exists() - ).toBeTruthy(); + expect(screen.getAllByRole('option')).toHaveLength(2); + expect(screen.getByRole('option', { name: CaseStatuses.open })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: CaseStatuses['in-progress'] })).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx index 58ba9127566a0..0c40d601b56b6 100644 --- a/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx @@ -5,62 +5,75 @@ * 2.0. */ -import React, { memo } from 'react'; -import type { EuiSuperSelectOption } from '@elastic/eui'; -import { EuiSuperSelect, EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Status } from '@kbn/cases-components/src/status/status'; -import { allCaseStatus, statuses } from '../status'; -import type { CaseStatusWithAllStatus } from '../../../common/ui/types'; -import { StatusAll } from '../../../common/ui/types'; +import { CaseStatuses } from '../../../common/types/domain'; +import { statuses } from '../status'; +import type { FilterOptions } from '../../../common/ui/types'; +import { MultiSelectFilter } from './multi_select_filter'; +import * as i18n from './translations'; -interface Props { - stats: Record; - selectedStatus: CaseStatusWithAllStatus; - onStatusChanged: (status: CaseStatusWithAllStatus) => void; - hiddenStatuses?: CaseStatusWithAllStatus[]; +interface StatusOption { + label: CaseStatuses; } -const AllStatusBadge = () => { - return ( - - {allCaseStatus[StatusAll].label} - - ); -}; +interface Props { + countClosedCases: number | null; + countInProgressCases: number | null; + countOpenCases: number | null; + hiddenStatuses?: CaseStatuses[]; + onChange: ({ filterId, options }: { filterId: keyof FilterOptions; options: string[] }) => void; + selectedOptions: string[]; +} -AllStatusBadge.displayName = 'AllStatusBadge'; +const caseStatuses = Object.keys(statuses) as CaseStatuses[]; -const StatusFilterComponent: React.FC = ({ - stats, - selectedStatus, - onStatusChanged, +export const StatusFilterComponent = ({ + countClosedCases, + countInProgressCases, + countOpenCases, hiddenStatuses = [], -}) => { - const caseStatuses = Object.keys(statuses) as CaseStatusWithAllStatus[]; - const options: Array> = [StatusAll, ...caseStatuses] - .filter((status) => !hiddenStatuses.includes(status)) - .map((status) => ({ - value: status, - inputDisplay: ( - - - {status === 'all' ? : } - - {status !== StatusAll && {` (${stats[status]})`}} - - ), - 'data-test-subj': `case-status-filter-${status}`, - })); - + onChange, + selectedOptions, +}: Props) => { + const stats = useMemo( + () => ({ + [CaseStatuses.open]: countOpenCases ?? 0, + [CaseStatuses['in-progress']]: countInProgressCases ?? 0, + [CaseStatuses.closed]: countClosedCases ?? 0, + }), + [countClosedCases, countInProgressCases, countOpenCases] + ); + const options: CaseStatuses[] = useMemo( + () => [...caseStatuses].filter((status) => !hiddenStatuses.includes(status)), + [hiddenStatuses] + ); + const renderOption = (option: StatusOption) => { + const selectedStatus = option.label; + return ( + + + + + + + {` (${stats[selectedStatus]})`} + + ); + }; return ( - + buttonLabel={i18n.STATUS} + id={'status'} + onChange={onChange} options={options} - valueOfSelected={selectedStatus} - onChange={onStatusChanged} - data-test-subj="case-status-filter" + renderOption={renderOption} + selectedOptions={selectedOptions} /> ); }; -StatusFilterComponent.displayName = 'StatusFilter'; -export const StatusFilter = memo(StatusFilterComponent); +StatusFilterComponent.displayName = 'StatusFilterComponent'; + +export const StatusFilter = React.memo(StatusFilterComponent); 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 3919547937a15..dca42a7a64464 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 @@ -17,8 +17,6 @@ import { OWNER_INFO, SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER, - MAX_TAGS_FILTER_LENGTH, - MAX_CATEGORY_FILTER_LENGTH, } from '../../../common/constants'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; @@ -34,15 +32,13 @@ jest.mock('../../containers/use_get_categories'); jest.mock('../../containers/user_profiles/use_suggest_user_profiles'); const onFilterChanged = jest.fn(); -const setFilterRefetch = jest.fn(); const props = { countClosedCases: 1234, countOpenCases: 99, countInProgressCases: 54, onFilterChanged, - initial: DEFAULT_FILTER_OPTIONS, - setFilterRefetch, + filterOptions: DEFAULT_FILTER_OPTIONS, availableSolutions: [], isLoading: false, currentUserProfile: undefined, @@ -65,41 +61,41 @@ describe('CasesTableFilters ', () => { it('should render the case status filter dropdown', () => { appMockRender.render(); - expect(screen.getByTestId('case-status-filter')).toBeInTheDocument(); + expect(screen.getByTestId('options-filter-popover-button-status')).toBeInTheDocument(); }); it('should render the case severity filter dropdown', () => { appMockRender.render(); - expect(screen.getByTestId('case-severity-filter')).toBeTruthy(); + expect(screen.getByTestId('options-filter-popover-button-severity')).toBeTruthy(); }); it('should call onFilterChange when the severity filter changes', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('case-severity-filter')); + userEvent.click(screen.getByTestId('options-filter-popover-button-severity')); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('case-severity-filter-high')); + userEvent.click(screen.getByTestId('options-filter-popover-item-high')); - expect(onFilterChanged).toBeCalledWith({ severity: ['high'] }); + expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, severity: ['high'] }); }); it('should call onFilterChange when selected tags change', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('options-filter-popover-button-Tags')); + 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'] }); + expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, tags: ['coke'] }); }); it('should call onFilterChange when selected category changes', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('options-filter-popover-button-Categories')); + userEvent.click(screen.getByTestId('options-filter-popover-button-category')); await waitForEuiPopoverOpen(); userEvent.click(screen.getByTestId('options-filter-popover-item-twix')); - expect(onFilterChanged).toBeCalledWith({ category: ['twix'] }); + expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, category: ['twix'] }); }); it('should call onFilterChange when selected assignees change', async () => { @@ -127,7 +123,7 @@ describe('CasesTableFilters ', () => { it('should call onFilterChange when search changes', async () => { appMockRender.render(); - await userEvent.type(screen.getByTestId('search-cases'), 'My search{enter}'); + userEvent.type(screen.getByTestId('search-cases'), 'My search{enter}'); expect(onFilterChanged).toBeCalledWith({ search: 'My search' }); }); @@ -135,115 +131,14 @@ describe('CasesTableFilters ', () => { it('should call onFilterChange when changing status', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('case-status-filter')); + userEvent.click(screen.getByTestId('options-filter-popover-button-status')); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('case-status-filter-closed')); + userEvent.click(screen.getByTestId('options-filter-popover-item-closed')); - expect(onFilterChanged).toBeCalledWith({ status: [CaseStatuses.closed] }); - }); - - it('should remove tag from selected tags when tag no longer exists', () => { - const ourProps = { - ...props, - initial: { - ...DEFAULT_FILTER_OPTIONS, - tags: ['pepsi', 'rc'], - }, - }; - - appMockRender.render(); - expect(onFilterChanged).toHaveBeenCalledWith({ tags: ['pepsi'] }); - }); - - it('should show warning message when maximum tags selected', async () => { - const newTags = Array(MAX_TAGS_FILTER_LENGTH).fill('coke'); - (useGetTags as jest.Mock).mockReturnValue({ data: newTags, isLoading: false }); - - const ourProps = { - ...props, - initial: { - ...DEFAULT_FILTER_OPTIONS, - tags: newTags, - }, - }; - - appMockRender.render(); - - userEvent.click(screen.getByTestId('options-filter-popover-button-Tags')); - - await waitForEuiPopoverOpen(); - - expect(screen.getByTestId('maximum-length-warning')).toBeInTheDocument(); - }); - - it('should show warning message when tags selection reaches maximum limit', async () => { - const newTags = Array(MAX_TAGS_FILTER_LENGTH - 1).fill('coke'); - const tags = [...newTags, 'pepsi']; - (useGetTags as jest.Mock).mockReturnValue({ data: tags, isLoading: false }); - - const ourProps = { - ...props, - initial: { - ...DEFAULT_FILTER_OPTIONS, - tags: newTags, - }, - }; - - appMockRender.render(); - - userEvent.click(screen.getByTestId('options-filter-popover-button-Tags')); - - await waitForEuiPopoverOpen(); - - userEvent.click(screen.getByTestId(`options-filter-popover-item-${tags[tags.length - 1]}`)); - - expect(screen.getByTestId('maximum-length-warning')).toBeInTheDocument(); - }); - - it('should not show warning message when one of the tags deselected after reaching the limit', async () => { - const newTags = Array(MAX_TAGS_FILTER_LENGTH).fill('coke'); - (useGetTags as jest.Mock).mockReturnValue({ data: newTags, isLoading: false }); - - const ourProps = { - ...props, - initial: { - ...DEFAULT_FILTER_OPTIONS, - tags: newTags, - }, - }; - - appMockRender.render(); - - userEvent.click(screen.getByTestId('options-filter-popover-button-Tags')); - - await waitForEuiPopoverOpen(); - - expect(screen.getByTestId('maximum-length-warning')).toBeInTheDocument(); - - userEvent.click(screen.getAllByTestId(`options-filter-popover-item-${newTags[0]}`)[0]); - - expect(screen.queryByTestId('maximum-length-warning')).not.toBeInTheDocument(); - }); - - it('should show warning message when maximum categories selected', async () => { - const newCategories = Array(MAX_CATEGORY_FILTER_LENGTH).fill('snickers'); - (useGetCategories as jest.Mock).mockReturnValue({ data: newCategories, isLoading: false }); - - const ourProps = { - ...props, - initial: { - ...DEFAULT_FILTER_OPTIONS, - category: newCategories, - }, - }; - - appMockRender.render(); - - userEvent.click(screen.getByTestId('options-filter-popover-button-Categories')); - - await waitForEuiPopoverOpen(); - - expect(screen.getByTestId('maximum-length-warning')).toBeInTheDocument(); + expect(onFilterChanged).toBeCalledWith({ + ...DEFAULT_FILTER_OPTIONS, + status: [CaseStatuses.closed], + }); }); it('should remove assignee from selected assignees when assignee no longer exists', async () => { @@ -280,14 +175,6 @@ describe('CasesTableFilters ', () => { `); }); - it('StatusFilterWrapper should have a fixed width of 180px', () => { - appMockRender.render(); - - expect(screen.getByTestId('status-filter-wrapper')).toHaveStyleRule('flex-basis', '180px', { - modifier: '&&', - }); - }); - describe('Solution filter', () => { const securitySolution = { id: SECURITY_SOLUTION_OWNER, 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 bc56fb0aeee34..210398946d639 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 @@ -5,24 +5,20 @@ * 2.0. */ -import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useState } from 'react'; import { isEqual } from 'lodash/fp'; -import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; -import type { CaseStatusWithAllStatus, CaseSeverityWithAll } from '../../../common/ui/types'; +import type { CaseStatuses } from '../../../common/types/domain'; import { MAX_TAGS_FILTER_LENGTH, MAX_CATEGORY_FILTER_LENGTH } from '../../../common/constants'; -import { StatusAll } from '../../../common/ui/types'; -import { CaseStatuses } from '../../../common/types/domain'; import type { FilterOptions } from '../../containers/types'; -import { FilterPopover } from '../filter_popover'; +import { MultiSelectFilter } from './multi_select_filter'; import { SolutionFilter } from './solution_filter'; import { StatusFilter } from './status_filter'; import * as i18n from './translations'; import { SeverityFilter } from './severity_filter'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetCategories } from '../../containers/use_get_categories'; -import { DEFAULT_FILTER_OPTIONS } from '../../containers/constants'; import { AssigneesFilterPopover } from './assignees_filter'; import type { CurrentUserProfile } from '../types'; import { useCasesFeatures } from '../../common/use_cases_features'; @@ -34,50 +30,52 @@ interface CasesTableFiltersProps { countInProgressCases: number | null; countOpenCases: number | null; onFilterChanged: (filterOptions: Partial) => void; - initial: FilterOptions; - hiddenStatuses?: CaseStatusWithAllStatus[]; + hiddenStatuses?: CaseStatuses[]; availableSolutions: Solution[]; isSelectorView?: boolean; onCreateCasePressed?: () => void; isLoading: boolean; currentUserProfile: CurrentUserProfile; + filterOptions: FilterOptions; } -// Fix the width of the status dropdown to prevent hiding long text items -const StatusFilterWrapper = styled(EuiFlexItem)` - && { - flex-basis: 180px; - } -`; - -const SeverityFilterWrapper = styled(EuiFlexItem)` - && { - flex-basis: 180px; - } -`; - const CasesTableFiltersComponent = ({ countClosedCases, countOpenCases, countInProgressCases, onFilterChanged, - initial = DEFAULT_FILTER_OPTIONS, hiddenStatuses, availableSolutions, isSelectorView = false, onCreateCasePressed, isLoading, currentUserProfile, + filterOptions, }: CasesTableFiltersProps) => { - const [search, setSearch] = useState(initial.search); - const [selectedTags, setSelectedTags] = useState(initial.tags); - const [selectedCategories, setSelectedCategories] = useState(initial.category); + const [search, setSearch] = useState(filterOptions.search); const [selectedOwner, setSelectedOwner] = useState([]); const [selectedAssignees, setSelectedAssignees] = useState([]); const { data: tags = [] } = useGetTags(); const { data: categories = [] } = useGetCategories(); const { caseAssignmentAuthorized } = useCasesFeatures(); + const onChange = ({ + filterId, + options, + }: { + filterId: keyof FilterOptions; + options: string[]; + }) => { + const newFilters = { + ...filterOptions, + [filterId]: options, + }; + + if (!isEqual(newFilters, filterOptions)) { + onFilterChanged(newFilters); + } + }; + const handleSelectedAssignees = useCallback( (newAssignees: AssigneesFilteringSelection[]) => { if (!isEqual(newAssignees, selectedAssignees)) { @@ -90,16 +88,6 @@ const CasesTableFiltersComponent = ({ [selectedAssignees, onFilterChanged] ); - const handleSelectedTags = useCallback( - (newTags) => { - if (!isEqual(newTags, selectedTags)) { - setSelectedTags(newTags); - onFilterChanged({ tags: newTags }); - } - }, - [onFilterChanged, selectedTags] - ); - const handleSelectedSolution = useCallback( (newOwner) => { if (!isEqual(newOwner, selectedOwner)) { @@ -110,23 +98,6 @@ const CasesTableFiltersComponent = ({ [onFilterChanged, selectedOwner] ); - const handleSelectedCategories = useCallback( - (newCategories) => { - if (!isEqual(newCategories, selectedCategories)) { - setSelectedCategories(newCategories); - onFilterChanged({ category: newCategories }); - } - }, - [onFilterChanged, selectedCategories] - ); - - useEffect(() => { - if (selectedTags.length) { - const newTags = selectedTags.filter((t) => tags.includes(t)); - handleSelectedTags(newTags); - } - }, [handleSelectedTags, selectedTags, tags]); - const handleOnSearch = useCallback( (newSearch) => { const trimSearch = newSearch.trim(); @@ -138,30 +109,6 @@ const CasesTableFiltersComponent = ({ [onFilterChanged, search] ); - const onStatusChanged = useCallback( - (status: CaseStatusWithAllStatus) => { - onFilterChanged({ status: [status] }); - }, - [onFilterChanged] - ); - - const onSeverityChanged = useCallback( - (severity: CaseSeverityWithAll) => { - onFilterChanged({ severity: [severity] }); - }, - [onFilterChanged] - ); - - const stats = useMemo( - () => ({ - [StatusAll]: null, - [CaseStatuses.open]: countOpenCases ?? 0, - [CaseStatuses['in-progress']]: countInProgressCases ?? 0, - [CaseStatuses.closed]: countClosedCases ?? 0, - }), - [countClosedCases, countInProgressCases, countOpenCases] - ); - const handleOnCreateCasePressed = useCallback(() => { if (onCreateCasePressed) { onCreateCasePressed(); @@ -169,51 +116,40 @@ const CasesTableFiltersComponent = ({ }, [onCreateCasePressed]); return ( - - - - {isSelectorView && onCreateCasePressed ? ( - - - {i18n.CREATE_CASE_TITLE} - - - ) : null} - - - - - - - - - - + + {isSelectorView && onCreateCasePressed ? ( + + + {i18n.CREATE_CASE_TITLE} + + + ) : null} + + + + {caseAssignmentAuthorized && !isSelectorView ? ( ) : null} - - {availableSolutions.length > 1 && ( + i18n.translate('xpack.cases.tableFilters.useFilters.options', { + defaultMessage: '{totalCount, plural, one {# option} other {# options}}', + values: { totalCount }, + }); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx index acd10eb3ea72d..30de5acb0bfac 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx @@ -157,7 +157,7 @@ describe('useAllCasesQueryParams', () => { expect(result.current.filterOptions).toMatchObject(existingLocalStorageValues); }); - it('takes into account legacy localStorage filter values', () => { + it('takes into account legacy localStorage filter values as string', () => { const existingLocalStorageValues = { severity: 'critical', status: 'open' }; localStorage.setItem( @@ -175,6 +175,24 @@ describe('useAllCasesQueryParams', () => { }); }); + it('takes into account legacy localStorage filter value all', () => { + const existingLocalStorageValues = { severity: 'all', status: 'all' }; + + localStorage.setItem( + LOCALSTORAGE_FILTER_OPTIONS_KEY, + JSON.stringify(existingLocalStorageValues) + ); + + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.filterOptions).toMatchObject({ + severity: [], + status: [], + }); + }); + it('takes into account existing url query params on first run', () => { const nonDefaultUrlParams = { page: DEFAULT_TABLE_ACTIVE_PAGE + 1, @@ -207,6 +225,24 @@ describe('useAllCasesQueryParams', () => { }); }); + it('takes into account legacy url filter option "all"', () => { + const nonDefaultUrlParams = new URLSearchParams(); + nonDefaultUrlParams.append('severity', 'all'); + nonDefaultUrlParams.append('status', 'all'); + nonDefaultUrlParams.append('status', 'open'); + nonDefaultUrlParams.append('severity', 'low'); + + mockLocation.search = stringifyToURL(nonDefaultUrlParams); + + renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); + + expect(useHistory().replace).toHaveBeenCalledWith({ + search: 'severity=low&status=open&page=1&perPage=10&sortField=createdAt&sortOrder=desc', + }); + }); + it('preserves other url parameters', () => { const nonDefaultUrlParams = { foo: 'bar', @@ -219,8 +255,7 @@ describe('useAllCasesQueryParams', () => { }); expect(useHistory().replace).toHaveBeenCalledWith({ - search: - 'foo=bar&page=1&perPage=10&sortField=createdAt&sortOrder=desc&severity=all&status=all', + search: 'foo=bar&page=1&perPage=10&sortField=createdAt&sortOrder=desc&severity=&status=', }); }); @@ -288,7 +323,7 @@ describe('useAllCasesQueryParams', () => { }); expect(useHistory().replace).toHaveBeenCalledWith({ - search: 'perPage=100&page=1&sortField=createdAt&sortOrder=desc&severity=all&status=all', + search: 'perPage=100&page=1&sortField=createdAt&sortOrder=desc&severity=&status=', }); mockLocation.search = ''; @@ -314,7 +349,7 @@ describe('useAllCasesQueryParams', () => { }); expect(useHistory().replace).toHaveBeenCalledWith({ - search: 'sortOrder=desc&page=1&perPage=10&sortField=createdAt&severity=all&status=all', + search: 'sortOrder=desc&page=1&perPage=10&sortField=createdAt&severity=&status=', }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx index 3733f17d41e9d..b988cf501cddb 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx @@ -11,6 +11,7 @@ import { isEqual } from 'lodash'; import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { removeLegacyValuesFromOptions, getStorableFilters } from './utils/sanitize_filter_options'; import type { FilterOptions, PartialFilterOptions, @@ -28,6 +29,7 @@ import { SORT_ORDER_VALUES } from '../../../common/ui/types'; import { useCasesContext } from '../cases_context/use_cases_context'; import { CASES_TABLE_PERPAGE_VALUES } from './types'; import { parseURLWithFilterOptions } from './utils/parse_url_with_filter_options'; +import { serializeUrlParams } from './utils/serialize_url_params'; export const getQueryParamsLocalStorageKey = (appId: string) => { const filteringKey = LOCAL_STORAGE_KEYS.casesQueryParams; @@ -114,15 +116,7 @@ const getFilterOptions = ( return { ...filterOptions, ...params, - severity, - status, - }; -}; - -const getSupportedFilterOptions = (filterOptions: PartialFilterOptions): PartialFilterOptions => { - return { - ...(filterOptions.severity && { severity: filterOptions.severity }), - ...(filterOptions.status && { status: filterOptions.status }), + ...removeLegacyValuesFromOptions({ status, severity }), }; }; @@ -186,8 +180,7 @@ export function useAllCasesState( localStorageFilterOptions ); - const newPersistedFilterOptions: PartialFilterOptions = - getSupportedFilterOptions(newFilterOptions); + const newPersistedFilterOptions: PartialFilterOptions = getStorableFilters(newFilterOptions); const newLocalStorageFilterOptions: PartialFilterOptions = { ...localStorageFilterOptions, @@ -210,19 +203,21 @@ export function useAllCasesState( const stateUrlParams = { ...parsedUrlParams, ...queryParams, - ...getSupportedFilterOptions(filterOptions), + ...getStorableFilters(filterOptions), page: queryParams.page.toString(), perPage: queryParams.perPage.toString(), }; if (!isEqual(parsedUrlParams, stateUrlParams)) { try { + const urlParams = serializeUrlParams({ + ...parsedUrlParams, + ...stateUrlParams, + }); + const newHistory = { ...location, - search: stringifyToURL({ ...parsedUrlParams, ...stateUrlParams } as unknown as Record< - string, - string - >), + search: stringifyToURL(urlParams), }; history.replace(newHistory); } catch { diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.test.tsx index d05ab6c788737..2cfa14f3af328 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.test.tsx @@ -48,4 +48,9 @@ describe('parseURLWithFilterOptions', () => { status: ['foo', 'bar', 'baz', 'qux', 'quux'], }); }); + + it('parses a url with status=', () => { + const url = 'status='; + expect(parseURLWithFilterOptions(url)).toStrictEqual({ status: [] }); + }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.tsx index 79c71d5ea7d01..6467dc99ab440 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.tsx @@ -31,7 +31,7 @@ export const parseURLWithFilterOptions = (search: string) => { if (paramKeysWithTypeArray.includes(key)) { if (!parsedUrlParams[key]) parsedUrlParams[key] = []; // only applies if the value is separated by commas (e.g., "foo,bar") - const splittedValues = value.split(','); + const splittedValues = value.split(',').filter(Boolean); (parsedUrlParams[key] as string[]).push(...splittedValues); } else { parsedUrlParams[key] = value; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.test.tsx new file mode 100644 index 0000000000000..b96dbc40fe668 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.test.tsx @@ -0,0 +1,46 @@ +/* + * 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. + */ + +// This file was contributed to by generative AI + +import { CaseStatuses, CaseSeverity } from '../../../../common/types/domain'; +import { removeLegacyValuesFromOptions, getStorableFilters } from './sanitize_filter_options'; + +describe('removeLegacyValuesFromOptions', () => { + it('should remove legacy values from options', () => { + const options: { + status: Array; + severity: Array; + } = { + status: ['all', CaseStatuses.open, CaseStatuses['in-progress'], 'all'], + severity: ['all', CaseSeverity.LOW, 'all'], + }; + + expect(removeLegacyValuesFromOptions(options)).toEqual({ + status: ['open', 'in-progress'], + severity: ['low'], + }); + }); +}); + +describe('getStorableFilters', () => { + it('should return the filters if provided', () => { + expect( + getStorableFilters({ + status: [CaseStatuses.open, CaseStatuses['in-progress']], + severity: [CaseSeverity.LOW], + }) + ).toEqual({ + status: [CaseStatuses.open, CaseStatuses['in-progress']], + severity: [CaseSeverity.LOW], + }); + }); + + it('should return undefined if no filters are provided', () => { + expect(getStorableFilters({})).toEqual({ status: undefined, severity: undefined }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.tsx new file mode 100644 index 0000000000000..498d2998a0a20 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.tsx @@ -0,0 +1,37 @@ +/* + * 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 type { FilterOptions } from '../../../../common/ui/types'; +import type { CaseStatuses, CaseSeverity } from '../../../../common/types/domain'; + +const notAll = (option: string) => option !== 'all'; + +/** + * In earlier versions, the options 'status' and 'severity' could have a value of 'all'. + * This function ensures such legacy values are removed from the URL parameters to maintain + * backwards compatibility. + */ +export const removeLegacyValuesFromOptions = ({ + status: legacyStatus, + severity: legacySeverity, +}: { + status: Array; + severity: Array; +}): { status: CaseStatuses[]; severity: CaseSeverity[] } => { + return { + status: legacyStatus.filter(notAll).filter(Boolean) as CaseStatuses[], + severity: legacySeverity.filter(notAll).filter(Boolean) as CaseSeverity[], + }; +}; + +export const getStorableFilters = ( + filterOptions: Partial +): { status: CaseStatuses[] | undefined; severity: CaseSeverity[] | undefined } => { + const { status, severity } = filterOptions; + + return { severity, status }; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.test.tsx new file mode 100644 index 0000000000000..ace5fdda934ab --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 { serializeUrlParams } from './serialize_url_params'; + +describe('serializeUrlParams', () => { + const commonProps = { + page: '1', + perPage: '5', + sortField: 'createdAt', + sortOrder: 'desc', + }; + + it('empty severity and status', () => { + const urlParams = { + ...commonProps, + status: [], + severity: [], + }; + + expect(serializeUrlParams(urlParams).toString()).toEqual( + 'page=1&perPage=5&sortField=createdAt&sortOrder=desc&status=&severity=' + ); + }); + + it('severity and status with one value', () => { + const urlParams = { + ...commonProps, + status: ['open'], + severity: ['low'], + }; + + expect(serializeUrlParams(urlParams).toString()).toEqual( + 'page=1&perPage=5&sortField=createdAt&sortOrder=desc&status=open&severity=low' + ); + }); + + it('severity and status with multiple values', () => { + const urlParams = { + ...commonProps, + status: ['open', 'closed'], + severity: ['low', 'high'], + }; + + expect(serializeUrlParams(urlParams).toString()).toEqual( + 'page=1&perPage=5&sortField=createdAt&sortOrder=desc&status=open&status=closed&severity=low&severity=high' + ); + }); + + it('severity and status are undefined', () => { + const urlParams = { + ...commonProps, + status: undefined, + severity: undefined, + }; + + expect(serializeUrlParams(urlParams).toString()).toEqual( + 'page=1&perPage=5&sortField=createdAt&sortOrder=desc' + ); + }); + + it('severity and status are undefined but there are more filters to serialize', () => { + const urlParams = { + status: undefined, + severity: undefined, + ...commonProps, + }; + + expect(serializeUrlParams(urlParams).toString()).toEqual( + 'page=1&perPage=5&sortField=createdAt&sortOrder=desc' + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.tsx new file mode 100644 index 0000000000000..4b3e352b894d0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.tsx @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export function serializeUrlParams(urlParams: { + [key in string]: string[] | string | undefined; +}) { + const urlSearchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(urlParams)) { + if (value) { + if (Array.isArray(value)) { + if (value.length === 0) { + urlSearchParams.append(key, ''); + } else { + value.forEach((v) => urlSearchParams.append(key, v)); + } + } else { + urlSearchParams.append(key, value); + } + } + } + + return urlSearchParams; +} diff --git a/x-pack/plugins/cases/public/components/severity/config.ts b/x-pack/plugins/cases/public/components/severity/config.ts index 6169cfb14d5b3..85df4b527c1b5 100644 --- a/x-pack/plugins/cases/public/components/severity/config.ts +++ b/x-pack/plugins/cases/public/components/severity/config.ts @@ -7,8 +7,7 @@ import { euiLightVars } from '@kbn/ui-theme'; import { CaseSeverity } from '../../../common/types/domain'; -import { SeverityAll } from '../../containers/types'; -import { ALL_SEVERITIES, CRITICAL, HIGH, LOW, MEDIUM } from './translations'; +import { CRITICAL, HIGH, LOW, MEDIUM } from './translations'; export const severities = { [CaseSeverity.LOW]: { @@ -28,11 +27,3 @@ export const severities = { label: CRITICAL, }, }; - -export const severitiesWithAll = { - [SeverityAll]: { - color: 'transparent', - label: ALL_SEVERITIES, - }, - ...severities, -}; diff --git a/x-pack/plugins/cases/public/components/severity/translations.ts b/x-pack/plugins/cases/public/components/severity/translations.ts index b70dbebe41d19..b5982c70ed690 100644 --- a/x-pack/plugins/cases/public/components/severity/translations.ts +++ b/x-pack/plugins/cases/public/components/severity/translations.ts @@ -26,7 +26,3 @@ export const CRITICAL = i18n.translate('xpack.cases.severity.critical', { export const SEVERITY_TITLE = i18n.translate('xpack.cases.severity.title', { defaultMessage: 'Severity', }); - -export const ALL_SEVERITIES = i18n.translate('xpack.cases.severity.all', { - defaultMessage: 'All severities', -}); diff --git a/x-pack/plugins/cases/public/components/status/config.ts b/x-pack/plugins/cases/public/components/status/config.ts index d6f66515580c1..dfe8f94eb89c3 100644 --- a/x-pack/plugins/cases/public/components/status/config.ts +++ b/x-pack/plugins/cases/public/components/status/config.ts @@ -5,17 +5,12 @@ * 2.0. */ import { getStatusConfiguration } from '@kbn/cases-components/src/status/config'; -import { StatusAll } from '../../../common/ui/types'; import { CaseStatuses } from '../../../common/types/domain'; import * as i18n from './translations'; -import type { AllCaseStatus, Statuses } from './types'; +import type { Statuses } from './types'; const statusConfiguration = getStatusConfiguration(); -export const allCaseStatus: AllCaseStatus = { - [StatusAll]: { color: 'hollow', label: i18n.ALL }, -}; - export const statuses: Statuses = { [CaseStatuses.open]: { ...statusConfiguration[CaseStatuses.open], diff --git a/x-pack/plugins/cases/public/components/status/types.ts b/x-pack/plugins/cases/public/components/status/types.ts index b871e51b78f66..689ffdfe7cf42 100644 --- a/x-pack/plugins/cases/public/components/status/types.ts +++ b/x-pack/plugins/cases/public/components/status/types.ts @@ -7,9 +7,6 @@ import type { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import type { CaseStatuses } from '../../../common/types/domain'; -import type { StatusAllType } from '../../../common/ui/types'; - -export type AllCaseStatus = Record; export type Statuses = Record< CaseStatuses, diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 1d4d4b798564d..97e84b8bbdc82 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -177,7 +177,7 @@ export const removeItemFromSessionStorage = (key: string) => { window.sessionStorage.removeItem(key); }; -export const stringifyToURL = (parsedParams: Record) => +export const stringifyToURL = (parsedParams: Record | URLSearchParams) => new URLSearchParams(parsedParams).toString(); export const parseURL = (queryString: string) => Object.fromEntries(new URLSearchParams(queryString)); diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index 7a1d401c94e2d..1b43b4f590d9e 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -38,7 +38,6 @@ import type { ResolvedCase, CaseUserActionsStats, } from '../../../common/ui/types'; -import { SeverityAll } from '../../../common/ui/types'; import type { SingleCaseMetricsResponse, CasePostRequest, @@ -87,7 +86,7 @@ export const getCaseUserActionsStats = async ( export const getCases = async ({ filterOptions = { - severity: [SeverityAll], + severity: [], search: '', searchFields: [], assignees: [], diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index 36885269c7993..2b82f64804bd5 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -277,11 +277,11 @@ describe('Cases API', () => { }); }); - it('should not send the severity field with "all" severity value', async () => { + it('should not send the severity field if empty', async () => { await getCases({ filterOptions: { ...DEFAULT_FILTER_OPTIONS, - severity: ['all'], + severity: [], }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, @@ -318,11 +318,11 @@ describe('Cases API', () => { }); }); - it('should not send the severity field with "all" status value', async () => { + it('should not send the status field if empty', async () => { await getCases({ filterOptions: { ...DEFAULT_FILTER_OPTIONS, - status: ['all'], + status: [], }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 0c9b176f069e6..ad75838ee2a86 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -35,7 +35,7 @@ import type { CasesUI, FilterOptions, } from '../../common/ui/types'; -import { SeverityAll, SortFieldCase, StatusAll } from '../../common/ui/types'; +import { SortFieldCase } from '../../common/ui/types'; import { getCaseCommentsUrl, getCasesDeleteFileAttachmentsUrl, @@ -252,10 +252,10 @@ export const getCases = async ({ filterOptions = { search: '', searchFields: [], - severity: [SeverityAll], + severity: [], assignees: [], reporters: [], - status: [StatusAll], + status: [], tags: [], owner: [], category: [], @@ -272,12 +272,12 @@ export const getCases = async ({ ...removeOptionFromFilter({ filterKey: 'status', filterOptions: filterOptions.status, - optionToBeRemoved: StatusAll, + optionToBeRemoved: 'all', }), ...removeOptionFromFilter({ filterKey: 'severity', filterOptions: filterOptions.severity, - optionToBeRemoved: SeverityAll, + optionToBeRemoved: 'all', }), ...constructAssigneesFilter(filterOptions.assignees), ...constructReportersFilter(filterOptions.reporters), diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index adeafe8f80868..4495d28339c12 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -6,7 +6,7 @@ */ import type { FilterOptions, QueryParams, SingleCaseMetricsFeature } from './types'; -import { SeverityAll, SortFieldCase, StatusAll } from './types'; +import { SortFieldCase } from './types'; export const DEFAULT_TABLE_ACTIVE_PAGE = 1; export const DEFAULT_TABLE_LIMIT = 10; @@ -69,10 +69,10 @@ const DEFAULT_SEARCH_FIELDS = ['title', 'description']; export const DEFAULT_FILTER_OPTIONS: FilterOptions = { search: '', searchFields: DEFAULT_SEARCH_FIELDS, - severity: [SeverityAll], + severity: [], assignees: [], reporters: [], - status: [StatusAll], + status: [], tags: [], owner: [], category: [], diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index c06aff873c9bf..96eac1e354891 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -11663,7 +11663,6 @@ "xpack.cases.server.viewCaseInKibana": "Pour plus de détails, afficher ce cas dans Kibana", "xpack.cases.settings.syncAlertsSwitchLabelOff": "Désactivé", "xpack.cases.settings.syncAlertsSwitchLabelOn": "Activé", - "xpack.cases.severity.all": "Toutes les sévérités", "xpack.cases.severity.critical": "Critique", "xpack.cases.severity.high": "Élevé", "xpack.cases.severity.low": "Bas", @@ -43809,4 +43808,4 @@ "xpack.serverlessObservability.nav.projectSettings": "Paramètres de projet", "xpack.serverlessObservability.nav.visualizations": "Visualisations" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5f27f5b60da13..b1d2c44791572 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11677,7 +11677,6 @@ "xpack.cases.server.viewCaseInKibana": "詳細については、Kibanaでこのケースを確認してください", "xpack.cases.settings.syncAlertsSwitchLabelOff": "オフ", "xpack.cases.settings.syncAlertsSwitchLabelOn": "オン", - "xpack.cases.severity.all": "すべての重要度", "xpack.cases.severity.critical": "重大", "xpack.cases.severity.high": "高", "xpack.cases.severity.low": "低", @@ -43799,4 +43798,4 @@ "xpack.serverlessObservability.nav.projectSettings": "プロジェクト設定", "xpack.serverlessObservability.nav.visualizations": "ビジュアライゼーション" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7c8328a39f987..6408882ad34eb 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11677,7 +11677,6 @@ "xpack.cases.server.viewCaseInKibana": "有关详细信息,请在 Kibana 中查看此案例", "xpack.cases.settings.syncAlertsSwitchLabelOff": "关闭", "xpack.cases.settings.syncAlertsSwitchLabelOn": "开启", - "xpack.cases.severity.all": "所有严重性", "xpack.cases.severity.critical": "紧急", "xpack.cases.severity.high": "高", "xpack.cases.severity.low": "低", @@ -43793,4 +43792,4 @@ "xpack.serverlessObservability.nav.projectSettings": "项目设置", "xpack.serverlessObservability.nav.visualizations": "可视化" } -} +} \ No newline at end of file diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts index a83b11da78b78..4cedf086b8803 100644 --- a/x-pack/test/functional/services/cases/list.ts +++ b/x-pack/test/functional/services/cases/list.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { CaseSeverityWithAll } from '@kbn/cases-plugin/common/ui'; import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -135,7 +134,7 @@ export function CasesTableServiceProvider( async filterByTag(tag: string) { await common.clickAndValidate( - 'options-filter-popover-button-Tags', + 'options-filter-popover-button-tags', `options-filter-popover-item-${tag}` ); @@ -144,7 +143,7 @@ export function CasesTableServiceProvider( async filterByCategory(category: string) { await common.clickAndValidate( - 'options-filter-popover-button-Categories', + 'options-filter-popover-button-category', `options-filter-popover-item-${category}` ); @@ -152,14 +151,28 @@ export function CasesTableServiceProvider( }, async filterByStatus(status: CaseStatuses) { - await common.clickAndValidate('case-status-filter', `case-status-filter-${status}`); + await common.clickAndValidate( + 'options-filter-popover-button-status', + `options-filter-popover-item-${status}` + ); + + await testSubjects.click(`options-filter-popover-item-${status}`); + // to close the popup + await testSubjects.click('options-filter-popover-button-status'); - await testSubjects.click(`case-status-filter-${status}`); + await testSubjects.missingOrFail(`options-filter-popover-item-${status}`, { + timeout: 5000, + }); }, - async filterBySeverity(severity: CaseSeverityWithAll) { - await common.clickAndValidate('case-severity-filter', `case-severity-filter-${severity}`); - await testSubjects.click(`case-severity-filter-${severity}`); + async filterBySeverity(severity: CaseSeverity) { + await common.clickAndValidate( + 'options-filter-popover-button-severity', + `options-filter-popover-item-${severity}` + ); + await testSubjects.click(`options-filter-popover-item-${severity}`); + // to close the popup + await testSubjects.click('options-filter-popover-button-severity'); }, async filterByAssignee(assignee: string) { diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts index 51a37c1eaccd9..74778ba830581 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts @@ -7,7 +7,6 @@ import expect from '@kbn/expect'; import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; -import { SeverityAll } from '@kbn/cases-plugin/common/ui'; import { UserProfile } from '@kbn/user-profile-components'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { @@ -444,6 +443,25 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.casesTable.validateCasesTableHasNthRows(1); }); + it('filter multiple status', async () => { + await cases.casesTable.changeStatus(CaseStatuses['in-progress'], 0); + await cases.casesTable.refreshTable(); + await cases.casesTable.changeStatus(CaseStatuses.closed, 1); + await cases.casesTable.refreshTable(); + + // by default filter by all + await cases.casesTable.validateCasesTableHasNthRows(4); + + await cases.casesTable.filterByStatus(CaseStatuses.open); + await cases.casesTable.validateCasesTableHasNthRows(2); + + await cases.casesTable.filterByStatus(CaseStatuses['in-progress']); + await cases.casesTable.validateCasesTableHasNthRows(3); + + await cases.casesTable.filterByStatus(CaseStatuses.closed); + await cases.casesTable.validateCasesTableHasNthRows(4); + }); + it('persists status filters', async () => { await cases.casesTable.changeStatus(CaseStatuses.closed, 0); await testSubjects.existOrFail(`case-status-badge-${CaseStatuses.closed}`); @@ -451,6 +469,18 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.existOrFail(`case-status-badge-${CaseStatuses.closed}`); }); + it('persists multiple status filters', async () => { + await cases.casesTable.changeStatus(CaseStatuses['in-progress'], 0); + await cases.casesTable.changeStatus(CaseStatuses.closed, 1); + await cases.casesTable.filterByStatus(CaseStatuses['in-progress']); + await cases.casesTable.filterByStatus(CaseStatuses.closed); + await cases.casesTable.validateCasesTableHasNthRows(2); + await browser.refresh(); + await testSubjects.existOrFail(`case-status-badge-${CaseStatuses['in-progress']}`); + await testSubjects.existOrFail(`case-status-badge-${CaseStatuses.closed}`); + await cases.casesTable.validateCasesTableHasNthRows(2); + }); + it('persists severity filters', async () => { await cases.casesTable.changeSeverity(CaseSeverity.MEDIUM, 0); await testSubjects.existOrFail(`case-table-column-severity-${CaseSeverity.MEDIUM}`); @@ -458,6 +488,18 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.existOrFail(`case-table-column-severity-${CaseSeverity.MEDIUM}`); }); + it('persists multiple severity filters', async () => { + await cases.casesTable.changeSeverity(CaseSeverity.HIGH, 0); + await cases.casesTable.changeSeverity(CaseSeverity.MEDIUM, 1); + await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); + await cases.casesTable.filterBySeverity(CaseSeverity.MEDIUM); + await cases.casesTable.validateCasesTableHasNthRows(2); + await browser.refresh(); + await testSubjects.existOrFail(`case-table-column-severity-${CaseSeverity.HIGH}`); + await testSubjects.existOrFail(`case-table-column-severity-${CaseSeverity.MEDIUM}`); + await cases.casesTable.validateCasesTableHasNthRows(2); + }); + describe('assignees filtering', () => { it('filters cases by the first cases all user assignee', async () => { await cases.casesTable.filterByAssignee('all'); @@ -529,21 +571,41 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // by default filter by all await cases.casesTable.validateCasesTableHasNthRows(5); - // low await cases.casesTable.filterBySeverity(CaseSeverity.LOW); await cases.casesTable.validateCasesTableHasNthRows(2); + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.LOW); - // high await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); await cases.casesTable.validateCasesTableHasNthRows(2); + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); - // critical await cases.casesTable.filterBySeverity(CaseSeverity.CRITICAL); await cases.casesTable.validateCasesTableHasNthRows(1); + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.CRITICAL); + + await cases.casesTable.validateCasesTableHasNthRows(5); + }); - // back to all - await cases.casesTable.filterBySeverity(SeverityAll); + it('filter multiple severities', async () => { + // by default filter by all await cases.casesTable.validateCasesTableHasNthRows(5); + + await cases.casesTable.filterBySeverity(CaseSeverity.LOW); + await cases.casesTable.validateCasesTableHasNthRows(2); + + await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); + await cases.casesTable.validateCasesTableHasNthRows(4); + + await cases.casesTable.filterBySeverity(CaseSeverity.CRITICAL); + await cases.casesTable.validateCasesTableHasNthRows(5); + + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.LOW); + await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); + await cases.casesTable.filterBySeverity(CaseSeverity.CRITICAL); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts index ef24d84ee7624..3b578d2901452 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts @@ -13,7 +13,7 @@ import { ALL_CASES_COMMENTS_COUNT, ALL_CASES_IN_PROGRESS_CASES_STATS, ALL_CASES_NAME, - ALL_CASES_OPEN_CASES_COUNT, + ALL_CASES_STATUS_FILTER, ALL_CASES_OPEN_CASES_STATS, ALL_CASES_OPENED_ON, ALL_CASES_PAGE_TITLE, @@ -84,7 +84,7 @@ describe('Cases', { tags: ['@ess', '@serverless'] }, () => { cy.get(ALL_CASES_OPEN_CASES_STATS).should('have.text', '1'); cy.get(ALL_CASES_CLOSED_CASES_STATS).should('have.text', '0'); cy.get(ALL_CASES_IN_PROGRESS_CASES_STATS).should('have.text', '0'); - cy.get(ALL_CASES_OPEN_CASES_COUNT).should('have.text', 'Open (1)'); + cy.get(ALL_CASES_STATUS_FILTER).should('have.text', 'Status1'); cy.get(ALL_CASES_TAGS_COUNT).should('have.text', 'Tags2'); cy.get(ALL_CASES_NAME).should('have.text', this.mycase.name); (this.mycase as TestCase).tags.forEach((CaseTag) => { diff --git a/x-pack/test/security_solution_cypress/cypress/screens/all_cases.ts b/x-pack/test/security_solution_cypress/cypress/screens/all_cases.ts index 8109bc31e07ad..8dbc850fb017c 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/all_cases.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/all_cases.ts @@ -23,9 +23,9 @@ export const ALL_CASES_NOT_PUSHED = '[data-test-subj="case-table-column-external export const ALL_CASES_NUMBER_OF_ALERTS = '[data-test-subj="case-table-column-alertsCount"]'; -export const ALL_CASES_OPEN_CASES_COUNT = '[data-test-subj="case-status-filter"]'; +export const ALL_CASES_STATUS_FILTER = '[data-test-subj="options-filter-popover-button-status"]'; -export const ALL_CASES_OPEN_FILTER = '[data-test-subj="case-status-filter-open"]'; +export const ALL_CASES_OPEN_FILTER = '[data-test-subj="options-filter-popover-item-open"]'; export const ALL_CASES_OPEN_CASES_STATS = '[data-test-subj="openStatsHeader"] .euiDescriptionList__description'; @@ -43,6 +43,6 @@ export const ALL_CASES_TAGS = (tag: string) => { return `[data-test-subj="case-table-column-tags-${tag}"]`; }; -export const ALL_CASES_TAGS_COUNT = '[data-test-subj="options-filter-popover-button-Tags"]'; +export const ALL_CASES_TAGS_COUNT = '[data-test-subj="options-filter-popover-button-tags"]'; export const EDIT_EXTERNAL_CONNECTION = '[data-test-subj="configure-case-button"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts index 65da5e0a7d5fd..d2eec92034125 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts @@ -12,7 +12,7 @@ import type { TestCase, TestCaseWithoutTimeline, } from '../objects/case'; -import { ALL_CASES_OPEN_CASES_COUNT, ALL_CASES_OPEN_FILTER } from '../screens/all_cases'; +import { ALL_CASES_STATUS_FILTER, ALL_CASES_OPEN_FILTER } from '../screens/all_cases'; import { TIMELINE_SEARCHBOX } from '../screens/common/controls'; import { @@ -46,7 +46,7 @@ export const backToCases = () => { }; export const filterStatusOpen = () => { - cy.get(ALL_CASES_OPEN_CASES_COUNT).click(); + cy.get(ALL_CASES_STATUS_FILTER).click(); cy.get(ALL_CASES_OPEN_FILTER).click(); }; diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts index b001adb306a44..f4307aa674c6f 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts @@ -7,7 +7,6 @@ import expect from '@kbn/expect'; import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; -import { SeverityAll } from '@kbn/cases-plugin/common/ui'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default ({ getPageObject, getService }: FtrProviderContext) => { @@ -177,20 +176,21 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // by default filter by all await cases.casesTable.validateCasesTableHasNthRows(5); - // low await cases.casesTable.filterBySeverity(CaseSeverity.LOW); await cases.casesTable.validateCasesTableHasNthRows(2); + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.LOW); - // high await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); await cases.casesTable.validateCasesTableHasNthRows(2); + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); - // critical await cases.casesTable.filterBySeverity(CaseSeverity.CRITICAL); await cases.casesTable.validateCasesTableHasNthRows(1); + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.CRITICAL); - // back to all - await cases.casesTable.filterBySeverity(SeverityAll); await cases.casesTable.validateCasesTableHasNthRows(5); }); }); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts index e672f99780fa8..7fe3789ea64e5 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts @@ -8,7 +8,6 @@ import expect from '@kbn/expect'; import { SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; -import { SeverityAll } from '@kbn/cases-plugin/common/ui'; import { navigateToCasesApp } from '../../../../../shared/lib/cases/helpers'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -183,20 +182,21 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // by default filter by all await cases.casesTable.validateCasesTableHasNthRows(5); - // low await cases.casesTable.filterBySeverity(CaseSeverity.LOW); await cases.casesTable.validateCasesTableHasNthRows(2); + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.LOW); - // high await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); await cases.casesTable.validateCasesTableHasNthRows(2); + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); - // critical await cases.casesTable.filterBySeverity(CaseSeverity.CRITICAL); await cases.casesTable.validateCasesTableHasNthRows(1); + // to uncheck + await cases.casesTable.filterBySeverity(CaseSeverity.CRITICAL); - // back to all - await cases.casesTable.filterBySeverity(SeverityAll); await cases.casesTable.validateCasesTableHasNthRows(5); }); });