From 219cbbdac2de98bdfa9256427b887a0e704a952a Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:41:35 +0100 Subject: [PATCH] [Cases] Custom Fields as Cases Filters (#171176) Meta issue: https://github.com/elastic/kibana/issues/167651 ## Summary https://github.com/elastic/kibana/assets/17549662/b0fc9464-8cac-4547-8a85-aac24cf941e9 ## What this PR does not include (will be done in future PRs) - If the users adds to many filters, the UI will overflow - If the url contains status or severity set and the filter is not active, we need to automatically activate that filter and set the filter option ## useEffect situation We tried to remove some useEffects, specifically in [useFilterConfig](https://github.com/elastic/kibana/pull/171176/files#diff-3e3d844f888b4030bd3f3ead9e71866757a6d9ff7e5d3972afebed9956fcddceR62) and [useSystemFilterConfig](https://github.com/elastic/kibana/pull/171176/files#diff-2696d6c860ec0b34363c060d2638f2b63698f06128d1155f735f34de7cc5b5b3R200) but as they have dependencies that are loaded from API's, there are some use cases, like if a new custom field is added or removed where effects (I think) are a must. We will come back to this issue once we have the feature in main to try to solve this issue --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/cases/common/constants/index.ts | 1 + x-pack/plugins/cases/common/ui/types.ts | 13 +- .../public/common/mock/test_providers.tsx | 4 +- .../all_cases/all_cases_list.test.tsx | 4 + .../components/all_cases/all_cases_list.tsx | 3 + .../all_cases/multi_select_filter.test.tsx | 19 + .../all_cases/multi_select_filter.tsx | 67 +- .../all_cases/severity_filter.test.tsx | 2 +- .../components/all_cases/severity_filter.tsx | 8 +- .../components/all_cases/solution_filter.tsx | 8 +- .../components/all_cases/status_filter.tsx | 30 +- .../more_filters_selectable.tsx | 33 + .../all_cases/table_filter_config/types.ts | 28 + .../use_custom_fields_filter_config.tsx | 119 ++++ .../use_filter_config.test.tsx | 82 +++ .../table_filter_config/use_filter_config.tsx | 174 +++++ .../use_system_filter_config.tsx | 272 ++++++++ .../all_cases/table_filters.test.tsx | 606 +++++++++++++++++- .../components/all_cases/table_filters.tsx | 121 ++-- .../components/all_cases/translations.ts | 9 + .../toggle/configure_toggle_field.ts | 4 + .../components/custom_fields/translations.ts | 14 + .../public/components/custom_fields/types.ts | 7 + .../cases/public/containers/__mocks__/api.ts | 1 + .../cases/public/containers/api.test.tsx | 161 +++-- x-pack/plugins/cases/public/containers/api.ts | 19 +- .../cases/public/containers/constants.ts | 1 + .../cases/public/containers/utils.test.ts | 37 ++ .../plugins/cases/public/containers/utils.ts | 37 ++ 29 files changed, 1682 insertions(+), 202 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/all_cases/table_filter_config/more_filters_selectable.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/table_filter_config/types.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_custom_fields_filter_config.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_system_filter_config.tsx diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 5a540b610135c..02622188a4f56 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -203,6 +203,7 @@ export const LOCAL_STORAGE_KEYS = { casesQueryParams: 'cases.list.queryParams', casesFilterOptions: 'cases.list.filterOptions', casesTableColumns: 'cases.list.tableColumns', + casesTableFiltersConfig: 'cases.list.tableFiltersConfig', }; /** diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index dca2b6c6549d1..f1f2233f7cc57 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -26,6 +26,7 @@ import type { ExternalReferenceAttachment, PersistableStateAttachment, Configuration, + CustomFieldTypes, } from '../types/domain'; import type { CasePatchRequest, @@ -145,7 +146,7 @@ export interface ParsedUrlQueryParams extends Partial { export type LocalStorageQueryParams = Partial>; -export interface FilterOptions { +export interface SystemFilterOptions { search: string; searchFields: string[]; severity: CaseSeverity[]; @@ -156,6 +157,16 @@ export interface FilterOptions { owner: string[]; category: string[]; } + +export interface FilterOptions extends SystemFilterOptions { + customFields: { + [key: string]: { + type: CustomFieldTypes; + options: string[]; + }; + }; +} + export type PartialFilterOptions = Partial; export type SingleCaseMetrics = SingleCaseMetricsResponse; diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 846ae8172b327..0361014cbfb68 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -129,7 +129,7 @@ export interface AppMockRenderer { render: UiRender; coreStart: StartServices; queryClient: QueryClient; - AppWrapper: React.FC<{ children: React.ReactElement }>; + AppWrapper: React.FC<{ children: React.ReactNode }>; getFilesClient: () => ScopedFilesClient; } @@ -176,7 +176,7 @@ export const createAppMockRenderer = ({ const getFilesClient = mockGetFilesClient(); - const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( + const AppWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( ({ eui: euiDarkVars, darkMode: true })}> 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 860d9b2a2055e..6a425b9a803a7 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 @@ -659,6 +659,7 @@ describe('AllCasesListGeneric', () => { assignees: [], owner: ['securitySolution', 'observability'], category: [], + customFields: {}, }, queryParams: DEFAULT_QUERY_PARAMS, }); @@ -686,6 +687,7 @@ describe('AllCasesListGeneric', () => { assignees: [], owner: ['securitySolution'], category: [], + customFields: {}, }, queryParams: DEFAULT_QUERY_PARAMS, }); @@ -709,6 +711,7 @@ describe('AllCasesListGeneric', () => { assignees: [], owner: ['securitySolution', 'observability'], category: [], + customFields: {}, }, queryParams: DEFAULT_QUERY_PARAMS, }); @@ -742,6 +745,7 @@ describe('AllCasesListGeneric', () => { assignees: [], owner: ['securitySolution'], category: [], + customFields: {}, }, queryParams: DEFAULT_QUERY_PARAMS, }); 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 91032668e50f4..6897197c4da6c 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 @@ -32,6 +32,7 @@ import { useIsLoadingCases } from './use_is_loading_cases'; import { useAllCasesState } from './use_all_cases_state'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { useCasesColumnsSelection } from './use_cases_columns_selection'; +import { DEFAULT_FILTER_OPTIONS } from '../../containers/constants'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -65,6 +66,7 @@ export const AllCasesList = React.memo( const hasOwner = !!owner.length; const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses)); const initialFilterOptions = { + ...DEFAULT_FILTER_OPTIONS, ...(!isEmpty(hiddenStatuses) && firstAvailableStatus && { status: [firstAvailableStatus] }), owner: hasOwner ? owner : availableSolutions, }; @@ -210,6 +212,7 @@ export const AllCasesList = React.memo( availableSolutions={hasOwner ? [] : availableSolutions} hiddenStatuses={hiddenStatuses} onCreateCasePressed={onCreateCasePressed} + initialFilterOptions={initialFilterOptions} isSelectorView={isSelectorView} isLoading={isLoadingCurrentUserProfile} currentUserProfile={currentUserProfile} diff --git a/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.test.tsx index 06e2ae8956e9d..5210e3d52e215 100644 --- a/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.test.tsx @@ -161,4 +161,23 @@ describe('multi select filter', () => { await waitForEuiPopoverOpen(); expect(screen.getAllByTestId(TEST_ID).length).toBe(2); }); + + it('should not show the amount of options if hideActiveOptionsNumber is active', () => { + const onChange = jest.fn(); + const props = { + id: 'tags', + buttonLabel: 'Tags', + options: [ + { label: 'tag a', key: 'tag a' }, + { label: 'tag b', key: 'tag b' }, + ], + onChange, + selectedOptionKeys: ['tag b'], + }; + + const { rerender } = render(); + expect(screen.queryByLabelText('1 active filters')).toBeInTheDocument(); + rerender(); + expect(screen.queryByLabelText('1 active filters')).not.toBeInTheDocument(); + }); }); 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 index 607a6ed91f3d1..cbfdb30d122aa 100644 --- 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 @@ -22,8 +22,8 @@ import { import { isEqual } from 'lodash/fp'; import * as i18n from './translations'; -type FilterOption = EuiSelectableOption<{ - key: string; +type FilterOption = EuiSelectableOption<{ + key: K; label: T; }>; @@ -38,12 +38,12 @@ export const mapToMultiSelectOption = (options: T[]) => { }); }; -const fromRawOptionsToEuiSelectableOptions = ( - options: Array>, +const fromRawOptionsToEuiSelectableOptions = ( + options: Array>, selectedOptionKeys: string[] -): Array> => { +): Array> => { return options.map(({ key, label }) => { - const selectableOption: FilterOption = { label, key }; + const selectableOption: FilterOption = { label, key }; if (selectedOptionKeys.includes(key)) { selectableOption.checked = 'on'; } @@ -52,33 +52,32 @@ const fromRawOptionsToEuiSelectableOptions = ( }); }; -const fromEuiSelectableOptionToRawOption = ( - options: Array> +const fromEuiSelectableOptionToRawOption = ( + options: Array> ): string[] => { return options.map((option) => option.key); }; -const getEuiSelectableCheckedOptions = (options: Array>) => - options.filter((option) => option.checked === 'on'); +const getEuiSelectableCheckedOptions = ( + options: Array> +) => options.filter((option) => option.checked === 'on') as Array>; -interface UseFilterParams { +interface UseFilterParams { buttonLabel?: string; + buttonIconType?: string; + hideActiveOptionsNumber?: boolean; id: string; limit?: number; limitReachedMessage?: string; - onChange: ({ - filterId, - selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; - }) => void; - options: Array>; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; + options: Array>; selectedOptionKeys?: string[]; - renderOption?: (option: FilterOption) => React.ReactNode; + renderOption?: (option: FilterOption) => React.ReactNode; } -export const MultiSelectFilter = ({ +export const MultiSelectFilter = ({ buttonLabel, + buttonIconType, + hideActiveOptionsNumber, id, limit, limitReachedMessage, @@ -86,15 +85,13 @@ export const MultiSelectFilter = ({ options: rawOptions, selectedOptionKeys = [], renderOption, -}: UseFilterParams) => { +}: UseFilterParams) => { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const toggleIsPopoverOpen = () => setIsPopoverOpen((prevValue) => !prevValue); + const showActiveOptionsNumber = !hideActiveOptionsNumber; const isInvalid = Boolean(limit && limitReachedMessage && selectedOptionKeys.length >= limit); - const options: Array> = fromRawOptionsToEuiSelectableOptions( - rawOptions, - selectedOptionKeys - ); + const options = fromRawOptionsToEuiSelectableOptions(rawOptions, selectedOptionKeys); useEffect(() => { const newSelectedOptions = selectedOptionKeys.filter((selectedOptionKey) => @@ -108,7 +105,7 @@ export const MultiSelectFilter = ({ } }, [selectedOptionKeys, rawOptions, id, onChange]); - const _onChange = (newOptions: Array>) => { + const _onChange = (newOptions: Array>) => { const newSelectedOptions = getEuiSelectableCheckedOptions(newOptions); if (isInvalid && limit && newSelectedOptions.length >= limit) { return; @@ -126,12 +123,12 @@ export const MultiSelectFilter = ({ button={ 0} - numActiveFilters={selectedOptionKeys.length} + numFilters={showActiveOptionsNumber ? options.length : undefined} + hasActiveFilters={showActiveOptionsNumber ? selectedOptionKeys.length > 0 : undefined} + numActiveFilters={showActiveOptionsNumber ? selectedOptionKeys.length : undefined} aria-label={buttonLabel} > {buttonLabel} @@ -154,10 +151,14 @@ export const MultiSelectFilter = ({ )} - > + > options={options} searchable - searchProps={{ placeholder: buttonLabel, compressed: false }} + searchProps={{ + placeholder: buttonLabel, + compressed: false, + 'data-test-subj': `${id}-search-input`, + }} emptyMessage={i18n.EMPTY_FILTER_MESSAGE} onChange={_onChange} singleSelection={false} 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 8d180aed88c94..6924cbd13f1c7 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,7 +10,7 @@ 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/react'; +import { screen, waitFor } from '@testing-library/react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { SeverityFilter } from './severity_filter'; 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 ee9baa3760d27..650e5215c6aac 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 @@ -15,13 +15,7 @@ import * as i18n from './translations'; interface Props { selectedOptionKeys: CaseSeverity[]; - onChange: ({ - filterId, - selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; - }) => void; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; } const options = mapToMultiSelectOption(Object.keys(severities) as CaseSeverity[]); 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 index 103ddeae18703..6f4eabddc0f8f 100644 --- a/x-pack/plugins/cases/public/components/all_cases/solution_filter.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/solution_filter.tsx @@ -17,13 +17,7 @@ import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { useCasesContext } from '../cases_context/use_cases_context'; interface FilterPopoverProps { - onChange: ({ - filterId, - selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; - }) => void; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; selectedOptionKeys: string[]; availableSolutions: string[]; } 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 b34428b426b9e..cc4b032f96c71 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 @@ -9,9 +9,9 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Status } from '@kbn/cases-components/src/status/status'; import { CaseStatuses } from '../../../common/types/domain'; -import { statuses } from '../status'; + import type { MultiSelectFilterOption } from './multi_select_filter'; -import { MultiSelectFilter, mapToMultiSelectOption } from './multi_select_filter'; +import { MultiSelectFilter } from './multi_select_filter'; import * as i18n from './translations'; interface Props { @@ -19,17 +19,15 @@ interface Props { countInProgressCases: number | null; countOpenCases: number | null; hiddenStatuses?: CaseStatuses[]; - onChange: ({ - filterId, - selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; - }) => void; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; selectedOptionKeys: string[]; } -const caseStatuses = Object.keys(statuses) as CaseStatuses[]; +const caseStatuses = [ + { key: CaseStatuses.open, label: i18n.STATUS_OPEN }, + { key: CaseStatuses['in-progress'], label: i18n.STATUS_IN_PROGRESS }, + { key: CaseStatuses.closed, label: i18n.STATUS_CLOSED }, +]; export const StatusFilterComponent = ({ countClosedCases, @@ -49,13 +47,13 @@ export const StatusFilterComponent = ({ ); const options = useMemo( () => - mapToMultiSelectOption( - [...caseStatuses].filter((status) => !hiddenStatuses.includes(status)) - ), + [...caseStatuses].filter((status) => !hiddenStatuses.includes(status.key)) as Array< + MultiSelectFilterOption + >, [hiddenStatuses] ); - const renderOption = (option: MultiSelectFilterOption) => { - const selectedStatus = option.label; + const renderOption = (option: MultiSelectFilterOption) => { + const selectedStatus = option.key; return ( @@ -68,7 +66,7 @@ export const StatusFilterComponent = ({ ); }; return ( - + >; + activeFilters: string[]; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; +}) => { + return ( + + ); +}; +MoreFiltersSelectable.displayName = 'MoreFiltersSelectable'; diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/types.ts b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/types.ts new file mode 100644 index 0000000000000..368c86d545bff --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FilterOptions } from '../../../../common/ui'; + +export interface FilterConfigState { + key: string; + isActive: boolean; +} + +export type FilterChangeHandler = (params: Partial) => void; + +export interface FilterConfigRenderParams { + filterOptions: FilterOptions; +} + +export interface FilterConfig { + key: string; + label: string; + isActive: boolean; + isAvailable: boolean; + getEmptyOptions: () => Partial; + render: (params: FilterConfigRenderParams) => React.ReactNode; +} diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_custom_fields_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_custom_fields_filter_config.tsx new file mode 100644 index 0000000000000..3193b1a1cc70f --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_custom_fields_filter_config.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect } from 'react'; +import type { CustomFieldTypes } from '../../../../common/types/domain'; +import { builderMap as customFieldsBuilder } from '../../custom_fields/builder'; +import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; +import type { FilterChangeHandler, FilterConfig, FilterConfigRenderParams } from './types'; +import { MultiSelectFilter } from '../multi_select_filter'; + +export const CUSTOM_FIELD_KEY_PREFIX = 'cf_'; + +interface CustomFieldFilterOptionFactoryProps { + buttonLabel: string; + customFieldOptions: Array<{ key: string; label: string }>; + fieldKey: string; + onFilterOptionsChange: FilterChangeHandler; + type: CustomFieldTypes; +} +const customFieldFilterOptionFactory = ({ + buttonLabel, + customFieldOptions, + fieldKey, + onFilterOptionsChange, + type, +}: CustomFieldFilterOptionFactoryProps) => { + return { + key: `${CUSTOM_FIELD_KEY_PREFIX}${fieldKey}`, // this prefix is set in case custom field has the same key as a system field + isActive: false, + isAvailable: true, + label: buttonLabel, + getEmptyOptions: () => { + return { + customFields: { + [fieldKey]: { + type, + options: [], + }, + }, + }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => { + const onCustomFieldChange = ({ + filterId, + selectedOptionKeys, + }: { + filterId: string; + selectedOptionKeys: string[]; + }) => { + onFilterOptionsChange({ + customFields: { + [filterId.replace(CUSTOM_FIELD_KEY_PREFIX, '')]: { + options: selectedOptionKeys, + type, + }, + }, + }); + }; + + return ( + ({ + key: option.key, + label: option.label, + }))} + selectedOptionKeys={filterOptions.customFields[fieldKey]?.options || []} + /> + ); + }, + }; +}; + +export const useCustomFieldsFilterConfig = ({ + isSelectorView, + onFilterOptionsChange, +}: { + isSelectorView: boolean; + onFilterOptionsChange: FilterChangeHandler; +}) => { + const [filterConfig, setFilterConfig] = useState([]); + + const { + data: { customFields }, + } = useGetCaseConfiguration(); + + useEffect(() => { + if (isSelectorView) return; + + const customFieldsFilterConfig: FilterConfig[] = []; + for (const { key: fieldKey, type, label: buttonLabel } of customFields ?? []) { + if (customFieldsBuilder[type]) { + const { filterOptions: customFieldOptions } = customFieldsBuilder[type](); + + if (customFieldOptions) { + customFieldsFilterConfig.push( + customFieldFilterOptionFactory({ + buttonLabel, + customFieldOptions, + fieldKey, + onFilterOptionsChange, + type, + }) + ); + } + } + } + + setFilterConfig(customFieldsFilterConfig); + }, [customFields, isSelectorView, onFilterOptionsChange]); + + return { customFieldsFilterConfig: filterConfig }; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx new file mode 100644 index 0000000000000..cb4de39427463 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx @@ -0,0 +1,82 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; +import type { FilterConfig, FilterConfigRenderParams } from './types'; +import { getCaseConfigure } from '../../../containers/configure/api'; +import { useFilterConfig } from './use_filter_config'; + +jest.mock('../../../containers/configure/api', () => { + const originalModule = jest.requireActual('../../../containers/configure/api'); + return { + ...originalModule, + getCaseConfigure: jest.fn(), + }; +}); + +const getCaseConfigureMock = getCaseConfigure as jest.Mock; + +describe('useFilterConfig', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + localStorage.clear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should remove a selected option if the filter is deleted', async () => { + getCaseConfigureMock.mockReturnValue(() => { + return []; + }); + const onFilterOptionsChange = jest.fn(); + const getEmptyOptions = jest.fn().mockReturnValue({ severity: [] }); + const filters: FilterConfig[] = [ + { + key: 'severity', + label: 'Severity', + isActive: true, + isAvailable: true, + getEmptyOptions, + render: ({ filterOptions }: FilterConfigRenderParams) => null, + }, + { + key: 'tags', + label: 'Tags', + isActive: true, + isAvailable: true, + getEmptyOptions() { + return { tags: ['initialValue'] }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => null, + }, + ]; + + const { rerender } = renderHook(useFilterConfig, { + wrapper: ({ children }) => {children}, + initialProps: { + systemFilterConfig: filters, + onFilterOptionsChange, + isSelectorView: false, + }, + }); + + expect(onFilterOptionsChange).not.toHaveBeenCalled(); + rerender({ systemFilterConfig: [], onFilterOptionsChange, isSelectorView: false }); + expect(getEmptyOptions).toHaveBeenCalledTimes(1); + expect(onFilterOptionsChange).toHaveBeenCalledTimes(1); + expect(onFilterOptionsChange).toHaveBeenCalledWith({ + severity: [], + tags: ['initialValue'], + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx new file mode 100644 index 0000000000000..9c30264b274a4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx @@ -0,0 +1,174 @@ +/* + * 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 { useEffect, useState } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { merge } from 'lodash'; +import type { FilterOptions } from '../../../../common/ui'; +import { LOCAL_STORAGE_KEYS } from '../../../../common/constants'; +import type { FilterConfig, FilterConfigState } from './types'; +import { useCustomFieldsFilterConfig } from './use_custom_fields_filter_config'; +import { useCasesContext } from '../../cases_context/use_cases_context'; + +const mergeSystemAndCustomFieldConfigs = ({ + systemFilterConfig, + customFieldsFilterConfig, +}: { + systemFilterConfig: FilterConfig[]; + customFieldsFilterConfig: FilterConfig[]; +}) => { + const newFilterConfig = new Map( + [...systemFilterConfig, ...customFieldsFilterConfig] + .filter((filter) => filter.isAvailable) + .map((filter) => [filter.key, filter]) + ); + + return newFilterConfig; +}; + +export const useFilterConfig = ({ + isSelectorView, + onFilterOptionsChange, + systemFilterConfig, +}: { + isSelectorView: boolean; + onFilterOptionsChange: (params: Partial) => void; + systemFilterConfig: FilterConfig[]; +}) => { + const { appId } = useCasesContext(); + const { customFieldsFilterConfig } = useCustomFieldsFilterConfig({ + isSelectorView, + onFilterOptionsChange, + }); + const [filterConfigs, setFilterConfigs] = useState>( + () => new Map([...systemFilterConfig].map((filter) => [filter.key, filter])) + ); + /** + * Initially we won't save any order, it will use the default config as it is defined in the system. + * Once the user adds/removes a filter, we start saving the order and the visible state. + */ + const [activeByFilterKey, setActiveByFilterKey] = useLocalStorage( + `${appId}.${LOCAL_STORAGE_KEYS.casesTableFiltersConfig}`, + [] + ); + + /** + * This effect is needed in case a filter (mostly custom field) is removed from the settings + * but the user was filtering by it. + */ + useEffect(() => { + const newFilterConfig = mergeSystemAndCustomFieldConfigs({ + systemFilterConfig, + customFieldsFilterConfig, + }); + + const emptyOptions: Array> = []; + filterConfigs.forEach((filter) => { + if (!newFilterConfig.has(filter.key)) { + emptyOptions.push(filter.getEmptyOptions()); + } + }); + + if (emptyOptions.length > 0) { + const mergedEmptyOptions = merge({}, ...emptyOptions); + onFilterOptionsChange(mergedEmptyOptions); + } + }, [filterConfigs, systemFilterConfig, customFieldsFilterConfig, onFilterOptionsChange]); + + /** + * As custom fields are loaded by fetching an API and they might also be removed + * while using the app, we merge the system and custom fields configs on every time + * they change. + */ + useEffect(() => { + setFilterConfigs( + mergeSystemAndCustomFieldConfigs({ + systemFilterConfig, + customFieldsFilterConfig, + }) + ); + }, [systemFilterConfig, customFieldsFilterConfig]); + + const onChange = ({ selectedOptionKeys }: { filterId: string; selectedOptionKeys: string[] }) => { + const newActiveByFilterKey = [...(activeByFilterKey || [])]; + const deactivatedFilters: string[] = []; + + // for each filter in the current state, this way we keep the order + (activeByFilterKey || []).forEach(({ key, isActive: prevIsActive }) => { + const currentIndex = newActiveByFilterKey.findIndex((filter) => filter.key === key); + if (filterConfigs.has(key)) { + const isActive = selectedOptionKeys.find((optionKey) => optionKey === key); + if (isActive && !prevIsActive) { + // remove/insert to the end with isActive = true + newActiveByFilterKey.splice(currentIndex, 1); + newActiveByFilterKey.push({ key, isActive: true }); + } else if (!isActive && prevIsActive) { + // dont move, just update isActive = false + deactivatedFilters.push(key); + newActiveByFilterKey[currentIndex] = { key, isActive: false }; + } + } else { + // we might have in local storage a key of a field that don't exist anymore + newActiveByFilterKey.splice(currentIndex, 1); + } + }); + + // for each filter in the config + filterConfigs.forEach(({ key: configKey }) => { + // add it if its a new filter + if (!newActiveByFilterKey.find(({ key }) => key === configKey)) { + // first time, the current state is empty, all filters will be added + // isActive = true if the filter is in the selectedOptionKeys + const isActive = selectedOptionKeys.find((optionKey) => optionKey === configKey); + if (!isActive) { + // for system filter that is removed as first action + deactivatedFilters.push(configKey); + } + newActiveByFilterKey.push({ + key: configKey, + isActive: Boolean(isActive), + }); + } + }); + + const emptyOptions = deactivatedFilters + .filter((key) => filterConfigs.has(key)) + .map((key) => (filterConfigs.get(key) as FilterConfig).getEmptyOptions()); + + if (emptyOptions.length > 0) { + const mergedEmptyOptions = merge({}, ...emptyOptions); + onFilterOptionsChange(mergedEmptyOptions); + } + + setActiveByFilterKey(newActiveByFilterKey); + }; + + const filterConfigArray = Array.from(filterConfigs.values()); + const selectableOptions = filterConfigArray + .map(({ key, label }) => ({ + key, + label, + })) + .sort((a, b) => { + if (a.label > b.label) return 1; + if (a.label < b.label) return -1; + return a.key > b.key ? 1 : -1; + }); + const source = + activeByFilterKey && activeByFilterKey.length > 0 ? activeByFilterKey : filterConfigArray; + const activeFilters = source + .filter((filter) => filter.isActive && filterConfigs.has(filter.key)) + .map((filter) => filterConfigs.get(filter.key)) as FilterConfig[]; + const activeFilterKeys = activeFilters.map((filter) => filter.key); + + return { + activeSelectableOptionKeys: activeFilterKeys, + filters: activeFilters, + onFilterConfigChange: onChange, + selectableOptions, + }; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_system_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_system_filter_config.tsx new file mode 100644 index 0000000000000..7d50e591d2252 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_system_filter_config.tsx @@ -0,0 +1,272 @@ +/* + * 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 type { FilterOptions } from '../../../../common/ui'; +import type { CaseStatuses } from '../../../../common/types/domain'; +import { MAX_TAGS_FILTER_LENGTH, MAX_CATEGORY_FILTER_LENGTH } from '../../../../common/constants'; +import { MultiSelectFilter, mapToMultiSelectOption } 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 { AssigneesFilterPopover } from '../assignees_filter'; +import type { CurrentUserProfile } from '../../types'; +import type { AssigneesFilteringSelection } from '../../user_profiles/types'; +import type { FilterChangeHandler, FilterConfig, FilterConfigRenderParams } from './types'; + +interface UseFilterConfigProps { + availableSolutions: string[]; + caseAssignmentAuthorized: boolean; + categories: string[]; + countClosedCases: number | null; + countInProgressCases: number | null; + countOpenCases: number | null; + currentUserProfile: CurrentUserProfile; + handleSelectedAssignees: (newAssignees: AssigneesFilteringSelection[]) => void; + hiddenStatuses?: CaseStatuses[]; + initialFilterOptions: Partial; + isLoading: boolean; + isSelectorView?: boolean; + onFilterOptionsChange: FilterChangeHandler; + selectedAssignees: AssigneesFilteringSelection[]; + tags: string[]; +} + +export const getSystemFilterConfig = ({ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + initialFilterOptions, + isLoading, + isSelectorView, + onFilterOptionsChange, + selectedAssignees, + tags, +}: UseFilterConfigProps): FilterConfig[] => { + const onSystemFilterChange = ({ + filterId, + selectedOptionKeys, + }: { + filterId: string; + selectedOptionKeys: string[]; + }) => { + onFilterOptionsChange({ + [filterId]: selectedOptionKeys, + }); + }; + return [ + { + key: 'severity', + label: i18n.SEVERITY, + isActive: true, + isAvailable: true, + getEmptyOptions: () => { + return { + severity: initialFilterOptions.severity, + }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => ( + + ), + }, + { + key: 'status', + label: i18n.STATUS, + isActive: true, + isAvailable: true, + getEmptyOptions: () => { + return { + status: initialFilterOptions.status, + }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => ( + + ), + }, + { + key: 'assignees', + label: i18n.ASSIGNEES, + isActive: true, + isAvailable: caseAssignmentAuthorized && !isSelectorView, + getEmptyOptions: () => { + return { + assignees: initialFilterOptions.assignees, + }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => { + return ( + + ); + }, + }, + { + key: 'tags', + label: i18n.TAGS, + isActive: true, + isAvailable: true, + getEmptyOptions: () => { + return { + tags: initialFilterOptions.tags, + }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => ( + + ), + }, + { + key: 'category', + label: i18n.CATEGORIES, + isActive: true, + isAvailable: true, + getEmptyOptions: () => { + return { + category: initialFilterOptions.category, + }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => ( + + ), + }, + { + key: 'owner', + label: i18n.SOLUTION, + isActive: true, + isAvailable: availableSolutions.length > 1, + getEmptyOptions: () => { + return { + owner: initialFilterOptions.owner, + }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => ( + + ), + }, + ]; +}; + +export const useSystemFilterConfig = ({ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + initialFilterOptions, + isLoading, + isSelectorView, + onFilterOptionsChange, + selectedAssignees, + tags, +}: UseFilterConfigProps) => { + const [filterConfig, setFilterConfig] = useState(() => + getSystemFilterConfig({ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + initialFilterOptions, + isLoading, + isSelectorView, + onFilterOptionsChange, + selectedAssignees, + tags, + }) + ); + + useEffect(() => { + setFilterConfig( + getSystemFilterConfig({ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + initialFilterOptions, + isLoading, + isSelectorView, + onFilterOptionsChange, + selectedAssignees, + tags, + }) + ); + }, [ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + initialFilterOptions, + isLoading, + isSelectorView, + onFilterOptionsChange, + selectedAssignees, + tags, + ]); + + return { + systemFilterConfig: filterConfig, + }; +}; 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 b2afa0b7b8560..a08e0650e5f2c 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,13 +6,13 @@ */ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor, within } 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/types/domain'; +import { CaseStatuses, CustomFieldTypes } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER } from '../../../common/constants'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; @@ -22,10 +22,21 @@ import { useGetTags } from '../../containers/use_get_tags'; import { useGetCategories } from '../../containers/use_get_categories'; import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; import { userProfiles } from '../../containers/user_profiles/api.mock'; +import { getCaseConfigure } from '../../containers/configure/api'; +import { CUSTOM_FIELD_KEY_PREFIX } from './table_filter_config/use_custom_fields_filter_config'; jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/use_get_categories'); jest.mock('../../containers/user_profiles/use_suggest_user_profiles'); +jest.mock('../../containers/configure/api', () => { + const originalModule = jest.requireActual('../../containers/configure/api'); + return { + ...originalModule, + getCaseConfigure: jest.fn(), + }; +}); + +const getCaseConfigureMock = getCaseConfigure as jest.Mock; const onFilterChanged = jest.fn(); @@ -37,15 +48,52 @@ const props = { filterOptions: DEFAULT_FILTER_OPTIONS, availableSolutions: [], isLoading: false, + initialFilterOptions: DEFAULT_FILTER_OPTIONS, currentUserProfile: undefined, }; describe('CasesTableFilters ', () => { let appMockRender: AppMockRenderer; + // eslint-disable-next-line prefer-object-spread + const originalGetComputedStyle = Object.assign({}, window.getComputedStyle); + + beforeAll(() => { + // The JSDOM implementation is too slow + // Especially for dropdowns that try to position themselves + // perf issue - https://github.com/jsdom/jsdom/issues/3234 + Object.defineProperty(window, 'getComputedStyle', { + value: (el: HTMLElement) => { + /** + * This is based on the jsdom implementation of getComputedStyle + * https://github.com/jsdom/jsdom/blob/9dae17bf0ad09042cfccd82e6a9d06d3a615d9f4/lib/jsdom/browser/Window.js#L779-L820 + * + * It is missing global style parsing and will only return styles applied directly to an element. + * Will not return styles that are global or from emotion + */ + const declaration = new CSSStyleDeclaration(); + const { style } = el; + + Array.prototype.forEach.call(style, (property: string) => { + declaration.setProperty( + property, + style.getPropertyValue(property), + style.getPropertyPriority(property) + ); + }); + + return declaration; + }, + configurable: true, + writable: true, + }); + }); + + afterAll(() => { + Object.defineProperty(window, 'getComputedStyle', originalGetComputedStyle); + }); beforeEach(() => { appMockRender = createAppMockRenderer(); - jest.clearAllMocks(); (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], isLoading: false }); (useGetCategories as jest.Mock).mockReturnValue({ data: ['twix', 'snickers'], @@ -54,6 +102,11 @@ describe('CasesTableFilters ', () => { (useSuggestUserProfiles as jest.Mock).mockReturnValue({ data: userProfiles, isLoading: false }); }); + afterEach(() => { + jest.clearAllMocks(); + window.localStorage.clear(); + }); + it('should render the case status filter dropdown', () => { appMockRender.render(); @@ -137,6 +190,19 @@ describe('CasesTableFilters ', () => { }); }); + it('should show in progress status only when "in p" is searched in the filter', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('options-filter-popover-button-status')); + await waitForEuiPopoverOpen(); + + userEvent.type(screen.getByTestId('status-search-input'), 'in p'); + + const allOptions = screen.getAllByRole('option'); + expect(allOptions).toHaveLength(1); + expect(allOptions[0]).toHaveTextContent('In progress'); + }); + it('should remove assignee from selected assignees when assignee no longer exists', async () => { const overrideProps = { ...props, @@ -201,6 +267,25 @@ describe('CasesTableFilters ', () => { 'hasActiveFilters' ); }); + + it('should reset the filter setting all available solutions when deactivated', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + await waitForEuiPopoverOpen(); + userEvent.click(screen.getByRole('option', { name: 'Solution' })); + + expect(onFilterChanged).toHaveBeenCalledWith({ + ...DEFAULT_FILTER_OPTIONS, + owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER], + }); + }); }); describe('assignees filter', () => { @@ -257,4 +342,519 @@ describe('CasesTableFilters ', () => { expect(onCreateCasePressed).toHaveBeenCalledWith(); }); }); + + describe('toggle type custom field filter', () => { + const customFieldKey = 'toggleKey'; + const uiCustomFieldKey = `${CUSTOM_FIELD_KEY_PREFIX}${customFieldKey}`; + + beforeEach(() => { + const previousState = [{ key: uiCustomFieldKey, isActive: true }]; + localStorage.setItem( + 'testAppId.cases.list.tableFiltersConfig', + JSON.stringify(previousState) + ); + getCaseConfigureMock.mockImplementation(() => { + return { + customFields: [{ type: 'toggle', key: customFieldKey, label: 'Toggle', required: false }], + }; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + }); + + it('should render its options', async () => { + appMockRender.render(); + + userEvent.click(await screen.findByRole('button', { name: 'Toggle' })); + await waitForEuiPopoverOpen(); + + const allOptions = screen.getAllByRole('option'); + expect(allOptions).toHaveLength(2); + expect(allOptions[0]).toHaveTextContent('On'); + expect(allOptions[1]).toHaveTextContent('Off'); + }); + + it('should call onFilterChange when On option changes', async () => { + appMockRender.render(); + + userEvent.click(await screen.findByRole('button', { name: 'Toggle' })); + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByTestId('options-filter-popover-item-on')); + + expect(onFilterChanged).toBeCalledWith({ + ...DEFAULT_FILTER_OPTIONS, + customFields: { + [customFieldKey]: { + type: 'toggle', + options: ['on'], + }, + }, + }); + }); + + it('should call onFilterChange when Off option changes', async () => { + appMockRender.render(); + + userEvent.click(await screen.findByRole('button', { name: 'Toggle' })); + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByTestId('options-filter-popover-item-off')); + + expect(onFilterChanged).toBeCalledWith({ + ...DEFAULT_FILTER_OPTIONS, + customFields: { + [customFieldKey]: { + type: 'toggle', + options: ['off'], + }, + }, + }); + }); + + it('should call onFilterChange when second option is clicked', async () => { + const customProps = { + ...props, + filterOptions: { + ...props.filterOptions, + customFields: { + [customFieldKey]: { + type: CustomFieldTypes.TOGGLE, + options: ['on'], + }, + }, + }, + }; + appMockRender.render(); + + userEvent.click(await screen.findByRole('button', { name: 'Toggle' })); + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByTestId('options-filter-popover-item-off')); + + expect(onFilterChanged).toHaveBeenCalledWith({ + ...DEFAULT_FILTER_OPTIONS, + customFields: { + [customFieldKey]: { + type: 'toggle', + options: ['on', 'off'], + }, + }, + }); + }); + + it('should reset the selected options when a custom field filter is deactivated', async () => { + const previousState = [{ key: uiCustomFieldKey, isActive: true }]; + localStorage.setItem( + 'testAppId.cases.list.tableFiltersConfig', + JSON.stringify(previousState) + ); + const customProps = { + ...props, + filterOptions: { + ...props.filterOptions, + customFields: { + [customFieldKey]: { + type: CustomFieldTypes.TOGGLE, + options: ['on'], + }, + }, + }, + }; + appMockRender.render(); + + userEvent.click(await screen.findByRole('button', { name: 'More' })); + userEvent.click(await screen.findByRole('option', { name: 'Toggle' })); + + expect(onFilterChanged).toHaveBeenCalledWith({ + ...DEFAULT_FILTER_OPTIONS, + customFields: { + [customFieldKey]: { + type: CustomFieldTypes.TOGGLE, + options: [], + }, + }, + }); + }); + }); + + describe('custom filters configuration', () => { + beforeEach(() => { + getCaseConfigureMock.mockImplementation(() => { + return { + customFields: [ + { type: 'toggle', key: 'toggle', label: 'Toggle', required: false }, + { type: 'text', key: 'text', label: 'Text', required: false }, + ], + }; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('shouldnt render the more button when in selector view', async () => { + appMockRender.render(); + expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument(); + }); + + it('should render all options in the popover, including custom fields', async () => { + appMockRender.render(); + + expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'More' })); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(5)); + + expect(screen.getByTestId('options-filter-popover-item-status')).toBeInTheDocument(); + expect( + screen.getByTestId(`options-filter-popover-item-${CUSTOM_FIELD_KEY_PREFIX}toggle`) + ).toBeInTheDocument(); + }); + + it('should not add text type custom fields', async () => { + appMockRender.render(); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + await waitForEuiPopoverOpen(); + + expect(screen.queryByTestId('options-filter-popover-item-text')).not.toBeInTheDocument(); + }); + + it('when a filter gets activated, it should be rendered at the end of the list', async () => { + appMockRender.render(); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(5)); + + expect(screen.queryByRole('button', { name: 'Toggle' })).not.toBeInTheDocument(); + userEvent.click(screen.getByRole('option', { name: 'Toggle' })); + expect(screen.getByRole('button', { name: 'Toggle' })).toBeInTheDocument(); + + const filterBar = screen.getByTestId('cases-table-filters-group'); + const allFilters = within(filterBar).getAllByRole('button'); + const orderedFilterLabels = ['Severity', 'Status', 'Tags', 'Categories', 'Toggle', 'More']; + orderedFilterLabels.forEach((label, index) => { + expect(allFilters[index]).toHaveTextContent(label); + }); + }); + + it('when a filter gets activated, it should be updated in the local storage', async () => { + appMockRender.render(); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(5)); + + userEvent.click(screen.getByRole('option', { name: 'Toggle' })); + const storedFilterState = localStorage.getItem('testAppId.cases.list.tableFiltersConfig'); + expect(storedFilterState).toBeTruthy(); + expect(JSON.parse(storedFilterState!)).toMatchInlineSnapshot(` + Array [ + Object { + "isActive": true, + "key": "severity", + }, + Object { + "isActive": true, + "key": "status", + }, + Object { + "isActive": true, + "key": "tags", + }, + Object { + "isActive": true, + "key": "category", + }, + Object { + "isActive": true, + "key": "cf_toggle", + }, + ] + `); + }); + + it('when a filter gets deactivated, it should be removed from the list', async () => { + appMockRender.render(); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(5)); + + expect(screen.getByRole('button', { name: 'Status' })).toBeInTheDocument(); + userEvent.click(screen.getByRole('option', { name: 'Status' })); + expect(screen.queryByRole('button', { name: 'Status' })).not.toBeInTheDocument(); + + const filterBar = screen.getByTestId('cases-table-filters-group'); + const allFilters = within(filterBar).getAllByRole('button'); + const orderedFilterLabels = ['Severity', 'Tags', 'Categories', 'More']; + orderedFilterLabels.forEach((label, index) => { + expect(allFilters[index]).toHaveTextContent(label); + }); + }); + + it('should reset the selected options when a system field filter is deactivated', async () => { + const customProps = { + ...props, + filterOptions: { + ...props.filterOptions, + status: [CaseStatuses.open], + }, + }; + appMockRender.render(); + + userEvent.click(await screen.findByRole('button', { name: 'More' })); + userEvent.click(await screen.findByRole('option', { name: 'Status' })); + + expect(onFilterChanged).toHaveBeenCalledWith({ + ...DEFAULT_FILTER_OPTIONS, + status: [], + customFields: { + toggle: { + type: CustomFieldTypes.TOGGLE, + options: [], + }, + }, + }); + }); + + it('when a filter gets deactivated, it should be updated in the local storage', async () => { + appMockRender.render(); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(5)); + + userEvent.click(screen.getByRole('option', { name: 'Status' })); + + const storedFilterState = localStorage.getItem('testAppId.cases.list.tableFiltersConfig'); + expect(storedFilterState).toBeTruthy(); + expect(JSON.parse(storedFilterState || '')).toMatchInlineSnapshot(` + Array [ + Object { + "isActive": true, + "key": "severity", + }, + Object { + "isActive": false, + "key": "status", + }, + Object { + "isActive": true, + "key": "tags", + }, + Object { + "isActive": true, + "key": "category", + }, + Object { + "isActive": false, + "key": "cf_toggle", + }, + ] + `); + }); + + it('should recover the stored state from the local storage with the right order', async () => { + const previousState = [ + { key: `${CUSTOM_FIELD_KEY_PREFIX}toggle`, isActive: true }, + { key: 'owner', isActive: false }, + { key: 'category', isActive: false }, + { key: 'tags', isActive: true }, + { key: 'assignee', isActive: false }, + { key: 'status', isActive: false }, + { key: 'severity', isActive: true }, + ]; + localStorage.setItem( + 'testAppId.cases.list.tableFiltersConfig', + JSON.stringify(previousState) + ); + + appMockRender.render(); + + const filterBar = screen.getByTestId('cases-table-filters-group'); + let allFilters: HTMLElement[]; + await waitFor(() => { + allFilters = within(filterBar).getAllByRole('button'); + expect(allFilters).toHaveLength(4); + }); + + const orderedFilterLabels = ['Toggle', 'Tags', 'Severity', 'More']; + orderedFilterLabels.forEach((label, index) => { + expect(allFilters[index]).toHaveTextContent(label); + }); + }); + + it('it should not render a filter that was stored but does not exist anymore', async () => { + const previousState = [ + { key: 'severity', isActive: true }, + { key: 'status', isActive: false }, + { key: 'fakeField', isActive: true }, // does not exist + { key: 'tags', isActive: true }, + { key: 'category', isActive: false }, + { key: 'owner', isActive: false }, + { key: `${CUSTOM_FIELD_KEY_PREFIX}toggle`, isActive: true }, + ]; + localStorage.setItem( + 'testAppId.cases.list.tableFiltersConfig', + JSON.stringify(previousState) + ); + + appMockRender.render(); + + const filterBar = screen.getByTestId('cases-table-filters-group'); + let allFilters: HTMLElement[]; + await waitFor(() => { + allFilters = within(filterBar).getAllByRole('button'); + expect(allFilters).toHaveLength(4); + }); + + const orderedFilterLabels = ['Severity', 'Tags', 'Toggle', 'More']; + orderedFilterLabels.forEach((label, index) => { + expect(allFilters[index]).toHaveTextContent(label); + }); + }); + + it('should sort the labels shown in the popover (on equal label, sort by key)', async () => { + getCaseConfigureMock.mockImplementation(() => { + return { + customFields: [ + { type: 'toggle', key: 'za', label: 'ZToggle', required: false }, + { type: 'toggle', key: 'tc', label: 'Toggle', required: false }, + { type: 'toggle', key: 'ta', label: 'Toggle', required: false }, + { type: 'toggle', key: 'tb', label: 'Toggle', required: false }, + { type: 'toggle', key: 'aa', label: 'AToggle', required: false }, + ], + }; + }); + appMockRender.render(); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + await waitFor(() => { + expect(screen.getAllByRole('option')).toHaveLength(9); + }); + const allOptions = screen.getAllByRole('option'); + const orderedKey = [ + `${CUSTOM_FIELD_KEY_PREFIX}aa`, + 'category', + 'severity', + 'status', + 'tags', + `${CUSTOM_FIELD_KEY_PREFIX}ta`, + `${CUSTOM_FIELD_KEY_PREFIX}tb`, + `${CUSTOM_FIELD_KEY_PREFIX}tc`, + `${CUSTOM_FIELD_KEY_PREFIX}za`, + ]; + orderedKey.forEach((key, index) => { + expect(allOptions[index].getAttribute('data-test-subj')).toBe( + `options-filter-popover-item-${key}` + ); + }); + }); + + it('when a filter is active and isnt last in the list, it should move the filter to last position after deactivating and activating', async () => { + appMockRender.render(); + + const filterBar = screen.getByTestId('cases-table-filters-group'); + let allFilters = within(filterBar).getAllByRole('button'); + let orderedFilterLabels = ['Severity', 'Status', 'Tags', 'Categories', 'More']; + orderedFilterLabels.forEach((label, index) => { + expect(allFilters[index]).toHaveTextContent(label); + }); + + expect(screen.getByRole('button', { name: 'Status' })).toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'More' })); + userEvent.click(await screen.findByRole('option', { name: 'Status' })); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + userEvent.click(await screen.findByRole('option', { name: 'Status' })); + + allFilters = within(filterBar).getAllByRole('button'); + orderedFilterLabels = ['Severity', 'Tags', 'Categories', 'Status', 'More']; + orderedFilterLabels.forEach((label, index) => { + expect(allFilters[index]).toHaveTextContent(label); + }); + }); + + it('should avoid key collisions between custom fields and default fields', async () => { + getCaseConfigureMock.mockImplementation(() => { + return { + customFields: [ + { type: 'toggle', key: 'severity', label: 'Fake Severity', required: false }, + { type: 'toggle', key: 'status', label: 'Fake Status', required: false }, + ], + }; + }); + appMockRender.render(); + + const filterBar = screen.getByTestId('cases-table-filters-group'); + let allFilters: HTMLElement[]; + await waitFor(() => { + allFilters = within(filterBar).getAllByRole('button'); + expect(allFilters).toHaveLength(5); + }); + + const orderedFilterLabels = ['Severity', 'Status', 'Tags', 'Categories', 'More']; + orderedFilterLabels.forEach((label, index) => { + expect(allFilters[index]).toHaveTextContent(label); + }); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + let allOptions: HTMLElement[]; + await waitFor(() => { + allOptions = screen.getAllByRole('option'); + expect(allOptions).toHaveLength(6); + }); + }); + + it('should delete stored filters that dont exist anymore', async () => { + const previousState = [ + { key: 'severity', isActive: true }, + { key: 'status', isActive: false }, + { key: 'fakeField', isActive: true }, // does not exist, should be removed + { key: 'tags', isActive: true }, + { key: 'category', isActive: false }, + { key: 'owner', isActive: false }, // isnt available, should be removed + { key: `${CUSTOM_FIELD_KEY_PREFIX}toggle`, isActive: true }, + ]; + localStorage.setItem( + 'testAppId.cases.list.tableFiltersConfig', + JSON.stringify(previousState) + ); + + appMockRender.render(); + + userEvent.click(screen.getByRole('button', { name: 'More' })); + // we need any user action to trigger the filter config update + userEvent.click(await screen.findByRole('option', { name: 'Toggle' })); + + const storedFilterState = localStorage.getItem('testAppId.cases.list.tableFiltersConfig'); + // the fakeField and owner filter should be removed and toggle should update isActive + expect(JSON.parse(storedFilterState || '')).toMatchInlineSnapshot(` + Array [ + Object { + "isActive": true, + "key": "severity", + }, + Object { + "isActive": false, + "key": "status", + }, + Object { + "isActive": true, + "key": "tags", + }, + Object { + "isActive": false, + "key": "category", + }, + Object { + "isActive": false, + "key": "cf_toggle", + }, + ] + `); + }); + }); }); 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 a7db3c2c68196..0fa7ea1e0c548 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 @@ -8,21 +8,18 @@ import React, { useCallback, useState } from 'react'; import { isEqual } from 'lodash/fp'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; - +import { mergeWith } from 'lodash'; +import { MoreFiltersSelectable } from './table_filter_config/more_filters_selectable'; import type { CaseStatuses } from '../../../common/types/domain'; -import { MAX_TAGS_FILTER_LENGTH, MAX_CATEGORY_FILTER_LENGTH } from '../../../common/constants'; import type { FilterOptions } from '../../containers/types'; -import { MultiSelectFilter, mapToMultiSelectOption } 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 { AssigneesFilterPopover } from './assignees_filter'; import type { CurrentUserProfile } from '../types'; import { useCasesFeatures } from '../../common/use_cases_features'; import type { AssigneesFilteringSelection } from '../user_profiles/types'; +import { useSystemFilterConfig } from './table_filter_config/use_system_filter_config'; +import { useFilterConfig } from './table_filter_config/use_filter_config'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -33,11 +30,18 @@ interface CasesTableFiltersProps { availableSolutions: string[]; isSelectorView?: boolean; onCreateCasePressed?: () => void; + initialFilterOptions: Partial; isLoading: boolean; currentUserProfile: CurrentUserProfile; filterOptions: FilterOptions; } +const mergeCustomizer = (objValue: string | string[], srcValue: string | string[], key: string) => { + if (Array.isArray(objValue)) { + return srcValue; + } +}; + const CasesTableFiltersComponent = ({ countClosedCases, countOpenCases, @@ -47,6 +51,7 @@ const CasesTableFiltersComponent = ({ availableSolutions, isSelectorView = false, onCreateCasePressed, + initialFilterOptions, isLoading, currentUserProfile, filterOptions, @@ -57,23 +62,6 @@ const CasesTableFiltersComponent = ({ const { data: categories = [] } = useGetCategories(); const { caseAssignmentAuthorized } = useCasesFeatures(); - const onChange = ({ - filterId, - selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; - }) => { - const newFilters = { - ...filterOptions, - [filterId]: selectedOptionKeys, - }; - - if (!isEqual(newFilters, filterOptions)) { - onFilterChanged(newFilters); - } - }; - const handleSelectedAssignees = useCallback( (newAssignees: AssigneesFilteringSelection[]) => { if (!isEqual(newAssignees, selectedAssignees)) { @@ -86,6 +74,41 @@ const CasesTableFiltersComponent = ({ [selectedAssignees, onFilterChanged] ); + const onFilterOptionsChange = useCallback( + (partialFilterOptions: Partial) => { + const newFilterOptions = mergeWith({}, filterOptions, partialFilterOptions, mergeCustomizer); + if (!isEqual(newFilterOptions, filterOptions)) { + onFilterChanged(newFilterOptions); + } + }, + [filterOptions, onFilterChanged] + ); + + const { systemFilterConfig } = useSystemFilterConfig({ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + initialFilterOptions, + isLoading, + isSelectorView, + onFilterOptionsChange, + selectedAssignees, + tags, + }); + + const { + filters: activeFilters, + selectableOptions, + activeSelectableOptionKeys, + onFilterConfigChange, + } = useFilterConfig({ systemFilterConfig, onFilterOptionsChange, isSelectorView }); + const handleOnSearch = useCallback( (newSearch) => { const trimSearch = newSearch.trim(); @@ -128,47 +151,15 @@ const CasesTableFiltersComponent = ({ /> - - - - {caseAssignmentAuthorized && !isSelectorView ? ( - - ) : null} - - - {availableSolutions.length > 1 && ( - + {activeFilters.map((filter) => ( + {filter.render({ filterOptions })} + ))} + {isSelectorView || ( + )} diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts index 0d3e14ec72fca..f0c402d097e8d 100644 --- a/x-pack/plugins/cases/public/components/all_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -9,6 +9,11 @@ import { i18n } from '@kbn/i18n'; export * from '../../common/translations'; export * from '../user_profiles/translations'; +export { + OPEN as STATUS_OPEN, + IN_PROGRESS as STATUS_IN_PROGRESS, + CLOSED as STATUS_CLOSED, +} from '@kbn/cases-components/src/status/translations'; export const NO_CASES = i18n.translate('xpack.cases.caseTable.noCases.title', { defaultMessage: 'No cases to display', @@ -216,3 +221,7 @@ export const OPTIONS = (totalCount: number) => defaultMessage: '{totalCount, plural, one {# option} other {# options}}', values: { totalCount }, }); + +export const MORE_FILTERS_LABEL = i18n.translate('xpack.cases.tableFilters.moreFiltersLabel', { + defaultMessage: 'More', +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts index 5076cff23a21a..6b82f423256cb 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts @@ -26,4 +26,8 @@ export const configureToggleCustomFieldFactory: CustomFieldFactory { }>; } +export interface CustomFieldFactoryFilterOption { + key: string; + label: string; + value: boolean | null; +} + export type CustomFieldEuiTableColumn = Pick< EuiTableComputedColumnType, 'name' | 'width' | 'data-test-subj' @@ -45,6 +51,7 @@ export type CustomFieldFactory = () => { label: string; getEuiTableColumn: (params: { label: string }) => CustomFieldEuiTableColumn; build: () => CustomFieldType; + filterOptions?: CustomFieldFactoryFilterOption[]; }; export type CustomFieldBuilderMap = { diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index 1b43b4f590d9e..9d99f658e9da0 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -95,6 +95,7 @@ export const getCases = async ({ tags: [], owner: [], category: [], + customFields: {}, }, queryParams = { page: 1, diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index 2b82f64804bd5..a1005519266e2 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -77,6 +77,7 @@ import { CaseStatuses, ConnectorTypes, AttachmentType, + CustomFieldTypes, } from '../../common/types/domain'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; @@ -212,12 +213,12 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ searchFields: DEFAULT_FILTER_OPTIONS.searchFields, - }, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -234,24 +235,25 @@ describe('Cases API', () => { search: 'hello', owner: [SECURITY_SOLUTION_OWNER], category: [], + customFields: {}, }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ + status: [CaseStatuses.open], + severity: [CaseSeverity.HIGH], assignees: ['123'], reporters: ['username'], tags: ['coke', 'pepsi'], search: 'hello', searchFields: DEFAULT_FILTER_OPTIONS.searchFields, - status: [CaseStatuses.open], - severity: [CaseSeverity.HIGH], owner: [SECURITY_SOLUTION_OWNER], - }, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -266,13 +268,13 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, - searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ severity: [CaseSeverity.HIGH], - }, + searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -287,12 +289,12 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ searchFields: DEFAULT_FILTER_OPTIONS.searchFields, - }, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -307,13 +309,13 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, - searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ status: [CaseStatuses.open], - }, + searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -328,12 +330,12 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ searchFields: DEFAULT_FILTER_OPTIONS.searchFields, - }, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -348,12 +350,12 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ searchFields: DEFAULT_FILTER_OPTIONS.searchFields, - }, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -368,13 +370,13 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, - searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ assignees: 'none', - }, + searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -389,13 +391,13 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, - searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ assignees: ['none', '123'], - }, + searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -417,18 +419,18 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ + status: [CaseStatuses.open], assignees: ['123'], reporters: [], tags: ['(', '"double"'], search: 'hello', searchFields: DEFAULT_FILTER_OPTIONS.searchFields, - status: [CaseStatuses.open], owner: [SECURITY_SOLUTION_OWNER], - }, + ...DEFAULT_QUERY_PARAMS, + }), signal: abortCtrl.signal, }); }); @@ -453,12 +455,49 @@ describe('Cases API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { - method: 'GET', - query: { - ...DEFAULT_QUERY_PARAMS, + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + ...DEFAULT_QUERY_PARAMS, + }), + signal: abortCtrl.signal, + }); + }); + + it('should send custom fields', async () => { + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + customFields: { + activeCustomFieldKey: { + type: CustomFieldTypes.TOGGLE, + options: ['on'], + }, + inactiveCustomFieldKey: { + type: CustomFieldTypes.TOGGLE, + options: ['off'], + }, + emptyCustomFieldKey: { + type: CustomFieldTypes.TOGGLE, + options: [], + }, + }, }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { + method: 'POST', + body: JSON.stringify({ + searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + customFields: { + activeCustomFieldKey: [true], + inactiveCustomFieldKey: [false], + }, + ...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 ad75838ee2a86..c15b41cb458a7 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -54,6 +54,7 @@ import { CASES_URL, INTERNAL_BULK_CREATE_ATTACHMENTS_URL, INTERNAL_GET_CASE_CATEGORIES_URL, + CASES_INTERNAL_URL, } from '../../common/constants'; import { getAllConnectorTypesUrl } from '../../common/utils/connectors_api'; @@ -86,6 +87,7 @@ import { constructAssigneesFilter, constructReportersFilter, decodeCaseUserActionStatsResponse, + constructCustomFieldsFilter, } from './utils'; import { decodeCasesFindResponse } from '../api/decoders'; @@ -259,6 +261,7 @@ export const getCases = async ({ tags: [], owner: [], category: [], + customFields: {}, }, queryParams = { page: 1, @@ -268,7 +271,7 @@ export const getCases = async ({ }, signal, }: FetchCasesProps): Promise => { - const query = { + const body = { ...removeOptionFromFilter({ filterKey: 'status', filterOptions: filterOptions.status, @@ -286,14 +289,18 @@ export const getCases = async ({ ...(filterOptions.searchFields.length > 0 ? { searchFields: filterOptions.searchFields } : {}), ...(filterOptions.owner.length > 0 ? { owner: filterOptions.owner } : {}), ...(filterOptions.category.length > 0 ? { category: filterOptions.category } : {}), + ...constructCustomFieldsFilter(filterOptions.customFields), ...queryParams, }; - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/_find`, { - method: 'GET', - query, - signal, - }); + const response = await KibanaServices.get().http.fetch( + `${CASES_INTERNAL_URL}/_search`, + { + method: 'POST', + body: JSON.stringify(body), + signal, + } + ); return convertAllCasesToCamel(decodeCasesFindResponse(response)); }; diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index 4495d28339c12..224ea2c8bd04c 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -76,6 +76,7 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = { tags: [], owner: [], category: [], + customFields: {}, }; export const DEFAULT_QUERY_PARAMS: QueryParams = { diff --git a/x-pack/plugins/cases/public/containers/utils.test.ts b/x-pack/plugins/cases/public/containers/utils.test.ts index b7a0b17ec5ff0..16dd6f25f3d2f 100644 --- a/x-pack/plugins/cases/public/containers/utils.test.ts +++ b/x-pack/plugins/cases/public/containers/utils.test.ts @@ -11,9 +11,11 @@ import { createUpdateSuccessToaster, constructAssigneesFilter, constructReportersFilter, + constructCustomFieldsFilter, } from './utils'; import type { CaseUI } from './types'; +import { CustomFieldTypes } from '../../common/types/domain'; const caseBeforeUpdate = { comments: [ @@ -186,4 +188,39 @@ describe('utils', () => { ).toEqual({ reporters: ['test', '123'] }); }); }); + + describe('constructCustomFieldsFilter', () => { + it('returns an empty object if the customFields is empty', () => { + expect(constructCustomFieldsFilter({})).toEqual({}); + }); + + it('returns the customFields correctly', () => { + expect( + constructCustomFieldsFilter({ + '957846f4-a792-45a2-bc9a-c028973dfdde': { + type: CustomFieldTypes.TOGGLE, + options: ['on'], + }, + 'dbeb8e9c-240b-4adb-b83e-e645e86c07ed': { + type: CustomFieldTypes.TOGGLE, + options: ['off'], + }, + 'c1f0c0a0-2aaf-11ec-8d3d-0242ac130003': { + type: CustomFieldTypes.TOGGLE, + options: [], + }, + 'e0e8c50a-8d65-4f00-b6f0-d8a131fd34b4': { + type: CustomFieldTypes.TOGGLE, + options: ['on', 'off'], + }, + }) + ).toEqual({ + customFields: { + '957846f4-a792-45a2-bc9a-c028973dfdde': [true], + 'dbeb8e9c-240b-4adb-b83e-e645e86c07ed': [false], + 'e0e8c50a-8d65-4f00-b6f0-d8a131fd34b4': [true, false], + }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts index c5713d8f1fac7..4a224a4580d11 100644 --- a/x-pack/plugins/cases/public/containers/utils.ts +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -11,6 +11,7 @@ import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import type { ToastInputFields } from '@kbn/core/public'; +import { builderMap as customFieldsBuilder } from '../components/custom_fields/builder'; import { AttachmentType, CaseRt, @@ -42,6 +43,7 @@ import { NO_ASSIGNEES_FILTERING_KEYWORD } from '../../common/constants'; import { throwErrors } from '../../common/api'; import type { CaseUI, FilterOptions, UpdateByKey } from './types'; import * as i18n from './translations'; +import type { CustomFieldFactoryFilterOption } from '../components/custom_fields/types'; export const getTypedPayload = (a: unknown): T => a as T; @@ -170,3 +172,38 @@ export const constructReportersFilter = (reporters: User[]) => { } : {}; }; + +export const constructCustomFieldsFilter = ( + optionKeysByCustomFieldKey: FilterOptions['customFields'] +) => { + if (!optionKeysByCustomFieldKey || Object.keys(optionKeysByCustomFieldKey).length === 0) { + return {}; + } + + const valuesByCustomFieldKey: { + [key in string]: Array; + } = {}; + + for (const [customFieldKey, customField] of Object.entries(optionKeysByCustomFieldKey)) { + const { type, options: selectedOptions } = customField; + if (customFieldsBuilder[type]) { + const { filterOptions: customFieldFilterOptionsConfig = [] } = customFieldsBuilder[type](); + const values = selectedOptions + .map((selectedOption) => { + const filterOptionConfig = customFieldFilterOptionsConfig.find( + (filterOption) => filterOption.key === selectedOption + ); + return filterOptionConfig ? filterOptionConfig.value : undefined; + }) + .filter((option) => option !== undefined) as Array; + + if (values.length > 0) { + valuesByCustomFieldKey[customFieldKey] = values; + } + } + } + + return { + customFields: valuesByCustomFieldKey, + }; +};