From cf63dac135ba79bfaf8210ebfc6f39d65b443b27 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Thu, 9 Nov 2023 17:40:28 +0100 Subject: [PATCH 01/32] first commit --- .../components/all_cases/table_filters.tsx | 255 +++++++++++++++--- .../toggle/configure_toggle_field.ts | 1 + .../public/components/custom_fields/types.ts | 1 + 3 files changed, 215 insertions(+), 42 deletions(-) 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 bb96b0dfa4346..0d032cbe6244e 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 @@ -6,9 +6,11 @@ */ import React, { useCallback, useState } from 'react'; -import { isEqual } from 'lodash/fp'; +import { isEqual, difference } from 'lodash/fp'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; +import { builderMap as customFieldsBuilder } from '../custom_fields/builder'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; 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'; @@ -38,6 +40,184 @@ interface CasesTableFiltersProps { filterOptions: FilterOptions; } +const getSystemFilterConfig = ({ + availableSolutions, + caseAssignmentAuthorized, + isSelectorView, +}: { + availableSolutions: string[]; + caseAssignmentAuthorized: boolean; + isSelectorView: boolean; +}) => { + return [ + { + key: 'severity', + isActive: true, + isAvailable: true, + render: ({ filterOptions, onChange }) => ( + + ), + }, + { + key: 'status', + isActive: true, + isAvailable: true, + render: ({ + filterOptions, + onChange, + hiddenStatuses, + countClosedCases, + countInProgressCases, + countOpenCases, + }) => ( + + ), + }, + // { + // key: 'assignee', + // isActive: true, + // isAvailable: caseAssignmentAuthorized && !isSelectorView, + // render: ({ filterOptions, onChange }) => ( + // + // ), + // }, + { + key: 'tags', + isActive: true, + isAvailable: true, + render: ({ filterOptions, onChange, tags }) => ( + + ), + }, + { + key: 'category', + isActive: true, + isAvailable: true, + render: ({ filterOptions, onChange, categories }) => ( + + ), + }, + { + key: 'owner', + isActive: true, + isAvailable: availableSolutions.length > 1, + render: ({ filterOptions, onChange }) => ( + + ), + }, + ].filter((filter) => filter.isAvailable); +}; + +const useCustomFieldsFilterConfig = () => { + const { + data: { customFields }, + } = useGetCaseConfiguration(); + + const customFieldsFilterConfig = []; + for (const { type, label } of customFields ?? []) { + if (customFieldsBuilder[type]) { + customFieldsFilterConfig.push({ + key: label, + isActive: false, + render: () =>
TODO
, + }); + } + } + + return { customFieldsFilterConfig }; +}; + +const useFilterConfig = ({ + availableSolutions, + caseAssignmentAuthorized, + isSelectorView, +}: { + availableSolutions: string[]; + caseAssignmentAuthorized: boolean; + isSelectorView: boolean; +}) => { + const { customFieldsFilterConfig } = useCustomFieldsFilterConfig(); + const [config, setConfig] = useState(() => [ + ...getSystemFilterConfig({ + availableSolutions, + caseAssignmentAuthorized, + isSelectorView, + }), + ...customFieldsFilterConfig, + ]); + + const filterConfigOptions = config.map((filter) => filter.key); + const selectedFilterConfigOptions = config + .filter((filter) => filter.isActive) + .filter(Boolean) + .map((filter) => filter.key); + + const onFilterConfigChange = ({ filterId, options }: { filterId: string; options: string[] }) => { + const addedOption = difference(options, selectedFilterConfigOptions)[0]; + const removedOption = difference(selectedFilterConfigOptions, options)[0]; + + let newConfig; + if (addedOption) { + const addedFilter = config.find((filter) => filter.key === addedOption); + newConfig = config.filter((filter) => filter.key !== addedOption); + newConfig.push({ + ...addedFilter, + isActive: true, + }); + } else if (removedOption) { + newConfig = config.map((filter) => { + if (filter.key === removedOption) { + return { + ...filter, + isActive: false, + }; + } + return filter; + }); + } + setConfig(newConfig); + }; + + return { + config, + filterConfigOptions: filterConfigOptions.sort(), + onFilterConfigChange, + selectedFilterConfigOptions, + }; +}; + const CasesTableFiltersComponent = ({ countClosedCases, countOpenCases, @@ -57,7 +237,18 @@ const CasesTableFiltersComponent = ({ const { data: categories = [] } = useGetCategories(); const { caseAssignmentAuthorized } = useCasesFeatures(); - const onChange = ({ + const { + config: filterConfig, + filterConfigOptions, + onFilterConfigChange, + selectedFilterConfigOptions, + } = useFilterConfig({ + availableSolutions, + caseAssignmentAuthorized, + isSelectorView, + }); + + const onFilterOptionChange = ({ filterId, options, }: { @@ -129,48 +320,28 @@ const CasesTableFiltersComponent = ({ - - - {caseAssignmentAuthorized && !isSelectorView ? ( - - ) : null} - + {filterConfig + .filter((filter) => filter.isActive) + .map((filter) => + filter.render({ + onChange: onFilterOptionChange, + filterOptions, + hiddenStatuses, + countClosedCases, + countInProgressCases, + countOpenCases, + tags, + categories, + }) + )} - {availableSolutions.length > 1 && ( - - )} 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 00f103fcfdd6a..e5fba24f5b5f2 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 @@ -23,4 +23,5 @@ export const configureToggleCustomFieldFactory: CustomFieldFactory = () => { id: string; label: string; build: () => CustomFieldType; + filterOptions?: string[]; }; export type CustomFieldBuilderMap = { From 4b5371f713e1d2f01c4d91a933cf82776f48014f Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Fri, 10 Nov 2023 16:23:52 +0100 Subject: [PATCH 02/32] working filter status --- .../all_cases/multi_select_filter.tsx | 18 +- .../components/all_cases/severity_filter.tsx | 3 +- .../components/all_cases/status_filter.tsx | 3 +- .../components/all_cases/table_filters.tsx | 272 +++--------------- .../all_cases/use_filter_config.tsx | 185 ++++++++++++ .../all_cases/use_system_filter_config.tsx | 227 +++++++++++++++ 6 files changed, 464 insertions(+), 244 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx 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 552753105f1ab..a56c6cc447d5f 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 @@ -20,7 +20,6 @@ import { 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[]) => { @@ -44,29 +43,32 @@ const getEuiSelectableCheckedOptions = (options: EuiSelectableOption[]) => interface UseFilterParams { buttonLabel?: string; - id: keyof FilterOptions; + hideActiveOptionsNumber?: boolean; + id: string; limit?: number; limitReachedMessage?: string; - onChange: ({ filterId, options }: { filterId: keyof FilterOptions; options: string[] }) => void; + onChange: ({ filterId, options }: { filterId: string; options: string[] }) => void; options: string[]; - selectedOptions?: string[]; renderOption?: (option: T) => React.ReactNode; + selectedOptions?: string[]; } export const MultiSelectFilter = ({ buttonLabel, + hideActiveOptionsNumber, id, limit, limitReachedMessage, onChange, options: rawOptions, - selectedOptions = [], renderOption, + selectedOptions = [], }: 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); + const showActiveOptionsNumber = !hideActiveOptionsNumber; useEffect(() => { const trimmedSelectedOptions = selectedOptions.filter((option) => rawOptions.includes(option)); @@ -99,9 +101,9 @@ export const MultiSelectFilter = ({ iconType="arrowDown" onClick={toggleIsPopoverOpen} isSelected={isPopoverOpen} - numFilters={options.length} - hasActiveFilters={selectedOptions.length > 0} - numActiveFilters={selectedOptions.length} + numFilters={showActiveOptionsNumber ? options.length : undefined} // FIXME: add tests to check that the number is not shown + hasActiveFilters={showActiveOptionsNumber ? selectedOptions.length > 0 : undefined} + numActiveFilters={showActiveOptionsNumber ? selectedOptions.length : undefined} aria-label={buttonLabel} > {buttonLabel} 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 f17af3de75686..4141ea7f68ede 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 @@ -8,7 +8,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiHealth } from '@elastic/eui'; import React from 'react'; 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'; @@ -19,7 +18,7 @@ interface SeverityOption { interface Props { selectedOptions: CaseSeverity[]; - onChange: ({ filterId, options }: { filterId: keyof FilterOptions; options: string[] }) => void; + onChange: ({ filterId, options }: { filterId: string; options: string[] }) => void; } const options = Object.keys(severities) as CaseSeverity[]; 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 0c40d601b56b6..dd16f26c0900c 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 @@ -10,7 +10,6 @@ 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 { FilterOptions } from '../../../common/ui/types'; import { MultiSelectFilter } from './multi_select_filter'; import * as i18n from './translations'; @@ -23,7 +22,7 @@ interface Props { countInProgressCases: number | null; countOpenCases: number | null; hiddenStatuses?: CaseStatuses[]; - onChange: ({ filterId, options }: { filterId: keyof FilterOptions; options: string[] }) => void; + onChange: ({ filterId, options }: { filterId: string; options: string[] }) => void; selectedOptions: string[]; } 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 0d032cbe6244e..3d0cde2540770 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 @@ -6,25 +6,18 @@ */ import React, { useCallback, useState } from 'react'; -import { isEqual, difference } from 'lodash/fp'; +import { isEqual } from 'lodash/fp'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; - -import { builderMap as customFieldsBuilder } from '../custom_fields/builder'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; 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 } 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 './use_system_filter_config'; +import { useFilterConfig } from './use_filter_config'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -40,184 +33,6 @@ interface CasesTableFiltersProps { filterOptions: FilterOptions; } -const getSystemFilterConfig = ({ - availableSolutions, - caseAssignmentAuthorized, - isSelectorView, -}: { - availableSolutions: string[]; - caseAssignmentAuthorized: boolean; - isSelectorView: boolean; -}) => { - return [ - { - key: 'severity', - isActive: true, - isAvailable: true, - render: ({ filterOptions, onChange }) => ( - - ), - }, - { - key: 'status', - isActive: true, - isAvailable: true, - render: ({ - filterOptions, - onChange, - hiddenStatuses, - countClosedCases, - countInProgressCases, - countOpenCases, - }) => ( - - ), - }, - // { - // key: 'assignee', - // isActive: true, - // isAvailable: caseAssignmentAuthorized && !isSelectorView, - // render: ({ filterOptions, onChange }) => ( - // - // ), - // }, - { - key: 'tags', - isActive: true, - isAvailable: true, - render: ({ filterOptions, onChange, tags }) => ( - - ), - }, - { - key: 'category', - isActive: true, - isAvailable: true, - render: ({ filterOptions, onChange, categories }) => ( - - ), - }, - { - key: 'owner', - isActive: true, - isAvailable: availableSolutions.length > 1, - render: ({ filterOptions, onChange }) => ( - - ), - }, - ].filter((filter) => filter.isAvailable); -}; - -const useCustomFieldsFilterConfig = () => { - const { - data: { customFields }, - } = useGetCaseConfiguration(); - - const customFieldsFilterConfig = []; - for (const { type, label } of customFields ?? []) { - if (customFieldsBuilder[type]) { - customFieldsFilterConfig.push({ - key: label, - isActive: false, - render: () =>
TODO
, - }); - } - } - - return { customFieldsFilterConfig }; -}; - -const useFilterConfig = ({ - availableSolutions, - caseAssignmentAuthorized, - isSelectorView, -}: { - availableSolutions: string[]; - caseAssignmentAuthorized: boolean; - isSelectorView: boolean; -}) => { - const { customFieldsFilterConfig } = useCustomFieldsFilterConfig(); - const [config, setConfig] = useState(() => [ - ...getSystemFilterConfig({ - availableSolutions, - caseAssignmentAuthorized, - isSelectorView, - }), - ...customFieldsFilterConfig, - ]); - - const filterConfigOptions = config.map((filter) => filter.key); - const selectedFilterConfigOptions = config - .filter((filter) => filter.isActive) - .filter(Boolean) - .map((filter) => filter.key); - - const onFilterConfigChange = ({ filterId, options }: { filterId: string; options: string[] }) => { - const addedOption = difference(options, selectedFilterConfigOptions)[0]; - const removedOption = difference(selectedFilterConfigOptions, options)[0]; - - let newConfig; - if (addedOption) { - const addedFilter = config.find((filter) => filter.key === addedOption); - newConfig = config.filter((filter) => filter.key !== addedOption); - newConfig.push({ - ...addedFilter, - isActive: true, - }); - } else if (removedOption) { - newConfig = config.map((filter) => { - if (filter.key === removedOption) { - return { - ...filter, - isActive: false, - }; - } - return filter; - }); - } - setConfig(newConfig); - }; - - return { - config, - filterConfigOptions: filterConfigOptions.sort(), - onFilterConfigChange, - selectedFilterConfigOptions, - }; -}; - const CasesTableFiltersComponent = ({ countClosedCases, countOpenCases, @@ -237,24 +52,43 @@ const CasesTableFiltersComponent = ({ const { data: categories = [] } = useGetCategories(); const { caseAssignmentAuthorized } = useCasesFeatures(); - const { - config: filterConfig, - filterConfigOptions, - onFilterConfigChange, - selectedFilterConfigOptions, - } = useFilterConfig({ + const handleSelectedAssignees = useCallback( + (newAssignees: AssigneesFilteringSelection[]) => { + if (!isEqual(newAssignees, selectedAssignees)) { + setSelectedAssignees(newAssignees); + onFilterChanged({ + assignees: newAssignees.map((assignee) => assignee?.uid ?? null), + }); + } + }, + [selectedAssignees, onFilterChanged] + ); + + const { systemFilterConfig } = useSystemFilterConfig({ availableSolutions, caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + isLoading, isSelectorView, + selectedAssignees, + tags, }); - const onFilterOptionChange = ({ - filterId, - options, - }: { - filterId: keyof FilterOptions; - options: string[]; - }) => { + const { + config: filterConfig, + moreFiltersSelectableComponent: MoreFiltersSelectable, + filterConfigOptions, + selectedFilterConfigOptions, + onFilterConfigChange, + } = useFilterConfig({ systemFilterConfig }); + + const onFilterOptionChange = ({ filterId, options }: { filterId: string; options: string[] }) => { const newFilters = { ...filterOptions, [filterId]: options, @@ -265,18 +99,6 @@ const CasesTableFiltersComponent = ({ } }; - const handleSelectedAssignees = useCallback( - (newAssignees: AssigneesFilteringSelection[]) => { - if (!isEqual(newAssignees, selectedAssignees)) { - setSelectedAssignees(newAssignees); - onFilterChanged({ - assignees: newAssignees.map((assignee) => assignee?.uid ?? null), - }); - } - }, - [selectedAssignees, onFilterChanged] - ); - const handleOnSearch = useCallback( (newSearch) => { const trimSearch = newSearch.trim(); @@ -322,25 +144,11 @@ const CasesTableFiltersComponent = ({ {filterConfig .filter((filter) => filter.isActive) - .map((filter) => - filter.render({ - onChange: onFilterOptionChange, - filterOptions, - hiddenStatuses, - countClosedCases, - countInProgressCases, - countOpenCases, - tags, - categories, - }) - )} - filter.render({ filterOptions, onChange: onFilterOptionChange }))} + diff --git a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx new file mode 100644 index 0000000000000..c0c70b4381193 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx @@ -0,0 +1,185 @@ +/* + * 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 { difference, differenceWith, intersectionWith, isEqual, unionWith } from 'lodash'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { builderMap as customFieldsBuilder } from '../custom_fields/builder'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { MultiSelectFilter } from './multi_select_filter'; +import type { SystemFilterConfig } from './use_system_filter_config'; + +type CustomFieldFilterConfig = SystemFilterConfig; + +const MoreFiltersSelectable = ({ + filterConfigOptions, + selectedFilterConfigOptions, + onFilterConfigChange, +}: { + filterConfigOptions: string[]; + selectedFilterConfigOptions: string[]; + onFilterConfigChange: ({ filterId, options }: { filterId: string; options: string[] }) => void; +}) => { + return ( + + ); +}; + +MoreFiltersSelectable.displayName = 'MoreFiltersSelectable'; + +const useCustomFieldsFilterConfig = () => { + const [filterConfig, setFilterConfig] = useState([]); // FIXME: type + + const { + data: { customFields }, + } = useGetCaseConfiguration(); + + useEffect(() => { + const customFieldsFilterConfig: CustomFieldFilterConfig[] = []; + for (const { key, type, label } of customFields ?? []) { + if (customFieldsBuilder[type]) { + const customField = customFieldsBuilder[type](); + customFieldsFilterConfig.push({ + key, + isActive: false, + isAvailable: type === CustomFieldTypes.TOGGLE, + label, + render: ({ filterOptions, onChange }) => { + return ( + + ); + }, + }); + } + } + + setFilterConfig(customFieldsFilterConfig); + }, [customFields]); + + return { customFieldsFilterConfig: filterConfig }; +}; +let loop = 0; +export const useFilterConfig = ({ + systemFilterConfig, +}: { + systemFilterConfig: SystemFilterConfig[]; +}) => { + const { customFieldsFilterConfig } = useCustomFieldsFilterConfig(); + const [config, setConfig] = useState(() => [...systemFilterConfig, ...customFieldsFilterConfig]); + + useEffect(() => { + if (loop++ > 100) throw new Error('loop'); + setConfig((prevConfig) => { + const newConfig: SystemFilterConfig[] = []; + for (const prevFilter of prevConfig) { + const systemFilter = systemFilterConfig.find((filter) => filter.key === prevFilter.key); + if (systemFilter) { + newConfig.push({ + ...systemFilter, + isActive: prevFilter.isActive, + }); + } else { + newConfig.push(prevFilter); + } + } + return newConfig; + }); + }, [systemFilterConfig]); + + useEffect(() => { + if (loop++ > 100) throw new Error('loop 2'); + setConfig((prevConfig) => { + const newConfig = []; + for (const prevFilter of prevConfig) { + const customFieldsFilter = customFieldsFilterConfig.find( + (filter) => filter.key === prevFilter.key + ); + if (customFieldsFilter) { + newConfig.push({ + ...customFieldsFilter, + isActive: prevFilter.isActive, + }); + } else { + newConfig.push(prevFilter); + } + } + + for (const customFieldsFilter of customFieldsFilterConfig) { + const prevFilter = prevConfig.find((filter) => filter.key === customFieldsFilter.key); + if (!prevFilter) { + newConfig.push(customFieldsFilter); + } + } + console.log({ newConfig }); + return newConfig; + }); + }, [customFieldsFilterConfig]); + + const filterConfigOptions = config + .filter((filter) => filter.isAvailable) + .map((filter) => filter.label); + + const selectedFilterConfigOptions = config + .filter((filter) => filter.isAvailable && filter.isActive) + .filter(Boolean) + .map((filter) => filter.label); + + const onFilterConfigChange = ({ filterId, options }: { filterId: string; options: string[] }) => { + const addedOption = difference(options, selectedFilterConfigOptions)[0]; + const removedOption = difference(selectedFilterConfigOptions, options)[0]; + + let newConfig: SystemFilterConfig[] = []; + if (addedOption) { + const addedFilter = config.find((filter) => filter.label === addedOption); + newConfig = config.filter((filter) => filter.label !== addedOption); + console.log({ + addedOption: { + ...addedFilter, + isActive: true, + }, + }); + newConfig.push({ + ...addedFilter, + isActive: true, + } as CustomFieldFilterConfig); + } else if (removedOption) { + newConfig = config.map((filter) => { + if (filter.label === removedOption) { + return { + ...filter, + isActive: false, + }; + } + return filter; + }); + } + console.log({ newConfig }); + setConfig(newConfig); + }; + + return { + config, + filterConfigOptions: filterConfigOptions.sort(), + onFilterConfigChange, + selectedFilterConfigOptions, + moreFiltersSelectableComponent: MoreFiltersSelectable, + }; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx new file mode 100644 index 0000000000000..dc6759defc468 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx @@ -0,0 +1,227 @@ +/* + * 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 { CaseStatuses } from '../../../common/types/domain'; +import { MAX_TAGS_FILTER_LENGTH, MAX_CATEGORY_FILTER_LENGTH } from '../../../common/constants'; +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 { AssigneesFilterPopover } from './assignees_filter'; +import type { CurrentUserProfile } from '../types'; +import type { AssigneesFilteringSelection } from '../user_profiles/types'; +import type { FilterOptions } from '../../containers/types'; + +interface UseSystemFilterConfigProps { + availableSolutions: string[]; + caseAssignmentAuthorized: boolean; + categories: string[]; + countClosedCases: number | null; + countInProgressCases: number | null; + countOpenCases: number | null; + currentUserProfile: CurrentUserProfile; + handleSelectedAssignees: (newAssignees: AssigneesFilteringSelection[]) => void; + hiddenStatuses?: CaseStatuses[]; + isLoading: boolean; + isSelectorView?: boolean; + selectedAssignees: AssigneesFilteringSelection[]; + tags: string[]; +} + +export interface SystemFilterConfig { + key: string; + label: string; + isActive: boolean; + isAvailable: boolean; + render: ({ + filterOptions, + onChange, + }: { + filterOptions: FilterOptions; + onChange: ({ filterId, options }: { filterId: string; options: string[] }) => void; + }) => React.ReactNode; +} + +export const getSystemFilterConfig = ({ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + isLoading, + isSelectorView, + selectedAssignees, + tags, +}: UseSystemFilterConfigProps): SystemFilterConfig[] => { + return [ + { + key: 'severity', + label: i18n.SEVERITY, + isActive: true, + isAvailable: true, + render: ({ filterOptions, onChange }) => ( + + ), + }, + { + key: 'status', + label: i18n.STATUS, + isActive: true, + isAvailable: true, + render: ({ filterOptions, onChange }) => ( + + ), + }, + { + key: 'assignee', + label: i18n.ASSIGNEES, + isActive: true, + isAvailable: caseAssignmentAuthorized && !isSelectorView, + render: ({ filterOptions, onChange }) => ( + + ), + }, + { + key: 'tags', + label: i18n.TAGS, + isActive: true, + isAvailable: true, + render: ({ filterOptions, onChange }) => ( + + ), + }, + { + key: 'category', + label: i18n.CATEGORIES, + isActive: true, + isAvailable: true, + render: ({ filterOptions, onChange }) => ( + + ), + }, + { + key: 'owner', + label: i18n.SOLUTION, + isActive: true, + isAvailable: availableSolutions.length > 1, + render: ({ filterOptions, onChange }) => ( + + ), + }, + ].filter((filter) => filter.isAvailable); +}; + +export const useSystemFilterConfig = ({ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + isLoading, + isSelectorView, + selectedAssignees, + tags, +}: UseSystemFilterConfigProps) => { + const [filterConfig, setFilterConfig] = useState(() => + getSystemFilterConfig({ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + isLoading, + isSelectorView, + selectedAssignees, + tags, + }) + ); + + useEffect(() => { + setFilterConfig( + getSystemFilterConfig({ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + isLoading, + isSelectorView, + selectedAssignees, + tags, + }) + ); + }, [ + availableSolutions, + caseAssignmentAuthorized, + categories, + countClosedCases, + countInProgressCases, + countOpenCases, + currentUserProfile, + handleSelectedAssignees, + hiddenStatuses, + isLoading, + isSelectorView, + selectedAssignees, + tags, + ]); + + return { + systemFilterConfig: filterConfig, + }; +}; From 7b86adcd772b07a4e2f846aa3af74922c20d8a66 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Mon, 13 Nov 2023 07:22:37 +0100 Subject: [PATCH 03/32] use maps --- .../components/all_cases/table_filters.tsx | 14 +- .../all_cases/use_filter_config.tsx | 167 +++++++----------- .../all_cases/use_system_filter_config.tsx | 12 +- 3 files changed, 76 insertions(+), 117 deletions(-) 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 3d0cde2540770..c0af080fd3179 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 @@ -84,7 +84,7 @@ const CasesTableFiltersComponent = ({ config: filterConfig, moreFiltersSelectableComponent: MoreFiltersSelectable, filterConfigOptions, - selectedFilterConfigOptions, + activeFilters, onFilterConfigChange, } = useFilterConfig({ systemFilterConfig }); @@ -142,13 +142,13 @@ const CasesTableFiltersComponent = ({ - {filterConfig - .filter((filter) => filter.isActive) - .map((filter) => filter.render({ filterOptions, onChange: onFilterOptionChange }))} + {filterConfig.map((filter) => + filter.render({ filterOptions, onChange: onFilterOptionChange }) + )} diff --git a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx index c0c70b4381193..493fb61aefaff 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx @@ -6,48 +6,44 @@ */ import React, { useState, useEffect } from 'react'; -import { difference, differenceWith, intersectionWith, isEqual, unionWith } from 'lodash'; import { CustomFieldTypes } from '../../../common/types/domain'; import { builderMap as customFieldsBuilder } from '../custom_fields/builder'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { MultiSelectFilter } from './multi_select_filter'; -import type { SystemFilterConfig } from './use_system_filter_config'; - -type CustomFieldFilterConfig = SystemFilterConfig; +import type { FilterConfig } from './use_system_filter_config'; const MoreFiltersSelectable = ({ - filterConfigOptions, - selectedFilterConfigOptions, - onFilterConfigChange, + options, + activeFilters, + onChange, }: { - filterConfigOptions: string[]; - selectedFilterConfigOptions: string[]; - onFilterConfigChange: ({ filterId, options }: { filterId: string; options: string[] }) => void; + options: string[]; + activeFilters: string[]; + onChange: ({ filterId, options }: { filterId: string; options: string[] }) => void; }) => { return ( ); }; - MoreFiltersSelectable.displayName = 'MoreFiltersSelectable'; const useCustomFieldsFilterConfig = () => { - const [filterConfig, setFilterConfig] = useState([]); // FIXME: type + const [filterConfig, setFilterConfig] = useState([]); const { data: { customFields }, } = useGetCaseConfiguration(); useEffect(() => { - const customFieldsFilterConfig: CustomFieldFilterConfig[] = []; + const customFieldsFilterConfig: FilterConfig[] = []; for (const { key, type, label } of customFields ?? []) { if (customFieldsBuilder[type]) { const customField = customFieldsBuilder[type](); @@ -76,110 +72,73 @@ const useCustomFieldsFilterConfig = () => { return { customFieldsFilterConfig: filterConfig }; }; -let loop = 0; -export const useFilterConfig = ({ - systemFilterConfig, -}: { - systemFilterConfig: SystemFilterConfig[]; -}) => { + +export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: FilterConfig[] }) => { const { customFieldsFilterConfig } = useCustomFieldsFilterConfig(); - const [config, setConfig] = useState(() => [...systemFilterConfig, ...customFieldsFilterConfig]); + const [config, setConfig] = useState>(() => { + return new Map( + [...systemFilterConfig, ...customFieldsFilterConfig].map((filter) => [filter.key, filter]) + ); + }); useEffect(() => { - if (loop++ > 100) throw new Error('loop'); setConfig((prevConfig) => { - const newConfig: SystemFilterConfig[] = []; - for (const prevFilter of prevConfig) { - const systemFilter = systemFilterConfig.find((filter) => filter.key === prevFilter.key); - if (systemFilter) { - newConfig.push({ - ...systemFilter, - isActive: prevFilter.isActive, - }); + const _config = new Map(prevConfig); + const updatedConfig = new Map( + [...systemFilterConfig, ...customFieldsFilterConfig].map((filter) => [filter.key, filter]) + ); + + updatedConfig.forEach((filter) => { + if (_config.has(filter.key)) { + const outputFilter = _config.get(filter.key); + if (outputFilter) { + _config.set(filter.key, { ...filter, isActive: outputFilter.isActive }); + } } else { - newConfig.push(prevFilter); + _config.set(filter.key, filter); } - } - return newConfig; - }); - }, [systemFilterConfig]); + }); - useEffect(() => { - if (loop++ > 100) throw new Error('loop 2'); + return _config; + }); + }, [systemFilterConfig, customFieldsFilterConfig]); + + const onFilterConfigChange = ({ + filterId, + options: activatedOptions, + }: { + filterId: string; + options: string[]; + }) => { setConfig((prevConfig) => { - const newConfig = []; - for (const prevFilter of prevConfig) { - const customFieldsFilter = customFieldsFilterConfig.find( - (filter) => filter.key === prevFilter.key - ); - if (customFieldsFilter) { - newConfig.push({ - ...customFieldsFilter, - isActive: prevFilter.isActive, - }); + const _config = new Map(prevConfig); + + prevConfig.forEach((filter) => { + if (activatedOptions.includes(filter.label)) { + if (!filter.isActive) { + // new activated options are inserted at the end of the list + _config.delete(filter.key); + _config.set(filter.key, { ...filter, isActive: true }); + } } else { - newConfig.push(prevFilter); + _config.set(filter.key, { ...filter, isActive: false }); } - } + }); - for (const customFieldsFilter of customFieldsFilterConfig) { - const prevFilter = prevConfig.find((filter) => filter.key === customFieldsFilter.key); - if (!prevFilter) { - newConfig.push(customFieldsFilter); - } - } - console.log({ newConfig }); - return newConfig; + return _config; }); - }, [customFieldsFilterConfig]); - - const filterConfigOptions = config - .filter((filter) => filter.isAvailable) - .map((filter) => filter.label); - - const selectedFilterConfigOptions = config - .filter((filter) => filter.isAvailable && filter.isActive) - .filter(Boolean) - .map((filter) => filter.label); - - const onFilterConfigChange = ({ filterId, options }: { filterId: string; options: string[] }) => { - const addedOption = difference(options, selectedFilterConfigOptions)[0]; - const removedOption = difference(selectedFilterConfigOptions, options)[0]; - - let newConfig: SystemFilterConfig[] = []; - if (addedOption) { - const addedFilter = config.find((filter) => filter.label === addedOption); - newConfig = config.filter((filter) => filter.label !== addedOption); - console.log({ - addedOption: { - ...addedFilter, - isActive: true, - }, - }); - newConfig.push({ - ...addedFilter, - isActive: true, - } as CustomFieldFilterConfig); - } else if (removedOption) { - newConfig = config.map((filter) => { - if (filter.label === removedOption) { - return { - ...filter, - isActive: false, - }; - } - return filter; - }); - } - console.log({ newConfig }); - setConfig(newConfig); }; + const configArray = Array.from(config.values()).filter((filter) => filter.isAvailable); + const filterLabels = configArray.map((filter) => filter.label); + const activeFilters = configArray.filter((filter) => filter.isActive).map((filter) => filter); + const activeFilterLabels = activeFilters.map((filter) => filter.label); + return { - config, - filterConfigOptions: filterConfigOptions.sort(), + config: activeFilters, + filterConfigOptions: filterLabels.sort(), onFilterConfigChange, - selectedFilterConfigOptions, + activeFilters: activeFilterLabels, moreFiltersSelectableComponent: MoreFiltersSelectable, }; }; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx index dc6759defc468..d01a187dfdf67 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx @@ -19,7 +19,7 @@ import type { CurrentUserProfile } from '../types'; import type { AssigneesFilteringSelection } from '../user_profiles/types'; import type { FilterOptions } from '../../containers/types'; -interface UseSystemFilterConfigProps { +interface UseFilterConfigProps { availableSolutions: string[]; caseAssignmentAuthorized: boolean; categories: string[]; @@ -35,7 +35,7 @@ interface UseSystemFilterConfigProps { tags: string[]; } -export interface SystemFilterConfig { +export interface FilterConfig { key: string; label: string; isActive: boolean; @@ -63,7 +63,7 @@ export const getSystemFilterConfig = ({ isSelectorView, selectedAssignees, tags, -}: UseSystemFilterConfigProps): SystemFilterConfig[] => { +}: UseFilterConfigProps): FilterConfig[] => { return [ { key: 'severity', @@ -151,7 +151,7 @@ export const getSystemFilterConfig = ({ /> ), }, - ].filter((filter) => filter.isAvailable); + ]; }; export const useSystemFilterConfig = ({ @@ -168,8 +168,8 @@ export const useSystemFilterConfig = ({ isSelectorView, selectedAssignees, tags, -}: UseSystemFilterConfigProps) => { - const [filterConfig, setFilterConfig] = useState(() => +}: UseFilterConfigProps) => { + const [filterConfig, setFilterConfig] = useState(() => getSystemFilterConfig({ availableSolutions, caseAssignmentAuthorized, From c9c9555d5e06ef18c3b3b6c2e34c04004e04b678 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 14 Nov 2023 12:53:57 +0100 Subject: [PATCH 04/32] refactor to use option keys --- .../all_cases/multi_select_filter.tsx | 12 ++---- .../components/all_cases/severity_filter.tsx | 8 +--- .../components/all_cases/solution_filter.tsx | 8 +--- .../components/all_cases/status_filter.tsx | 8 +--- .../components/all_cases/table_filters.tsx | 10 ++++- .../all_cases/use_filter_config.tsx | 41 +++++++++++++------ .../all_cases/use_system_filter_config.tsx | 18 ++++---- 7 files changed, 51 insertions(+), 54 deletions(-) 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 669c78cb27209..74c5b76f96311 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 @@ -67,13 +67,7 @@ interface UseFilterParams { id: string; limit?: number; limitReachedMessage?: string; - onChange: ({ - filterId, - selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; - }) => void; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; options: Array>; selectedOptionKeys?: string[]; renderOption?: (option: FilterOption) => React.ReactNode; @@ -92,12 +86,12 @@ export const MultiSelectFilter = ({ 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 showActiveOptionsNumber = !hideActiveOptionsNumber; useEffect(() => { const newSelectedOptions = selectedOptionKeys.filter((selectedOptionKey) => @@ -132,7 +126,7 @@ export const MultiSelectFilter = ({ iconType="arrowDown" onClick={toggleIsPopoverOpen} isSelected={isPopoverOpen} - numFilters={showActiveOptionsNumber ? options.length : undefined} // FIXME: add tests to check that the number is not shown + numFilters={showActiveOptionsNumber ? options.length : undefined} // FIXME: add tests hasActiveFilters={showActiveOptionsNumber ? selectedOptionKeys.length > 0 : undefined} numActiveFilters={showActiveOptionsNumber ? selectedOptionKeys.length : undefined} aria-label={buttonLabel} 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..b6ce440e81c15 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 @@ -19,13 +19,7 @@ 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[]; } 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 9038634cfd0ee..c9d5adb4e2b57 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 @@ -108,10 +108,16 @@ const CasesTableFiltersComponent = ({ onFilterConfigChange, } = useFilterConfig({ systemFilterConfig }); - const onFilterOptionChange = ({ filterId, options }: { filterId: string; options: string[] }) => { + const onFilterOptionChange = ({ + filterId, + selectedOptionKeys, + }: { + filterId: string; + selectedOptionKeys: string[]; + }) => { const newFilters = { ...filterOptions, - [filterId]: options, + [filterId]: selectedOptionKeys, }; if (!isEqual(newFilters, filterOptions)) { diff --git a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx index 493fb61aefaff..bfe2e914f38c2 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx @@ -9,7 +9,8 @@ import React, { useState, useEffect } from 'react'; import { CustomFieldTypes } from '../../../common/types/domain'; import { builderMap as customFieldsBuilder } from '../custom_fields/builder'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; -import { MultiSelectFilter } from './multi_select_filter'; +import type { MultiSelectFilterOption } from './multi_select_filter'; +import { MultiSelectFilter, mapToMultiSelectOption } from './multi_select_filter'; import type { FilterConfig } from './use_system_filter_config'; const MoreFiltersSelectable = ({ @@ -17,16 +18,16 @@ const MoreFiltersSelectable = ({ activeFilters, onChange, }: { - options: string[]; + options: Array>; activeFilters: string[]; - onChange: ({ filterId, options }: { filterId: string; options: string[] }) => void; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; }) => { return ( { buttonLabel={label} id={key} onChange={onChange} - options={customField.filterOptions || []} - selectedOptions={filterOptions[key]} + options={mapToMultiSelectOption(customField.filterOptions || [])} + selectedOptionKeys={filterOptions[key]} /> ); }, @@ -99,22 +100,28 @@ export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: Fi } }); + _config.forEach((filter) => { + if (!updatedConfig.has(filter.key)) { + _config.delete(filter.key); + } + }); + return _config; }); }, [systemFilterConfig, customFieldsFilterConfig]); const onFilterConfigChange = ({ filterId, - options: activatedOptions, + selectedOptionKeys, }: { filterId: string; - options: string[]; + selectedOptionKeys: string[]; }) => { setConfig((prevConfig) => { const _config = new Map(prevConfig); prevConfig.forEach((filter) => { - if (activatedOptions.includes(filter.label)) { + if (selectedOptionKeys.includes(filter.key)) { if (!filter.isActive) { // new activated options are inserted at the end of the list _config.delete(filter.key); @@ -130,15 +137,23 @@ export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: Fi }; const configArray = Array.from(config.values()).filter((filter) => filter.isAvailable); - const filterLabels = configArray.map((filter) => filter.label); + const filterSelectableOptions = configArray + .map(({ key, label }) => ({ + key, + label, + })) + .sort((a, b) => { + const compare = a.label.localeCompare(b.label); + return compare === 0 ? a.key.localeCompare(b.key) : compare; + }); const activeFilters = configArray.filter((filter) => filter.isActive).map((filter) => filter); - const activeFilterLabels = activeFilters.map((filter) => filter.label); + const activeFilterKeys = activeFilters.map((filter) => filter.key); return { config: activeFilters, - filterConfigOptions: filterLabels.sort(), + filterConfigOptions: filterSelectableOptions, onFilterConfigChange, - activeFilters: activeFilterLabels, + activeFilters: activeFilterKeys, moreFiltersSelectableComponent: MoreFiltersSelectable, }; }; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx index d01a187dfdf67..80599b8c433aa 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx @@ -9,7 +9,7 @@ import React, { useState, useEffect } from 'react'; import type { CaseStatuses } from '../../../common/types/domain'; import { MAX_TAGS_FILTER_LENGTH, MAX_CATEGORY_FILTER_LENGTH } from '../../../common/constants'; -import { MultiSelectFilter } from './multi_select_filter'; +import { MultiSelectFilter, mapToMultiSelectOption } from './multi_select_filter'; import { SolutionFilter } from './solution_filter'; import { StatusFilter } from './status_filter'; import * as i18n from './translations'; @@ -45,7 +45,7 @@ export interface FilterConfig { onChange, }: { filterOptions: FilterOptions; - onChange: ({ filterId, options }: { filterId: string; options: string[] }) => void; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; }) => React.ReactNode; } @@ -71,7 +71,7 @@ export const getSystemFilterConfig = ({ isActive: true, isAvailable: true, render: ({ filterOptions, onChange }) => ( - + ), }, { @@ -81,7 +81,7 @@ export const getSystemFilterConfig = ({ isAvailable: true, render: ({ filterOptions, onChange }) => ( ), }, @@ -133,8 +133,8 @@ export const getSystemFilterConfig = ({ limit={MAX_CATEGORY_FILTER_LENGTH} limitReachedMessage={i18n.MAX_SELECTED_FILTER(MAX_CATEGORY_FILTER_LENGTH, 'categories')} onChange={onChange} - options={categories} - selectedOptions={filterOptions?.category} + options={mapToMultiSelectOption(categories)} + selectedOptionKeys={filterOptions?.category} /> ), }, @@ -146,7 +146,7 @@ export const getSystemFilterConfig = ({ render: ({ filterOptions, onChange }) => ( ), From 252e0b11676e68ad9b71e6d2f01caf439b9f2d31 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 14 Nov 2023 12:37:41 +0000 Subject: [PATCH 05/32] [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' --- .../cases/public/components/all_cases/table_filters.tsx | 3 --- 1 file changed, 3 deletions(-) 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 c9d5adb4e2b57..9aa19923b061c 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 @@ -10,9 +10,6 @@ import { isEqual } from 'lodash/fp'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; import type { CaseStatuses } from '../../../common/types/domain'; 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 { useGetTags } from '../../containers/use_get_tags'; import { useGetCategories } from '../../containers/use_get_categories'; From 3a48a2f794f6eff977d29d24f23642e11d01cbe0 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 14 Nov 2023 17:03:13 +0100 Subject: [PATCH 06/32] minor changes --- .../components/all_cases/multi_select_filter.tsx | 4 +++- .../public/components/all_cases/table_filters.tsx | 3 --- .../public/components/all_cases/translations.ts | 4 ++++ .../components/all_cases/use_filter_config.tsx | 15 ++++++++------- 4 files changed, 15 insertions(+), 11 deletions(-) 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 74c5b76f96311..e84190ae6f71f 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 @@ -63,6 +63,7 @@ const getEuiSelectableCheckedOptions = (options: Array { buttonLabel?: string; + buttonIconType?: string; hideActiveOptionsNumber?: boolean; id: string; limit?: number; @@ -74,6 +75,7 @@ interface UseFilterParams { } export const MultiSelectFilter = ({ buttonLabel, + buttonIconType, hideActiveOptionsNumber, id, limit, @@ -123,7 +125,7 @@ export const MultiSelectFilter = ({ button={ 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/all_cases/use_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx index bfe2e914f38c2..2493dcb79d819 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx @@ -12,6 +12,7 @@ import { useGetCaseConfiguration } from '../../containers/configure/use_get_case import type { MultiSelectFilterOption } from './multi_select_filter'; import { MultiSelectFilter, mapToMultiSelectOption } from './multi_select_filter'; import type { FilterConfig } from './use_system_filter_config'; +import { MORE_FILTERS_LABEL } from './translations'; const MoreFiltersSelectable = ({ options, @@ -24,13 +25,13 @@ const MoreFiltersSelectable = ({ }) => { return ( ); }; @@ -111,7 +112,6 @@ export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: Fi }, [systemFilterConfig, customFieldsFilterConfig]); const onFilterConfigChange = ({ - filterId, selectedOptionKeys, }: { filterId: string; @@ -143,8 +143,9 @@ export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: Fi label, })) .sort((a, b) => { - const compare = a.label.localeCompare(b.label); - return compare === 0 ? a.key.localeCompare(b.key) : compare; + if (a.label > b.label) return 1; + if (a.label < b.label) return -1; + return a.key > b.key ? 1 : -1; }); const activeFilters = configArray.filter((filter) => filter.isActive).map((filter) => filter); const activeFilterKeys = activeFilters.map((filter) => filter.key); From ca2c4a4f6879038a1e032554d00701ef06f9a192 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 15 Nov 2023 21:07:55 +0100 Subject: [PATCH 07/32] add persistence --- .../components/all_cases/table_filters.tsx | 12 +- .../all_cases/use_filter_config.tsx | 121 +++++++++--------- .../all_cases/use_system_filter_config.tsx | 5 + 3 files changed, 75 insertions(+), 63 deletions(-) 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 9aa19923b061c..028bb325d6be6 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 @@ -98,10 +98,10 @@ const CasesTableFiltersComponent = ({ }); const { - config: filterConfig, + filters: activeFilters, moreFiltersSelectableComponent: MoreFiltersSelectable, - filterConfigOptions, - activeFilters, + selectableOptions, + activeSelectableOptionKeys, onFilterConfigChange, } = useFilterConfig({ systemFilterConfig }); @@ -165,12 +165,12 @@ const CasesTableFiltersComponent = ({ - {filterConfig.map((filter) => + {activeFilters.map((filter) => filter.render({ filterOptions, onChange: onFilterOptionChange }) )} diff --git a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx index 2493dcb79d819..2ddb1e1333d64 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx @@ -6,12 +6,13 @@ */ import React, { useState, useEffect } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { CustomFieldTypes } from '../../../common/types/domain'; import { builderMap as customFieldsBuilder } from '../custom_fields/builder'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import type { MultiSelectFilterOption } from './multi_select_filter'; import { MultiSelectFilter, mapToMultiSelectOption } from './multi_select_filter'; -import type { FilterConfig } from './use_system_filter_config'; +import type { FilterConfig, FilterConfigState } from './use_system_filter_config'; import { MORE_FILTERS_LABEL } from './translations'; const MoreFiltersSelectable = ({ @@ -77,67 +78,70 @@ const useCustomFieldsFilterConfig = () => { export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: FilterConfig[] }) => { const { customFieldsFilterConfig } = useCustomFieldsFilterConfig(); - const [config, setConfig] = useState>(() => { - return new Map( - [...systemFilterConfig, ...customFieldsFilterConfig].map((filter) => [filter.key, filter]) - ); - }); + const [config, setConfig] = useState>( + () => new Map([...systemFilterConfig].map((filter) => [filter.key, filter])) + ); + const [storedOptions, setStoredOptions] = useLocalStorage>( + 'filters', + new Map(), + { + raw: false, + serializer: (value) => { + return JSON.stringify( + Array.from(value.entries()).map(([key, filter]) => ({ + key, + isActive: filter.isActive, + })) + ); + }, + deserializer: (value) => { + return new Map( + JSON.parse(value).map(({ key, isActive }: { key: string; isActive: boolean }) => [ + key, + { key, isActive }, + ]) + ); + }, + } + ); useEffect(() => { - setConfig((prevConfig) => { - const _config = new Map(prevConfig); - const updatedConfig = new Map( - [...systemFilterConfig, ...customFieldsFilterConfig].map((filter) => [filter.key, filter]) - ); - - updatedConfig.forEach((filter) => { - if (_config.has(filter.key)) { - const outputFilter = _config.get(filter.key); - if (outputFilter) { - _config.set(filter.key, { ...filter, isActive: outputFilter.isActive }); - } - } else { - _config.set(filter.key, filter); - } - }); - - _config.forEach((filter) => { - if (!updatedConfig.has(filter.key)) { - _config.delete(filter.key); - } - }); - - return _config; - }); + const newConfig = new Map( + [...systemFilterConfig, ...customFieldsFilterConfig] + .filter((filter) => filter.isAvailable) + .map((filter) => [filter.key, filter]) + ); + setConfig(newConfig); }, [systemFilterConfig, customFieldsFilterConfig]); - const onFilterConfigChange = ({ - selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; - }) => { - setConfig((prevConfig) => { - const _config = new Map(prevConfig); + const onChange = ({ selectedOptionKeys }: { filterId: string; selectedOptionKeys: string[] }) => { + const _storedOptions = new Map(storedOptions); - prevConfig.forEach((filter) => { - if (selectedOptionKeys.includes(filter.key)) { - if (!filter.isActive) { - // new activated options are inserted at the end of the list - _config.delete(filter.key); - _config.set(filter.key, { ...filter, isActive: true }); - } - } else { - _config.set(filter.key, { ...filter, isActive: false }); + _storedOptions.forEach(({ key, isActive }) => { + if (selectedOptionKeys.includes(key)) { + if (!isActive) { + _storedOptions.delete(key); + _storedOptions.set(key, { key, isActive: true }); } - }); + } else { + _storedOptions.set(key, { key, isActive: false }); + } + }); - return _config; + config.forEach(({ key, isActive }) => { + if (!_storedOptions.has(key)) { + _storedOptions.set(key, { + key, + isActive: selectedOptionKeys.includes(key), + }); + } }); + + setStoredOptions(_storedOptions); }; - const configArray = Array.from(config.values()).filter((filter) => filter.isAvailable); - const filterSelectableOptions = configArray + const availableConfigs = Array.from(config.values()).filter((filter) => filter.isAvailable); + const selectableOptions = availableConfigs .map(({ key, label }) => ({ key, label, @@ -147,14 +151,17 @@ export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: Fi if (a.label < b.label) return -1; return a.key > b.key ? 1 : -1; }); - const activeFilters = configArray.filter((filter) => filter.isActive).map((filter) => filter); + const source = storedOptions && storedOptions.size > 0 ? storedOptions : config; + const activeFilters = Array.from(source.values()) + .filter((filter) => filter.isActive && config.has(filter.key)) + .map((filter) => config.get(filter.key)) as FilterConfig[]; const activeFilterKeys = activeFilters.map((filter) => filter.key); return { - config: activeFilters, - filterConfigOptions: filterSelectableOptions, - onFilterConfigChange, - activeFilters: activeFilterKeys, + activeSelectableOptionKeys: activeFilterKeys, + filters: activeFilters, moreFiltersSelectableComponent: MoreFiltersSelectable, + onFilterConfigChange: onChange, + selectableOptions, }; }; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx index 80599b8c433aa..2706476f2ba69 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx @@ -35,6 +35,11 @@ interface UseFilterConfigProps { tags: string[]; } +export interface FilterConfigState { + key: string; + isActive: boolean; +} + export interface FilterConfig { key: string; label: string; From 1f5df78fdd021815e4861f41a32271e235077516 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 15 Nov 2023 21:26:35 +0100 Subject: [PATCH 08/32] renaming --- .../all_cases/use_filter_config.tsx | 77 +++++++++---------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx index 2ddb1e1333d64..aef1ab3606fa9 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx @@ -78,69 +78,67 @@ const useCustomFieldsFilterConfig = () => { export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: FilterConfig[] }) => { const { customFieldsFilterConfig } = useCustomFieldsFilterConfig(); - const [config, setConfig] = useState>( + const [filters, setFilters] = useState>( () => new Map([...systemFilterConfig].map((filter) => [filter.key, filter])) ); - const [storedOptions, setStoredOptions] = useLocalStorage>( - 'filters', - new Map(), - { - raw: false, - serializer: (value) => { - return JSON.stringify( - Array.from(value.entries()).map(([key, filter]) => ({ - key, - isActive: filter.isActive, - })) - ); - }, - deserializer: (value) => { - return new Map( - JSON.parse(value).map(({ key, isActive }: { key: string; isActive: boolean }) => [ - key, - { key, isActive }, - ]) - ); - }, - } - ); + const [filterVisibilityMap, setFilterVisibilityMap] = useLocalStorage< + Map + >('filters', new Map(), { + raw: false, + serializer: (value) => { + return JSON.stringify( + Array.from(value.entries()).map(([key, filter]) => ({ + key, + isActive: filter.isActive, + })) + ); + }, + deserializer: (value) => { + return new Map( + JSON.parse(value).map(({ key, isActive }: { key: string; isActive: boolean }) => [ + key, + { key, isActive }, + ]) + ); + }, + }); useEffect(() => { - const newConfig = new Map( + const newFilters = new Map( [...systemFilterConfig, ...customFieldsFilterConfig] .filter((filter) => filter.isAvailable) .map((filter) => [filter.key, filter]) ); - setConfig(newConfig); + setFilters(newFilters); }, [systemFilterConfig, customFieldsFilterConfig]); const onChange = ({ selectedOptionKeys }: { filterId: string; selectedOptionKeys: string[] }) => { - const _storedOptions = new Map(storedOptions); + const newFilterVisibilityMap = new Map(filterVisibilityMap); - _storedOptions.forEach(({ key, isActive }) => { + newFilterVisibilityMap.forEach(({ key, isActive }) => { if (selectedOptionKeys.includes(key)) { if (!isActive) { - _storedOptions.delete(key); - _storedOptions.set(key, { key, isActive: true }); + newFilterVisibilityMap.delete(key); + newFilterVisibilityMap.set(key, { key, isActive: true }); } } else { - _storedOptions.set(key, { key, isActive: false }); + newFilterVisibilityMap.set(key, { key, isActive: false }); } }); - config.forEach(({ key, isActive }) => { - if (!_storedOptions.has(key)) { - _storedOptions.set(key, { + filters.forEach(({ key, isActive }) => { + if (!newFilterVisibilityMap.has(key)) { + newFilterVisibilityMap.set(key, { key, isActive: selectedOptionKeys.includes(key), }); } }); - setStoredOptions(_storedOptions); + setFilterVisibilityMap(newFilterVisibilityMap); }; - const availableConfigs = Array.from(config.values()).filter((filter) => filter.isAvailable); + const availableConfigs = Array.from(filters.values()).filter((filter) => filter.isAvailable); const selectableOptions = availableConfigs .map(({ key, label }) => ({ key, @@ -151,10 +149,11 @@ export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: Fi if (a.label < b.label) return -1; return a.key > b.key ? 1 : -1; }); - const source = storedOptions && storedOptions.size > 0 ? storedOptions : config; + const source = + filterVisibilityMap && filterVisibilityMap.size > 0 ? filterVisibilityMap : filters; const activeFilters = Array.from(source.values()) - .filter((filter) => filter.isActive && config.has(filter.key)) - .map((filter) => config.get(filter.key)) as FilterConfig[]; + .filter((filter) => filter.isActive && filters.has(filter.key)) + .map((filter) => filters.get(filter.key)) as FilterConfig[]; const activeFilterKeys = activeFilters.map((filter) => filter.key); return { From d84f402bbdcfc51df1cae60dc718129e56b093d1 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Thu, 16 Nov 2023 16:59:39 +0100 Subject: [PATCH 09/32] first tests --- .../more_filters_selectable.tsx | 33 ++ .../all_cases/table_filter_config/types.ts | 27 ++ .../use_custom_fields_filter_config.tsx | 53 +++ .../use_filter_config.tsx | 99 ++---- .../use_system_filter_config.tsx | 41 +-- .../all_cases/table_filters.test.tsx | 313 +++++++++++++++++- .../components/all_cases/table_filters.tsx | 31 +- 7 files changed, 462 insertions(+), 135 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 rename x-pack/plugins/cases/public/components/all_cases/{ => table_filter_config}/use_filter_config.tsx (51%) rename x-pack/plugins/cases/public/components/all_cases/{ => table_filter_config}/use_system_filter_config.tsx (84%) diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/more_filters_selectable.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/more_filters_selectable.tsx new file mode 100644 index 0000000000000..e89ff2af07242 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/more_filters_selectable.tsx @@ -0,0 +1,33 @@ +/* + * 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 type { MultiSelectFilterOption } from '../multi_select_filter'; +import { MultiSelectFilter } from '../multi_select_filter'; +import { MORE_FILTERS_LABEL } from '../translations'; + +export const MoreFiltersSelectable = ({ + options, + activeFilters, + onChange, +}: { + options: Array>; + 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..af0b612f4aba1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/types.ts @@ -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. + */ + +import type { FilterOptions } from '../../../../common/ui'; + +export interface FilterConfigState { + key: string; + isActive: boolean; +} + +export interface FilterConfig { + key: string; + label: string; + isActive: boolean; + isAvailable: boolean; + render: ({ + filterOptions, + onChange, + }: { + filterOptions: FilterOptions; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; + }) => 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..f364da296bf7a --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_custom_fields_filter_config.tsx @@ -0,0 +1,53 @@ +/* + * 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 { CustomFieldTypes } from '../../../../common/types/domain'; +import { builderMap as customFieldsBuilder } from '../../custom_fields/builder'; +import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; +import type { FilterConfig } from './types'; +import { MultiSelectFilter, mapToMultiSelectOption } from '../multi_select_filter'; + +export const useCustomFieldsFilterConfig = () => { + const [filterConfig, setFilterConfig] = useState([]); + + const { + data: { customFields }, + } = useGetCaseConfiguration(); + + useEffect(() => { + console.log({ customFields }); + const customFieldsFilterConfig: FilterConfig[] = []; + for (const { key, type, label } of customFields ?? []) { + if (customFieldsBuilder[type]) { + const customField = customFieldsBuilder[type](); + + customFieldsFilterConfig.push({ + key, + isActive: false, + isAvailable: type === CustomFieldTypes.TOGGLE, + label, + render: ({ filterOptions, onChange }) => { + return ( + + ); + }, + }); + } + } + + setFilterConfig(customFieldsFilterConfig); + }, [customFields]); + + return { customFieldsFilterConfig: filterConfig }; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx similarity index 51% rename from x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx rename to x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx index aef1ab3606fa9..da5efb7dc55e2 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx @@ -5,75 +5,28 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; -import { CustomFieldTypes } from '../../../common/types/domain'; -import { builderMap as customFieldsBuilder } from '../custom_fields/builder'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; -import type { MultiSelectFilterOption } from './multi_select_filter'; -import { MultiSelectFilter, mapToMultiSelectOption } from './multi_select_filter'; -import type { FilterConfig, FilterConfigState } from './use_system_filter_config'; -import { MORE_FILTERS_LABEL } from './translations'; +import type { FilterConfig, FilterConfigState } from './types'; +import { useCustomFieldsFilterConfig } from './use_custom_fields_filter_config'; +import { MoreFiltersSelectable } from './more_filters_selectable'; -const MoreFiltersSelectable = ({ - options, - activeFilters, - onChange, -}: { - options: Array>; - activeFilters: string[]; - onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; -}) => { - return ( - +const serializeFilterVisibilityMap = (value: Map) => { + return JSON.stringify( + Array.from(value.entries()).map(([key, filter]) => ({ + key, + isActive: filter.isActive, + })) ); }; -MoreFiltersSelectable.displayName = 'MoreFiltersSelectable'; - -const useCustomFieldsFilterConfig = () => { - const [filterConfig, setFilterConfig] = useState([]); - - const { - data: { customFields }, - } = useGetCaseConfiguration(); - - useEffect(() => { - const customFieldsFilterConfig: FilterConfig[] = []; - for (const { key, type, label } of customFields ?? []) { - if (customFieldsBuilder[type]) { - const customField = customFieldsBuilder[type](); - customFieldsFilterConfig.push({ - key, - isActive: false, - isAvailable: type === CustomFieldTypes.TOGGLE, - label, - render: ({ filterOptions, onChange }) => { - return ( - - ); - }, - }); - } - } - setFilterConfig(customFieldsFilterConfig); - }, [customFields]); - - return { customFieldsFilterConfig: filterConfig }; +const deserializeFilterVisibilityMap = (value: string): Map => { + return new Map( + JSON.parse(value).map(({ key, isActive }: { key: string; isActive: boolean }) => [ + key, + { key, isActive }, + ]) + ); }; export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: FilterConfig[] }) => { @@ -85,22 +38,8 @@ export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: Fi Map >('filters', new Map(), { raw: false, - serializer: (value) => { - return JSON.stringify( - Array.from(value.entries()).map(([key, filter]) => ({ - key, - isActive: filter.isActive, - })) - ); - }, - deserializer: (value) => { - return new Map( - JSON.parse(value).map(({ key, isActive }: { key: string; isActive: boolean }) => [ - key, - { key, isActive }, - ]) - ); - }, + serializer: serializeFilterVisibilityMap, + deserializer: deserializeFilterVisibilityMap, }); useEffect(() => { diff --git a/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_system_filter_config.tsx similarity index 84% rename from x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx rename to x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_system_filter_config.tsx index 2706476f2ba69..642559c35bee8 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_system_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_system_filter_config.tsx @@ -7,17 +7,17 @@ import React, { useState, useEffect } from 'react'; -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 { FilterOptions } from '../../containers/types'; +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 { FilterConfig } from './types'; interface UseFilterConfigProps { availableSolutions: string[]; @@ -35,25 +35,6 @@ interface UseFilterConfigProps { tags: string[]; } -export interface FilterConfigState { - key: string; - isActive: boolean; -} - -export interface FilterConfig { - key: string; - label: string; - isActive: boolean; - isAvailable: boolean; - render: ({ - filterOptions, - onChange, - }: { - filterOptions: FilterOptions; - onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; - }) => React.ReactNode; -} - export const getSystemFilterConfig = ({ availableSolutions, caseAssignmentAuthorized, 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..19033a2d5e2b4 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,7 +6,7 @@ */ 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'; @@ -22,10 +22,20 @@ 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'; 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(); @@ -45,7 +55,6 @@ describe('CasesTableFilters ', () => { 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 +63,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(); @@ -257,4 +271,299 @@ describe('CasesTableFilters ', () => { expect(onCreateCasePressed).toHaveBeenCalledWith(); }); }); + + describe.only('custom filters configuration', () => { + beforeEach(() => { + getCaseConfigureMock.mockImplementation(() => { + return { + customFields: [ + { type: 'toggle', key: 'toggle', label: 'Toggle', required: false }, + { type: 'text', key: 'text', label: 'Text', required: false }, + ], + }; + }); + }); + + 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-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('filters'); + expect(storedFilterState).toBeTruthy(); + expect(JSON.parse(storedFilterState || '')).toMatchInlineSnapshot(` + Array [ + Object { + "isActive": true, + "key": "severity", + }, + Object { + "isActive": true, + "key": "status", + }, + Object { + "isActive": false, + "key": "assignee", + }, + Object { + "isActive": true, + "key": "tags", + }, + Object { + "isActive": true, + "key": "category", + }, + Object { + "isActive": false, + "key": "owner", + }, + Object { + "isActive": true, + "key": "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('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('filters'); + expect(storedFilterState).toBeTruthy(); + expect(JSON.parse(storedFilterState || '')).toMatchInlineSnapshot(` + Array [ + Object { + "isActive": true, + "key": "severity", + }, + Object { + "isActive": false, + "key": "status", + }, + Object { + "isActive": false, + "key": "assignee", + }, + Object { + "isActive": true, + "key": "tags", + }, + Object { + "isActive": true, + "key": "category", + }, + Object { + "isActive": false, + "key": "owner", + }, + Object { + "isActive": false, + "key": "toggle", + }, + ] + `); + }); + + it('should recover the stored state from the local storage with the right order', async () => { + const previousState = [ + { key: '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('filters', 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: 'toggle', isActive: true }, + ]; + localStorage.setItem('filters', 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.only('when a custom field is active but it gets deleted, it should be removed from the list', async () => { + getCaseConfigureMock + .mockImplementation(() => { + console.log('its called again'); + return { + customFields: [{ type: 'toggle', key: 'za', label: 'ZToggle', required: false }], + }; + }) + .mockImplementationOnce(() => { + console.log('its calling the first one'); + 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 }, + ], + }; + }); + const previousState = [ + { key: 'severity', isActive: true }, + { key: 'status', isActive: false }, + { key: 'ta', isActive: true }, + { key: 'tags', isActive: true }, + { key: 'category', isActive: false }, + { key: 'owner', isActive: false }, + { key: 'toggle', isActive: true }, + ]; + + localStorage.setItem('filters', JSON.stringify(previousState)); + + console.log('render'); + const { rerender } = appMockRender.render(); + + const filterBar = screen.getByTestId('cases-table-filters-group'); + let allFilters: HTMLElement[]; + await waitFor(() => { + allFilters = within(filterBar).getAllByRole('button'); + expect(allFilters).toHaveLength(4); + }); + + console.log('rerender'); + rerender(); + + await waitFor(() => { + allFilters = within(filterBar).getAllByRole('button'); + expect(allFilters).toHaveLength(3); + }); + + const orderedFilterLabels = ['Severity', 'Tags', '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 = ['aa', 'category', 'severity', 'status', 'tags', 'ta', 'tb', 'tc', 'za']; + orderedKey.forEach((key, index) => { + expect(allOptions[index].getAttribute('data-test-subj')).toBe( + `options-filter-popover-item-${key}` + ); + }); + }); + + it('check - uncheck - check', () => {}); + }); }); 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 028bb325d6be6..2de053b8300cc 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 @@ -16,8 +16,8 @@ import { useGetCategories } from '../../containers/use_get_categories'; import type { CurrentUserProfile } from '../types'; import { useCasesFeatures } from '../../common/use_cases_features'; import type { AssigneesFilteringSelection } from '../user_profiles/types'; -import { useSystemFilterConfig } from './use_system_filter_config'; -import { useFilterConfig } from './use_filter_config'; +import { useSystemFilterConfig } from './table_filter_config/use_system_filter_config'; +import { useFilterConfig } from './table_filter_config/use_filter_config'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -52,23 +52,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)) { @@ -164,10 +147,12 @@ const CasesTableFiltersComponent = ({ /> - - {activeFilters.map((filter) => - filter.render({ filterOptions, onChange: onFilterOptionChange }) - )} + + {activeFilters.map((filter) => ( + + {filter.render({ filterOptions, onChange: onFilterOptionChange })} + + ))} Date: Mon, 20 Nov 2023 10:15:02 +0100 Subject: [PATCH 10/32] filter tests --- .../all_cases/multi_select_filter.test.tsx | 19 +++ .../all_cases/multi_select_filter.tsx | 2 +- .../all_cases/table_filter_config/types.ts | 13 +- .../use_custom_fields_filter_config.tsx | 1 - .../table_filter_config/use_filter_config.tsx | 3 +- .../use_system_filter_config.tsx | 32 ++-- .../all_cases/table_filters.test.tsx | 139 ++++++++---------- 7 files changed, 109 insertions(+), 100 deletions(-) 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 e84190ae6f71f..a649f6a01347c 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 @@ -128,7 +128,7 @@ export const MultiSelectFilter = ({ iconType={buttonIconType || 'arrowDown'} onClick={toggleIsPopoverOpen} isSelected={isPopoverOpen} - numFilters={showActiveOptionsNumber ? options.length : undefined} // FIXME: add tests + numFilters={showActiveOptionsNumber ? options.length : undefined} hasActiveFilters={showActiveOptionsNumber ? selectedOptionKeys.length > 0 : undefined} numActiveFilters={showActiveOptionsNumber ? selectedOptionKeys.length : undefined} aria-label={buttonLabel} 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 index af0b612f4aba1..2e4a558b90436 100644 --- 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 @@ -12,16 +12,15 @@ export interface FilterConfigState { isActive: boolean; } +export interface FilterConfigRenderParams { + filterOptions: FilterOptions; + onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; +} + export interface FilterConfig { key: string; label: string; isActive: boolean; isAvailable: boolean; - render: ({ - filterOptions, - onChange, - }: { - filterOptions: FilterOptions; - onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; - }) => React.ReactNode; + 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 index f364da296bf7a..76c012eab7308 100644 --- 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 @@ -20,7 +20,6 @@ export const useCustomFieldsFilterConfig = () => { } = useGetCaseConfiguration(); useEffect(() => { - console.log({ customFields }); const customFieldsFilterConfig: FilterConfig[] = []; for (const { key, type, label } of customFields ?? []) { if (customFieldsBuilder[type]) { 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 index da5efb7dc55e2..cea187db05300 100644 --- 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 @@ -77,8 +77,7 @@ export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: Fi setFilterVisibilityMap(newFilterVisibilityMap); }; - const availableConfigs = Array.from(filters.values()).filter((filter) => filter.isAvailable); - const selectableOptions = availableConfigs + const selectableOptions = Array.from(filters.values()) .map(({ key, label }) => ({ key, label, 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 index 642559c35bee8..050dc087a95cb 100644 --- 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 @@ -17,7 +17,7 @@ import { SeverityFilter } from '../severity_filter'; import { AssigneesFilterPopover } from '../assignees_filter'; import type { CurrentUserProfile } from '../../types'; import type { AssigneesFilteringSelection } from '../../user_profiles/types'; -import type { FilterConfig } from './types'; +import type { FilterConfig, FilterConfigRenderParams } from './types'; interface UseFilterConfigProps { availableSolutions: string[]; @@ -56,7 +56,7 @@ export const getSystemFilterConfig = ({ label: i18n.SEVERITY, isActive: true, isAvailable: true, - render: ({ filterOptions, onChange }) => ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( ), }, @@ -65,7 +65,7 @@ export const getSystemFilterConfig = ({ label: i18n.STATUS, isActive: true, isAvailable: true, - render: ({ filterOptions, onChange }) => ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( ( - - ), + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { + return ( + + ); + }, }, { key: 'tags', label: i18n.TAGS, isActive: true, isAvailable: true, - render: ({ filterOptions, onChange }) => ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( 1, - render: ({ filterOptions, onChange }) => ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( ), }, - ]; + ].filter((filter) => filter.isAvailable) as FilterConfig[]; }; export const useSystemFilterConfig = ({ 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 19033a2d5e2b4..436795bbe4d48 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 @@ -52,6 +52,43 @@ const props = { 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(); @@ -272,7 +309,7 @@ describe('CasesTableFilters ', () => { }); }); - describe.only('custom filters configuration', () => { + describe('custom filters configuration', () => { beforeEach(() => { getCaseConfigureMock.mockImplementation(() => { return { @@ -284,6 +321,10 @@ describe('CasesTableFilters ', () => { }); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should render all options in the popover, including custom fields', async () => { appMockRender.render(); @@ -341,10 +382,6 @@ describe('CasesTableFilters ', () => { "isActive": true, "key": "status", }, - Object { - "isActive": false, - "key": "assignee", - }, Object { "isActive": true, "key": "tags", @@ -353,10 +390,6 @@ describe('CasesTableFilters ', () => { "isActive": true, "key": "category", }, - Object { - "isActive": false, - "key": "owner", - }, Object { "isActive": true, "key": "toggle", @@ -403,10 +436,6 @@ describe('CasesTableFilters ', () => { "isActive": false, "key": "status", }, - Object { - "isActive": false, - "key": "assignee", - }, Object { "isActive": true, "key": "tags", @@ -415,10 +444,6 @@ describe('CasesTableFilters ', () => { "isActive": true, "key": "category", }, - Object { - "isActive": false, - "key": "owner", - }, Object { "isActive": false, "key": "toggle", @@ -481,62 +506,6 @@ describe('CasesTableFilters ', () => { }); }); - it.only('when a custom field is active but it gets deleted, it should be removed from the list', async () => { - getCaseConfigureMock - .mockImplementation(() => { - console.log('its called again'); - return { - customFields: [{ type: 'toggle', key: 'za', label: 'ZToggle', required: false }], - }; - }) - .mockImplementationOnce(() => { - console.log('its calling the first one'); - 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 }, - ], - }; - }); - const previousState = [ - { key: 'severity', isActive: true }, - { key: 'status', isActive: false }, - { key: 'ta', isActive: true }, - { key: 'tags', isActive: true }, - { key: 'category', isActive: false }, - { key: 'owner', isActive: false }, - { key: 'toggle', isActive: true }, - ]; - - localStorage.setItem('filters', JSON.stringify(previousState)); - - console.log('render'); - const { rerender } = appMockRender.render(); - - const filterBar = screen.getByTestId('cases-table-filters-group'); - let allFilters: HTMLElement[]; - await waitFor(() => { - allFilters = within(filterBar).getAllByRole('button'); - expect(allFilters).toHaveLength(4); - }); - - console.log('rerender'); - rerender(); - - await waitFor(() => { - allFilters = within(filterBar).getAllByRole('button'); - expect(allFilters).toHaveLength(3); - }); - - const orderedFilterLabels = ['Severity', 'Tags', '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 { @@ -564,6 +533,28 @@ describe('CasesTableFilters ', () => { }); }); - it('check - uncheck - check', () => {}); + it('should have right order after deactivating and activating a filter that by default is activated and not in the last one', 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); + }); + }); }); }); From e8bc6b97a584b9d6e3ffb93d09685dcf636e2def Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 21 Nov 2023 10:13:04 +0100 Subject: [PATCH 11/32] set right local storage key for filter config --- x-pack/plugins/cases/common/constants/index.ts | 1 + .../table_filter_config/use_filter_config.tsx | 5 ++++- .../components/all_cases/table_filters.test.tsx | 16 +++++++++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index f2c2dcef9bb2e..18a40345379fb 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -202,6 +202,7 @@ export const SEARCH_DEBOUNCE_MS = 500; export const LOCAL_STORAGE_KEYS = { casesQueryParams: 'cases.list.queryParams', casesFilterOptions: 'cases.list.filterOptions', + casesTableFiltersConfig: 'cases.list.tableFiltersConfig', }; /** 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 index cea187db05300..f6ea9abed77b5 100644 --- 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 @@ -7,9 +7,11 @@ import { useState, useEffect } from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { LOCAL_STORAGE_KEYS } from '../../../../common/constants'; import type { FilterConfig, FilterConfigState } from './types'; import { useCustomFieldsFilterConfig } from './use_custom_fields_filter_config'; import { MoreFiltersSelectable } from './more_filters_selectable'; +import { useCasesContext } from '../../cases_context/use_cases_context'; const serializeFilterVisibilityMap = (value: Map) => { return JSON.stringify( @@ -30,13 +32,14 @@ const deserializeFilterVisibilityMap = (value: string): Map { + const { appId } = useCasesContext(); const { customFieldsFilterConfig } = useCustomFieldsFilterConfig(); const [filters, setFilters] = useState>( () => new Map([...systemFilterConfig].map((filter) => [filter.key, filter])) ); const [filterVisibilityMap, setFilterVisibilityMap] = useLocalStorage< Map - >('filters', new Map(), { + >(`${appId}.${LOCAL_STORAGE_KEYS.casesTableFiltersConfig}`, new Map(), { raw: false, serializer: serializeFilterVisibilityMap, deserializer: deserializeFilterVisibilityMap, 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 436795bbe4d48..933d383157c9a 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 @@ -370,9 +370,9 @@ describe('CasesTableFilters ', () => { await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(5)); userEvent.click(screen.getByRole('option', { name: 'Toggle' })); - const storedFilterState = localStorage.getItem('filters'); + const storedFilterState = localStorage.getItem('testAppId.cases.list.tableFiltersConfig'); expect(storedFilterState).toBeTruthy(); - expect(JSON.parse(storedFilterState || '')).toMatchInlineSnapshot(` + expect(JSON.parse(storedFilterState!)).toMatchInlineSnapshot(` Array [ Object { "isActive": true, @@ -424,7 +424,7 @@ describe('CasesTableFilters ', () => { userEvent.click(screen.getByRole('option', { name: 'Status' })); - const storedFilterState = localStorage.getItem('filters'); + const storedFilterState = localStorage.getItem('testAppId.cases.list.tableFiltersConfig'); expect(storedFilterState).toBeTruthy(); expect(JSON.parse(storedFilterState || '')).toMatchInlineSnapshot(` Array [ @@ -462,7 +462,10 @@ describe('CasesTableFilters ', () => { { key: 'status', isActive: false }, { key: 'severity', isActive: true }, ]; - localStorage.setItem('filters', JSON.stringify(previousState)); + localStorage.setItem( + 'testAppId.cases.list.tableFiltersConfig', + JSON.stringify(previousState) + ); appMockRender.render(); @@ -489,7 +492,10 @@ describe('CasesTableFilters ', () => { { key: 'owner', isActive: false }, { key: 'toggle', isActive: true }, ]; - localStorage.setItem('filters', JSON.stringify(previousState)); + localStorage.setItem( + 'testAppId.cases.list.tableFiltersConfig', + JSON.stringify(previousState) + ); appMockRender.render(); From 3fd75c4f3a1799c5f961e4c28b17b5c5808dbf3c Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 21 Nov 2023 11:28:34 +0100 Subject: [PATCH 12/32] add customFields in FilterOptions --- x-pack/plugins/cases/common/ui/types.ts | 9 ++++++++- .../use_custom_fields_filter_config.tsx | 2 +- x-pack/plugins/cases/public/containers/__mocks__/api.ts | 1 + x-pack/plugins/cases/public/containers/api.test.tsx | 1 + x-pack/plugins/cases/public/containers/api.ts | 1 + x-pack/plugins/cases/public/containers/constants.ts | 1 + 6 files changed, 13 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index dca2b6c6549d1..5ac717f67c616 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -145,7 +145,7 @@ export interface ParsedUrlQueryParams extends Partial { export type LocalStorageQueryParams = Partial>; -export interface FilterOptions { +export interface SystemFilterOptions { search: string; searchFields: string[]; severity: CaseSeverity[]; @@ -156,6 +156,13 @@ export interface FilterOptions { owner: string[]; category: string[]; } + +export interface FilterOptions extends SystemFilterOptions { + customFields: { + [key: string]: string[]; + }; +} + export type PartialFilterOptions = Partial; export type SingleCaseMetrics = SingleCaseMetricsResponse; 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 index 76c012eab7308..5dc3b2b4c8d12 100644 --- 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 @@ -37,7 +37,7 @@ export const useCustomFieldsFilterConfig = () => { id={key} onChange={onChange} options={mapToMultiSelectOption(customField.filterOptions || [])} - selectedOptionKeys={filterOptions[key]} + selectedOptionKeys={filterOptions.customFields[key]} /> ); }, 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..1a00b2ad48771 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -234,6 +234,7 @@ describe('Cases API', () => { search: 'hello', owner: [SECURITY_SOLUTION_OWNER], category: [], + customFields: {}, }, 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 ad75838ee2a86..d999bcd8a642e 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -259,6 +259,7 @@ export const getCases = async ({ tags: [], owner: [], category: [], + customFields: {}, }, queryParams = { page: 1, 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 = { From 5f58c3df49a2c0c051e737d9485c3af5afc4027c Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 21 Nov 2023 13:09:48 +0100 Subject: [PATCH 13/32] add customField filters in query --- .../all_cases/table_filter_config/types.ts | 8 ++- .../use_custom_fields_filter_config.tsx | 58 +++++++++++++++---- .../components/all_cases/table_filters.tsx | 23 +++++--- x-pack/plugins/cases/public/containers/api.ts | 2 + .../plugins/cases/public/containers/utils.ts | 9 +++ 5 files changed, 79 insertions(+), 21 deletions(-) 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 index 2e4a558b90436..c3b1f4f7b8716 100644 --- 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 @@ -12,9 +12,15 @@ export interface FilterConfigState { isActive: boolean; } +export type FilterChangeHandler = (params: { + filterId: string; + selectedOptionKeys: string[]; + isCustomField?: boolean; +}) => void; + export interface FilterConfigRenderParams { filterOptions: FilterOptions; - onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; + onChange: FilterChangeHandler; } export interface FilterConfig { 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 index 5dc3b2b4c8d12..2cf8659a879d5 100644 --- 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 @@ -9,9 +9,49 @@ import React, { useState, useEffect } from 'react'; import { CustomFieldTypes } from '../../../../common/types/domain'; import { builderMap as customFieldsBuilder } from '../../custom_fields/builder'; import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; -import type { FilterConfig } from './types'; +import type { FilterConfig, FilterConfigRenderParams } from './types'; import { MultiSelectFilter, mapToMultiSelectOption } from '../multi_select_filter'; +const getCustomFieldFilterComponent = ({ + customFieldOptions = [], + label, + key, +}: { + customFieldOptions?: string[]; + label: string; + key: string; +}) => { + const FieldFilterComponent = ({ filterOptions, onChange }: FilterConfigRenderParams) => { + const onCustomFieldChange = ({ + filterId, + selectedOptionKeys, + }: { + filterId: string; + selectedOptionKeys: string[]; + }) => { + onChange({ + filterId, + selectedOptionKeys, + isCustomField: true, + }); + }; + + return ( + + ); + }; + + FieldFilterComponent.displayName = 'FieldFilterComponent'; + + return FieldFilterComponent; +}; + export const useCustomFieldsFilterConfig = () => { const [filterConfig, setFilterConfig] = useState([]); @@ -30,17 +70,11 @@ export const useCustomFieldsFilterConfig = () => { isActive: false, isAvailable: type === CustomFieldTypes.TOGGLE, label, - render: ({ filterOptions, onChange }) => { - return ( - - ); - }, + render: getCustomFieldFilterComponent({ + customFieldOptions: customField.filterOptions, + key, + label, + }), }); } } 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 2de053b8300cc..cf26f4433f149 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 @@ -18,6 +18,7 @@ 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'; +import type { FilterChangeHandler } from './table_filter_config/types'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -88,17 +89,23 @@ const CasesTableFiltersComponent = ({ onFilterConfigChange, } = useFilterConfig({ systemFilterConfig }); - const onFilterOptionChange = ({ + const onFilterOptionChange: FilterChangeHandler = ({ filterId, selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; + isCustomField = false, }) => { - const newFilters = { - ...filterOptions, - [filterId]: selectedOptionKeys, - }; + const newFilters = isCustomField + ? { + ...filterOptions, + customFields: { + ...filterOptions.customFields, + [filterId]: selectedOptionKeys, + }, + } + : { + ...filterOptions, + [filterId]: selectedOptionKeys, + }; if (!isEqual(newFilters, filterOptions)) { onFilterChanged(newFilters); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index d999bcd8a642e..e145a0d2d1dc9 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -86,6 +86,7 @@ import { constructAssigneesFilter, constructReportersFilter, decodeCaseUserActionStatsResponse, + constructCustomFieldsFilter, } from './utils'; import { decodeCasesFindResponse } from '../api/decoders'; @@ -287,6 +288,7 @@ 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, }; diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts index c5713d8f1fac7..d17187ca58d89 100644 --- a/x-pack/plugins/cases/public/containers/utils.ts +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -170,3 +170,12 @@ export const constructReportersFilter = (reporters: User[]) => { } : {}; }; + +export const constructCustomFieldsFilter = (customFields: FilterOptions['customFields']) => { + const filter = Object.entries(customFields).reduce( + (acc, [key, value]) => (value.length > 0 ? { ...acc, [key]: value } : acc), + {} + ); + + return Object.keys(filter).length > 0 ? filter : {}; +}; From c98f2bb78f891a659813bd7db62588551be9891e Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 22 Nov 2023 09:58:14 +0100 Subject: [PATCH 14/32] custom fields as part of the filter query --- x-pack/plugins/cases/common/ui/types.ts | 6 +- .../all_cases/severity_filter.test.tsx | 2 +- .../all_cases/table_filter_config/types.ts | 3 +- .../use_custom_fields_filter_config.tsx | 83 ++++++++----------- .../components/all_cases/table_filters.tsx | 9 +- .../toggle/configure_toggle_field.ts | 6 +- .../components/custom_fields/translations.ts | 21 +++++ .../public/components/custom_fields/types.ts | 8 +- x-pack/plugins/cases/public/containers/api.ts | 16 ++-- .../cases/public/containers/utils.test.ts | 32 +++++++ .../plugins/cases/public/containers/utils.ts | 40 +++++++-- 11 files changed, 156 insertions(+), 70 deletions(-) diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 5ac717f67c616..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, @@ -159,7 +160,10 @@ export interface SystemFilterOptions { export interface FilterOptions extends SystemFilterOptions { customFields: { - [key: string]: string[]; + [key: string]: { + type: CustomFieldTypes; + options: string[]; + }; }; } 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/table_filter_config/types.ts b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/types.ts index c3b1f4f7b8716..67895dc133c8f 100644 --- 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 @@ -5,6 +5,7 @@ * 2.0. */ +import type { CustomFieldTypes } from '../../../../common/types/domain'; import type { FilterOptions } from '../../../../common/ui'; export interface FilterConfigState { @@ -15,7 +16,7 @@ export interface FilterConfigState { export type FilterChangeHandler = (params: { filterId: string; selectedOptionKeys: string[]; - isCustomField?: boolean; + customFieldType?: CustomFieldTypes; }) => void; export interface FilterConfigRenderParams { 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 index 2cf8659a879d5..83ac48cca168b 100644 --- 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 @@ -10,47 +10,7 @@ import { CustomFieldTypes } from '../../../../common/types/domain'; import { builderMap as customFieldsBuilder } from '../../custom_fields/builder'; import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; import type { FilterConfig, FilterConfigRenderParams } from './types'; -import { MultiSelectFilter, mapToMultiSelectOption } from '../multi_select_filter'; - -const getCustomFieldFilterComponent = ({ - customFieldOptions = [], - label, - key, -}: { - customFieldOptions?: string[]; - label: string; - key: string; -}) => { - const FieldFilterComponent = ({ filterOptions, onChange }: FilterConfigRenderParams) => { - const onCustomFieldChange = ({ - filterId, - selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; - }) => { - onChange({ - filterId, - selectedOptionKeys, - isCustomField: true, - }); - }; - - return ( - - ); - }; - - FieldFilterComponent.displayName = 'FieldFilterComponent'; - - return FieldFilterComponent; -}; +import { MultiSelectFilter } from '../multi_select_filter'; export const useCustomFieldsFilterConfig = () => { const [filterConfig, setFilterConfig] = useState([]); @@ -61,20 +21,43 @@ export const useCustomFieldsFilterConfig = () => { useEffect(() => { const customFieldsFilterConfig: FilterConfig[] = []; - for (const { key, type, label } of customFields ?? []) { + for (const { key: fieldKey, type, label: buttonLabel } of customFields ?? []) { if (customFieldsBuilder[type]) { - const customField = customFieldsBuilder[type](); + const { filterOptions: customFieldOptions = [] } = customFieldsBuilder[type](); customFieldsFilterConfig.push({ - key, + key: fieldKey, isActive: false, isAvailable: type === CustomFieldTypes.TOGGLE, - label, - render: getCustomFieldFilterComponent({ - customFieldOptions: customField.filterOptions, - key, - label, - }), + label: buttonLabel, + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { + const onCustomFieldChange = ({ + filterId, + selectedOptionKeys, + }: { + filterId: string; + selectedOptionKeys: string[]; + }) => { + onChange({ + filterId, + selectedOptionKeys, + customFieldType: type, + }); + }; + + return ( + ({ + key: option.key, + label: option.label, + }))} + selectedOptionKeys={filterOptions.customFields[fieldKey]?.options || []} + /> + ); + }, }); } } 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 cf26f4433f149..2ddf46a8cabba 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 @@ -92,14 +92,17 @@ const CasesTableFiltersComponent = ({ const onFilterOptionChange: FilterChangeHandler = ({ filterId, selectedOptionKeys, - isCustomField = false, + customFieldType, }) => { - const newFilters = isCustomField + const newFilters = customFieldType ? { ...filterOptions, customFields: { ...filterOptions.customFields, - [filterId]: selectedOptionKeys, + [filterId]: { + type: customFieldType, + options: selectedOptionKeys, + }, }, } : { 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 40ea477ea6f14..fce93c842a34f 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,5 +26,9 @@ 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,7 +51,7 @@ export type CustomFieldFactory = () => { label: string; getEuiTableColumn: (params: { label: string }) => CustomFieldEuiTableColumn; build: () => CustomFieldType; - filterOptions?: string[]; + filterOptions?: CustomFieldFactoryFilterOption[]; }; export type CustomFieldBuilderMap = { diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index e145a0d2d1dc9..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'; @@ -270,7 +271,7 @@ export const getCases = async ({ }, signal, }: FetchCasesProps): Promise => { - const query = { + const body = { ...removeOptionFromFilter({ filterKey: 'status', filterOptions: filterOptions.status, @@ -292,11 +293,14 @@ export const getCases = async ({ ...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/utils.test.ts b/x-pack/plugins/cases/public/containers/utils.test.ts index b7a0b17ec5ff0..e333ea71c4bdc 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,34 @@ 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', 'unset'], + }, + 'c1f0c0a0-2aaf-11ec-8d3d-0242ac130003': { + type: CustomFieldTypes.TOGGLE, + options: [], + }, + }) + ).toEqual({ + customFields: { + '957846f4-a792-45a2-bc9a-c028973dfdde': [true], + 'dbeb8e9c-240b-4adb-b83e-e645e86c07ed': [false, null], + }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts index d17187ca58d89..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; @@ -171,11 +173,37 @@ export const constructReportersFilter = (reporters: User[]) => { : {}; }; -export const constructCustomFieldsFilter = (customFields: FilterOptions['customFields']) => { - const filter = Object.entries(customFields).reduce( - (acc, [key, value]) => (value.length > 0 ? { ...acc, [key]: value } : acc), - {} - ); +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 Object.keys(filter).length > 0 ? filter : {}; + return { + customFields: valuesByCustomFieldKey, + }; }; From a3ac59b9d98fba0bf78d82f66e03527b4ffa120c Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 22 Nov 2023 11:45:34 +0100 Subject: [PATCH 15/32] add prefix to custom field keys --- .../use_custom_fields_filter_config.tsx | 6 +- .../all_cases/table_filters.test.tsx | 58 ++++++++++++++++--- .../toggle/configure_toggle_field.ts | 1 - 3 files changed, 55 insertions(+), 10 deletions(-) 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 index 83ac48cca168b..465d7250a3d4a 100644 --- 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 @@ -12,6 +12,8 @@ import { useGetCaseConfiguration } from '../../../containers/configure/use_get_c import type { FilterConfig, FilterConfigRenderParams } from './types'; import { MultiSelectFilter } from '../multi_select_filter'; +export const CUSTOM_FIELD_KEY_PREFIX = 'cf_'; + export const useCustomFieldsFilterConfig = () => { const [filterConfig, setFilterConfig] = useState([]); @@ -26,7 +28,7 @@ export const useCustomFieldsFilterConfig = () => { const { filterOptions: customFieldOptions = [] } = customFieldsBuilder[type](); customFieldsFilterConfig.push({ - key: fieldKey, + 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: type === CustomFieldTypes.TOGGLE, label: buttonLabel, @@ -39,7 +41,7 @@ export const useCustomFieldsFilterConfig = () => { selectedOptionKeys: string[]; }) => { onChange({ - filterId, + filterId: filterId.replace(CUSTOM_FIELD_KEY_PREFIX, ''), selectedOptionKeys, customFieldType: type, }); 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 933d383157c9a..6e89a835fa51b 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 @@ -23,6 +23,7 @@ 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'); @@ -333,7 +334,9 @@ describe('CasesTableFilters ', () => { await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(5)); expect(screen.getByTestId('options-filter-popover-item-status')).toBeInTheDocument(); - expect(screen.getByTestId('options-filter-popover-item-toggle')).toBeInTheDocument(); + expect( + screen.getByTestId(`options-filter-popover-item-${CUSTOM_FIELD_KEY_PREFIX}toggle`) + ).toBeInTheDocument(); }); it('should not add text type custom fields', async () => { @@ -392,7 +395,7 @@ describe('CasesTableFilters ', () => { }, Object { "isActive": true, - "key": "toggle", + "key": "cf_toggle", }, ] `); @@ -446,7 +449,7 @@ describe('CasesTableFilters ', () => { }, Object { "isActive": false, - "key": "toggle", + "key": "cf_toggle", }, ] `); @@ -454,7 +457,7 @@ describe('CasesTableFilters ', () => { it('should recover the stored state from the local storage with the right order', async () => { const previousState = [ - { key: 'toggle', isActive: true }, + { key: `${CUSTOM_FIELD_KEY_PREFIX}toggle`, isActive: true }, { key: 'owner', isActive: false }, { key: 'category', isActive: false }, { key: 'tags', isActive: true }, @@ -490,7 +493,7 @@ describe('CasesTableFilters ', () => { { key: 'tags', isActive: true }, { key: 'category', isActive: false }, { key: 'owner', isActive: false }, - { key: 'toggle', isActive: true }, + { key: `${CUSTOM_FIELD_KEY_PREFIX}toggle`, isActive: true }, ]; localStorage.setItem( 'testAppId.cases.list.tableFiltersConfig', @@ -531,7 +534,17 @@ describe('CasesTableFilters ', () => { expect(screen.getAllByRole('option')).toHaveLength(9); }); const allOptions = screen.getAllByRole('option'); - const orderedKey = ['aa', 'category', 'severity', 'status', 'tags', 'ta', 'tb', 'tc', 'za']; + 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}` @@ -539,7 +552,7 @@ describe('CasesTableFilters ', () => { }); }); - it('should have right order after deactivating and activating a filter that by default is activated and not in the last one', async () => { + 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'); @@ -562,5 +575,36 @@ describe('CasesTableFilters ', () => { 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); + }); + }); }); }); 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 fce93c842a34f..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 @@ -29,6 +29,5 @@ export const configureToggleCustomFieldFactory: CustomFieldFactory Date: Wed, 22 Nov 2023 13:34:12 +0100 Subject: [PATCH 16/32] update getCases test + custom fields test --- .../cases/public/containers/api.test.tsx | 160 +++++++++++------- 1 file changed, 99 insertions(+), 61 deletions(-) diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index 1a00b2ad48771..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, }); }); @@ -240,19 +241,19 @@ 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], + 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, }); }); @@ -267,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, }); }); @@ -288,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, }); }); @@ -308,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, }); }); @@ -329,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, }); }); @@ -349,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, }); }); @@ -369,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, }); }); @@ -390,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, }); }); @@ -418,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, }); }); @@ -454,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, }); }); From 88a4e04281006c2f3f47d0de4bb3a58df9d87d00 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 22 Nov 2023 15:21:10 +0100 Subject: [PATCH 17/32] add custom field filter test --- .../all_cases/table_filters.test.tsx | 106 +++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) 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 6e89a835fa51b..e4638cd1e3b54 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 @@ -12,7 +12,7 @@ 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'; @@ -310,6 +310,110 @@ describe('CasesTableFilters ', () => { }); }); + 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: { + toggleKey: { + 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: { + toggleKey: { + type: 'toggle', + options: ['off'], + }, + }, + }); + }); + + it('should call onFilterChange when second option is clicked', async () => { + const customProps = { + ...props, + filterOptions: { + ...props.filterOptions, + customFields: { + toggleKey: { + 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: { + toggleKey: { + type: 'toggle', + options: ['on', 'off'], + }, + }, + }); + }); + }); + describe('custom filters configuration', () => { beforeEach(() => { getCaseConfigureMock.mockImplementation(() => { From c915c167da2e8c07cee678b86212de50e2fd7987 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 22 Nov 2023 17:17:04 +0100 Subject: [PATCH 18/32] added onDelete prop on filters --- .../all_cases/table_filter_config/types.ts | 1 + .../use_custom_fields_filter_config.tsx | 7 +++ .../table_filter_config/use_filter_config.tsx | 14 +++++- .../use_system_filter_config.tsx | 22 ++++++++- .../components/all_cases/table_filters.tsx | 48 +++++++++---------- 5 files changed, 65 insertions(+), 27 deletions(-) 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 index 67895dc133c8f..b814eb64e4f36 100644 --- 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 @@ -29,5 +29,6 @@ export interface FilterConfig { label: string; isActive: boolean; isAvailable: boolean; + onDeactivate: (params: { onChange: FilterChangeHandler }) => void; 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 index 465d7250a3d4a..59a294d567501 100644 --- 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 @@ -32,6 +32,13 @@ export const useCustomFieldsFilterConfig = () => { isActive: false, isAvailable: type === CustomFieldTypes.TOGGLE, label: buttonLabel, + onDeactivate: ({ onChange }) => { + onChange({ + filterId: fieldKey, + selectedOptionKeys: [], + customFieldType: type, + }); + }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { const onCustomFieldChange = ({ filterId, 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 index f6ea9abed77b5..a2a46d55e4697 100644 --- 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 @@ -8,7 +8,7 @@ import { useState, useEffect } from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; import { LOCAL_STORAGE_KEYS } from '../../../../common/constants'; -import type { FilterConfig, FilterConfigState } from './types'; +import type { FilterChangeHandler, FilterConfig, FilterConfigState } from './types'; import { useCustomFieldsFilterConfig } from './use_custom_fields_filter_config'; import { MoreFiltersSelectable } from './more_filters_selectable'; import { useCasesContext } from '../../cases_context/use_cases_context'; @@ -31,7 +31,13 @@ const deserializeFilterVisibilityMap = (value: string): Map { +export const useFilterConfig = ({ + systemFilterConfig, + onFilterOptionChange, +}: { + systemFilterConfig: FilterConfig[]; + onFilterOptionChange: FilterChangeHandler; +}) => { const { appId } = useCasesContext(); const { customFieldsFilterConfig } = useCustomFieldsFilterConfig(); const [filters, setFilters] = useState>( @@ -64,6 +70,10 @@ export const useFilterConfig = ({ systemFilterConfig }: { systemFilterConfig: Fi newFilterVisibilityMap.set(key, { key, isActive: true }); } } else { + const filter = filters.get(key); + if (filter) { + filter.onDeactivate({ onChange: onFilterOptionChange }); + } newFilterVisibilityMap.set(key, { key, isActive: false }); } }); 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 index 050dc087a95cb..a9e0b27602372 100644 --- 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 @@ -17,7 +17,7 @@ import { SeverityFilter } from '../severity_filter'; import { AssigneesFilterPopover } from '../assignees_filter'; import type { CurrentUserProfile } from '../../types'; import type { AssigneesFilteringSelection } from '../../user_profiles/types'; -import type { FilterConfig, FilterConfigRenderParams } from './types'; +import type { FilterChangeHandler, FilterConfig, FilterConfigRenderParams } from './types'; interface UseFilterConfigProps { availableSolutions: string[]; @@ -56,6 +56,9 @@ export const getSystemFilterConfig = ({ label: i18n.SEVERITY, isActive: true, isAvailable: true, + onDeactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + onChange({ filterId: 'severity', selectedOptionKeys: [] }); + }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( ), @@ -65,6 +68,9 @@ export const getSystemFilterConfig = ({ label: i18n.STATUS, isActive: true, isAvailable: true, + onDeactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + onChange({ filterId: 'status', selectedOptionKeys: [] }); + }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { + onChange({ filterId: 'assignee', selectedOptionKeys: [] }); + }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { return ( { + onChange({ filterId: 'tags', selectedOptionKeys: [] }); + }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { + onChange({ filterId: 'category', selectedOptionKeys: [] }); + }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( 1, + onDeactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + if (availableSolutions.length > 1) { + onChange({ filterId: 'owner', selectedOptionKeys: [] }); + } + }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { const trimSearch = newSearch.trim(); From 1d74669bf0dea414a63ebe2ee17dd7330a46f5bf Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Thu, 23 Nov 2023 11:28:29 +0100 Subject: [PATCH 19/32] deactivate + test --- .../all_cases/table_filter_config/types.ts | 2 +- .../use_custom_fields_filter_config.tsx | 2 +- .../table_filter_config/use_filter_config.tsx | 20 ++++-- .../use_system_filter_config.tsx | 18 +++--- .../all_cases/table_filters.test.tsx | 61 +++++++++++++++++-- 5 files changed, 82 insertions(+), 21 deletions(-) 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 index b814eb64e4f36..d85d18903251d 100644 --- 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 @@ -29,6 +29,6 @@ export interface FilterConfig { label: string; isActive: boolean; isAvailable: boolean; - onDeactivate: (params: { onChange: FilterChangeHandler }) => void; + deactivate: (params: { onChange: FilterChangeHandler }) => void; 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 index 59a294d567501..e6e0496bb739c 100644 --- 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 @@ -32,7 +32,7 @@ export const useCustomFieldsFilterConfig = () => { isActive: false, isAvailable: type === CustomFieldTypes.TOGGLE, label: buttonLabel, - onDeactivate: ({ onChange }) => { + deactivate: ({ onChange }) => { onChange({ filterId: fieldKey, selectedOptionKeys: [], 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 index a2a46d55e4697..2161d8b970fdb 100644 --- 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 @@ -62,6 +62,7 @@ export const useFilterConfig = ({ const onChange = ({ selectedOptionKeys }: { filterId: string; selectedOptionKeys: string[] }) => { const newFilterVisibilityMap = new Map(filterVisibilityMap); + const deactivatedFilters: string[] = []; newFilterVisibilityMap.forEach(({ key, isActive }) => { if (selectedOptionKeys.includes(key)) { @@ -70,23 +71,30 @@ export const useFilterConfig = ({ newFilterVisibilityMap.set(key, { key, isActive: true }); } } else { - const filter = filters.get(key); - if (filter) { - filter.onDeactivate({ onChange: onFilterOptionChange }); - } + deactivatedFilters.push(key); newFilterVisibilityMap.set(key, { key, isActive: false }); } }); - filters.forEach(({ key, isActive }) => { + filters.forEach(({ key }) => { if (!newFilterVisibilityMap.has(key)) { + const isActive = selectedOptionKeys.includes(key); + if (!isActive) { + deactivatedFilters.push(key); + } newFilterVisibilityMap.set(key, { key, - isActive: selectedOptionKeys.includes(key), + isActive, }); } }); + deactivatedFilters + .filter((key) => filters.has(key)) + .forEach((key) => { + (filters.get(key) as FilterConfig).deactivate({ onChange: onFilterOptionChange }); + }); + setFilterVisibilityMap(newFilterVisibilityMap); }; 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 index a9e0b27602372..c087f8315afe7 100644 --- 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 @@ -56,7 +56,7 @@ export const getSystemFilterConfig = ({ label: i18n.SEVERITY, isActive: true, isAvailable: true, - onDeactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { onChange({ filterId: 'severity', selectedOptionKeys: [] }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( @@ -68,7 +68,7 @@ export const getSystemFilterConfig = ({ label: i18n.STATUS, isActive: true, isAvailable: true, - onDeactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { onChange({ filterId: 'status', selectedOptionKeys: [] }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( @@ -87,7 +87,8 @@ export const getSystemFilterConfig = ({ label: i18n.ASSIGNEES, isActive: true, isAvailable: caseAssignmentAuthorized && !isSelectorView, - onDeactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + // FIXME: not working onChange({ filterId: 'assignee', selectedOptionKeys: [] }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { @@ -106,7 +107,7 @@ export const getSystemFilterConfig = ({ label: i18n.TAGS, isActive: true, isAvailable: true, - onDeactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { onChange({ filterId: 'tags', selectedOptionKeys: [] }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( @@ -126,7 +127,7 @@ export const getSystemFilterConfig = ({ label: i18n.CATEGORIES, isActive: true, isAvailable: true, - onDeactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { onChange({ filterId: 'category', selectedOptionKeys: [] }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( @@ -146,10 +147,9 @@ export const getSystemFilterConfig = ({ label: i18n.SOLUTION, isActive: true, isAvailable: availableSolutions.length > 1, - onDeactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { - if (availableSolutions.length > 1) { - onChange({ filterId: 'owner', selectedOptionKeys: [] }); - } + deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { + // FIXME: test this + onChange({ filterId: 'owner', selectedOptionKeys: availableSolutions }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, customFields: { - toggleKey: { + [customFieldKey]: { type: 'toggle', options: ['on'], }, @@ -374,7 +374,7 @@ describe('CasesTableFilters ', () => { expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, customFields: { - toggleKey: { + [customFieldKey]: { type: 'toggle', options: ['off'], }, @@ -388,7 +388,7 @@ describe('CasesTableFilters ', () => { filterOptions: { ...props.filterOptions, customFields: { - toggleKey: { + [customFieldKey]: { type: CustomFieldTypes.TOGGLE, options: ['on'], }, @@ -405,13 +405,47 @@ describe('CasesTableFilters ', () => { expect(onFilterChanged).toHaveBeenCalledWith({ ...DEFAULT_FILTER_OPTIONS, customFields: { - toggleKey: { + [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', () => { @@ -523,6 +557,25 @@ describe('CasesTableFilters ', () => { }); }); + 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: [], + }); + }); + it('when a filter gets deactivated, it should be updated in the local storage', async () => { appMockRender.render(); From 4f7c9879e39cfd909e46a55f01a6f6075955d155 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Thu, 23 Nov 2023 11:58:53 +0100 Subject: [PATCH 20/32] fix assign deactivate +custom field filter factory --- .../all_cases/table_filter_config/types.ts | 2 +- .../use_custom_fields_filter_config.tsx | 117 +++++++++++------- .../table_filter_config/use_filter_config.tsx | 10 +- .../use_system_filter_config.tsx | 34 ++--- .../components/all_cases/table_filters.tsx | 46 +++---- 5 files changed, 120 insertions(+), 89 deletions(-) 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 index d85d18903251d..bcd33198b7a2b 100644 --- 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 @@ -29,6 +29,6 @@ export interface FilterConfig { label: string; isActive: boolean; isAvailable: boolean; - deactivate: (params: { onChange: FilterChangeHandler }) => void; + deactivate: () => void; 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 index e6e0496bb739c..92724442e0060 100644 --- 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 @@ -9,12 +9,73 @@ import React, { useState, useEffect } from 'react'; import { CustomFieldTypes } from '../../../../common/types/domain'; import { builderMap as customFieldsBuilder } from '../../custom_fields/builder'; import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; -import type { FilterConfig, FilterConfigRenderParams } from './types'; +import type { FilterChangeHandler, FilterConfig, FilterConfigRenderParams } from './types'; import { MultiSelectFilter } from '../multi_select_filter'; export const CUSTOM_FIELD_KEY_PREFIX = 'cf_'; -export const useCustomFieldsFilterConfig = () => { +interface CustomFieldFilterOptionFactoryProps { + fieldKey: string; + buttonLabel: string; + type: CustomFieldTypes; + onFilterOptionChange: FilterChangeHandler; + customFieldOptions: Array<{ key: string; label: string }>; +} +const customFieldFilterOptionFactory = ({ + fieldKey, + buttonLabel, + type, + onFilterOptionChange, + customFieldOptions, +}: 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: type === CustomFieldTypes.TOGGLE, + label: buttonLabel, + deactivate: () => { + onFilterOptionChange({ + filterId: fieldKey, + selectedOptionKeys: [], + customFieldType: type, + }); + }, + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { + const onCustomFieldChange = ({ + filterId, + selectedOptionKeys, + }: { + filterId: string; + selectedOptionKeys: string[]; + }) => { + onChange({ + filterId: filterId.replace(CUSTOM_FIELD_KEY_PREFIX, ''), + selectedOptionKeys, + customFieldType: type, + }); + }; + + return ( + ({ + key: option.key, + label: option.label, + }))} + selectedOptionKeys={filterOptions.customFields[fieldKey]?.options || []} + /> + ); + }, + }; +}; + +export const useCustomFieldsFilterConfig = ({ + onFilterOptionChange, +}: { + onFilterOptionChange: FilterChangeHandler; +}) => { const [filterConfig, setFilterConfig] = useState([]); const { @@ -27,52 +88,20 @@ export const useCustomFieldsFilterConfig = () => { if (customFieldsBuilder[type]) { const { filterOptions: customFieldOptions = [] } = customFieldsBuilder[type](); - customFieldsFilterConfig.push({ - 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: type === CustomFieldTypes.TOGGLE, - label: buttonLabel, - deactivate: ({ onChange }) => { - onChange({ - filterId: fieldKey, - selectedOptionKeys: [], - customFieldType: type, - }); - }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { - const onCustomFieldChange = ({ - filterId, - selectedOptionKeys, - }: { - filterId: string; - selectedOptionKeys: string[]; - }) => { - onChange({ - filterId: filterId.replace(CUSTOM_FIELD_KEY_PREFIX, ''), - selectedOptionKeys, - customFieldType: type, - }); - }; - - return ( - ({ - key: option.key, - label: option.label, - }))} - selectedOptionKeys={filterOptions.customFields[fieldKey]?.options || []} - /> - ); - }, - }); + customFieldsFilterConfig.push( + customFieldFilterOptionFactory({ + fieldKey, + buttonLabel, + type, + onFilterOptionChange, + customFieldOptions, + }) + ); } } setFilterConfig(customFieldsFilterConfig); - }, [customFields]); + }, [customFields, onFilterOptionChange]); return { customFieldsFilterConfig: filterConfig }; }; 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 index 2161d8b970fdb..2af63e35393a2 100644 --- 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 @@ -39,7 +39,7 @@ export const useFilterConfig = ({ onFilterOptionChange: FilterChangeHandler; }) => { const { appId } = useCasesContext(); - const { customFieldsFilterConfig } = useCustomFieldsFilterConfig(); + const { customFieldsFilterConfig } = useCustomFieldsFilterConfig({ onFilterOptionChange }); const [filters, setFilters] = useState>( () => new Map([...systemFilterConfig].map((filter) => [filter.key, filter])) ); @@ -89,11 +89,9 @@ export const useFilterConfig = ({ } }); - deactivatedFilters - .filter((key) => filters.has(key)) - .forEach((key) => { - (filters.get(key) as FilterConfig).deactivate({ onChange: onFilterOptionChange }); - }); + deactivatedFilters.forEach((key) => { + (filters.get(key) as FilterConfig).deactivate(); + }); setFilterVisibilityMap(newFilterVisibilityMap); }; 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 index c087f8315afe7..dfae1c15b0fc6 100644 --- 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 @@ -31,6 +31,7 @@ interface UseFilterConfigProps { hiddenStatuses?: CaseStatuses[]; isLoading: boolean; isSelectorView?: boolean; + onFilterOptionChange: FilterChangeHandler; selectedAssignees: AssigneesFilteringSelection[]; tags: string[]; } @@ -47,6 +48,7 @@ export const getSystemFilterConfig = ({ hiddenStatuses, isLoading, isSelectorView, + onFilterOptionChange, selectedAssignees, tags, }: UseFilterConfigProps): FilterConfig[] => { @@ -56,8 +58,8 @@ export const getSystemFilterConfig = ({ label: i18n.SEVERITY, isActive: true, isAvailable: true, - deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { - onChange({ filterId: 'severity', selectedOptionKeys: [] }); + deactivate: () => { + onFilterOptionChange({ filterId: 'severity', selectedOptionKeys: [] }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( @@ -68,8 +70,8 @@ export const getSystemFilterConfig = ({ label: i18n.STATUS, isActive: true, isAvailable: true, - deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { - onChange({ filterId: 'status', selectedOptionKeys: [] }); + deactivate: () => { + onFilterOptionChange({ filterId: 'status', selectedOptionKeys: [] }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { - // FIXME: not working - onChange({ filterId: 'assignee', selectedOptionKeys: [] }); + deactivate: () => { + handleSelectedAssignees([]); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { return ( @@ -107,8 +108,8 @@ export const getSystemFilterConfig = ({ label: i18n.TAGS, isActive: true, isAvailable: true, - deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { - onChange({ filterId: 'tags', selectedOptionKeys: [] }); + deactivate: () => { + onFilterOptionChange({ filterId: 'tags', selectedOptionKeys: [] }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { - onChange({ filterId: 'category', selectedOptionKeys: [] }); + deactivate: () => { + onFilterOptionChange({ filterId: 'category', selectedOptionKeys: [] }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( 1, - deactivate: ({ onChange }: { onChange: FilterChangeHandler }) => { - // FIXME: test this - onChange({ filterId: 'owner', selectedOptionKeys: availableSolutions }); + deactivate: () => { + onFilterOptionChange({ filterId: 'owner', selectedOptionKeys: availableSolutions }); }, render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { @@ -190,6 +191,7 @@ export const useSystemFilterConfig = ({ hiddenStatuses, isLoading, isSelectorView, + onFilterOptionChange, selectedAssignees, tags, }) @@ -209,6 +211,7 @@ export const useSystemFilterConfig = ({ hiddenStatuses, isLoading, isSelectorView, + onFilterOptionChange, selectedAssignees, tags, }) @@ -225,6 +228,7 @@ export const useSystemFilterConfig = ({ hiddenStatuses, isLoading, isSelectorView, + onFilterOptionChange, selectedAssignees, tags, ]); 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 f6612f8e44909..2e3f4464e6099 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 @@ -65,31 +65,30 @@ const CasesTableFiltersComponent = ({ [selectedAssignees, onFilterChanged] ); - const onFilterOptionChange: FilterChangeHandler = ({ - filterId, - selectedOptionKeys, - customFieldType, - }) => { - const newFilters = customFieldType - ? { - ...filterOptions, - customFields: { - ...filterOptions.customFields, - [filterId]: { - type: customFieldType, - options: selectedOptionKeys, + const onFilterOptionChange: FilterChangeHandler = useCallback( + ({ filterId, selectedOptionKeys, customFieldType }) => { + const newFilters = customFieldType + ? { + ...filterOptions, + customFields: { + ...filterOptions.customFields, + [filterId]: { + type: customFieldType, + options: selectedOptionKeys, + }, }, - }, - } - : { - ...filterOptions, - [filterId]: selectedOptionKeys, - }; + } + : { + ...filterOptions, + [filterId]: selectedOptionKeys, + }; - if (!isEqual(newFilters, filterOptions)) { - onFilterChanged(newFilters); - } - }; + if (!isEqual(newFilters, filterOptions)) { + onFilterChanged(newFilters); + } + }, + [filterOptions, onFilterChanged] + ); const { systemFilterConfig } = useSystemFilterConfig({ availableSolutions, @@ -103,6 +102,7 @@ const CasesTableFiltersComponent = ({ hiddenStatuses, isLoading, isSelectorView, + onFilterOptionChange, selectedAssignees, tags, }); From 7f6bc6822ab0ab9b29e44a6e242a71f5ace64ba6 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Fri, 24 Nov 2023 09:23:01 +0100 Subject: [PATCH 21/32] remove filter when custom field does not exist anymore --- .../table_filter_config/use_filter_config.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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 index 2af63e35393a2..1ca5b44597fc6 100644 --- 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 @@ -60,6 +60,20 @@ export const useFilterConfig = ({ setFilters(newFilters); }, [systemFilterConfig, customFieldsFilterConfig]); + useEffect(() => { + const newFilterKeys = new Map( + [...systemFilterConfig, ...customFieldsFilterConfig] + .filter((filter) => filter.isAvailable) + .map((filter) => [filter.key, filter]) + ); + + filters.forEach((filter) => { + if (!newFilterKeys.has(filter.key)) { + filter.deactivate(); + } + }); + }, [filters, systemFilterConfig, customFieldsFilterConfig]); + const onChange = ({ selectedOptionKeys }: { filterId: string; selectedOptionKeys: string[] }) => { const newFilterVisibilityMap = new Map(filterVisibilityMap); const deactivatedFilters: string[] = []; @@ -89,9 +103,9 @@ export const useFilterConfig = ({ } }); - deactivatedFilters.forEach((key) => { - (filters.get(key) as FilterConfig).deactivate(); - }); + deactivatedFilters + .filter((key) => filters.has(key)) + .forEach((key) => (filters.get(key) as FilterConfig).deactivate()); setFilterVisibilityMap(newFilterVisibilityMap); }; From 61817092f40ceb93689ddd74156a9213f66b4ca4 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Fri, 24 Nov 2023 10:23:50 +0100 Subject: [PATCH 22/32] remove keys in storage that does not exist + refactor merge fn --- .../table_filter_config/use_filter_config.tsx | 133 ++++++++++-------- 1 file changed, 71 insertions(+), 62 deletions(-) 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 index 1ca5b44597fc6..9ef9668e93fce 100644 --- 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 @@ -13,22 +13,20 @@ import { useCustomFieldsFilterConfig } from './use_custom_fields_filter_config'; import { MoreFiltersSelectable } from './more_filters_selectable'; import { useCasesContext } from '../../cases_context/use_cases_context'; -const serializeFilterVisibilityMap = (value: Map) => { - return JSON.stringify( - Array.from(value.entries()).map(([key, filter]) => ({ - key, - isActive: filter.isActive, - })) +const mergeSystemAndCustomFieldConfigs = ({ + systemFilterConfig, + customFieldsFilterConfig, +}: { + systemFilterConfig: FilterConfig[]; + customFieldsFilterConfig: FilterConfig[]; +}) => { + const newFilterConfig = new Map( + [...systemFilterConfig, ...customFieldsFilterConfig] + .filter((filter) => filter.isAvailable) + .map((filter) => [filter.key, filter]) ); -}; -const deserializeFilterVisibilityMap = (value: string): Map => { - return new Map( - JSON.parse(value).map(({ key, isActive }: { key: string; isActive: boolean }) => [ - key, - { key, isActive }, - ]) - ); + return newFilterConfig; }; export const useFilterConfig = ({ @@ -40,77 +38,86 @@ export const useFilterConfig = ({ }) => { const { appId } = useCasesContext(); const { customFieldsFilterConfig } = useCustomFieldsFilterConfig({ onFilterOptionChange }); - const [filters, setFilters] = useState>( + const [filterConfigs, setFilterConfigs] = useState>( () => new Map([...systemFilterConfig].map((filter) => [filter.key, filter])) ); - const [filterVisibilityMap, setFilterVisibilityMap] = useLocalStorage< - Map - >(`${appId}.${LOCAL_STORAGE_KEYS.casesTableFiltersConfig}`, new Map(), { - raw: false, - serializer: serializeFilterVisibilityMap, - deserializer: deserializeFilterVisibilityMap, - }); - - useEffect(() => { - const newFilters = new Map( - [...systemFilterConfig, ...customFieldsFilterConfig] - .filter((filter) => filter.isAvailable) - .map((filter) => [filter.key, filter]) - ); - setFilters(newFilters); - }, [systemFilterConfig, customFieldsFilterConfig]); + const [activeByFilterKey, setActiveByFilterKey] = useLocalStorage( + `${appId}.${LOCAL_STORAGE_KEYS.casesTableFiltersConfig}`, + [] + ); + // this effect is used to clean up filters that are no longer available useEffect(() => { - const newFilterKeys = new Map( - [...systemFilterConfig, ...customFieldsFilterConfig] - .filter((filter) => filter.isAvailable) - .map((filter) => [filter.key, filter]) - ); + const newFilterConfig = mergeSystemAndCustomFieldConfigs({ + systemFilterConfig, + customFieldsFilterConfig, + }); - filters.forEach((filter) => { - if (!newFilterKeys.has(filter.key)) { + filterConfigs.forEach((filter) => { + if (!newFilterConfig.has(filter.key)) { filter.deactivate(); } }); - }, [filters, systemFilterConfig, customFieldsFilterConfig]); + }, [filterConfigs, systemFilterConfig, customFieldsFilterConfig]); + + useEffect(() => { + setFilterConfigs( + mergeSystemAndCustomFieldConfigs({ + systemFilterConfig, + customFieldsFilterConfig, + }) + ); + }, [systemFilterConfig, customFieldsFilterConfig]); const onChange = ({ selectedOptionKeys }: { filterId: string; selectedOptionKeys: string[] }) => { - const newFilterVisibilityMap = new Map(filterVisibilityMap); + const newActiveByFilterKey = [...(activeByFilterKey || [])]; const deactivatedFilters: string[] = []; - newFilterVisibilityMap.forEach(({ key, isActive }) => { - if (selectedOptionKeys.includes(key)) { - if (!isActive) { - newFilterVisibilityMap.delete(key); - newFilterVisibilityMap.set(key, { key, isActive: true }); + // for each filter in the current state, this way we keep the order + (activeByFilterKey || []).forEach(({ key, isActive: prevIsActive }, currentIndex) => { + 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 { - deactivatedFilters.push(key); - newFilterVisibilityMap.set(key, { key, isActive: false }); + // clean up filters that are no longer available + newActiveByFilterKey.splice(currentIndex, 1); } }); - filters.forEach(({ key }) => { - if (!newFilterVisibilityMap.has(key)) { - const isActive = selectedOptionKeys.includes(key); + // 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) { - deactivatedFilters.push(key); + // for system filter that is removed as first action + deactivatedFilters.push(configKey); } - newFilterVisibilityMap.set(key, { - key, - isActive, + newActiveByFilterKey.push({ + key: configKey, + isActive: Boolean(isActive), }); } }); deactivatedFilters - .filter((key) => filters.has(key)) - .forEach((key) => (filters.get(key) as FilterConfig).deactivate()); + .filter((key) => filterConfigs.has(key)) + .forEach((key) => (filterConfigs.get(key) as FilterConfig).deactivate()); - setFilterVisibilityMap(newFilterVisibilityMap); + setActiveByFilterKey(newActiveByFilterKey); }; - const selectableOptions = Array.from(filters.values()) + const selectableOptions = Array.from(filterConfigs.values()) .map(({ key, label }) => ({ key, label, @@ -121,10 +128,12 @@ export const useFilterConfig = ({ return a.key > b.key ? 1 : -1; }); const source = - filterVisibilityMap && filterVisibilityMap.size > 0 ? filterVisibilityMap : filters; - const activeFilters = Array.from(source.values()) - .filter((filter) => filter.isActive && filters.has(filter.key)) - .map((filter) => filters.get(filter.key)) as FilterConfig[]; + activeByFilterKey && activeByFilterKey.length > 0 + ? activeByFilterKey + : Array.from(filterConfigs.values()); + 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 { From 24d6e7903af5e22b4574acd7001f34379736c28e Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Mon, 27 Nov 2023 08:51:26 +0100 Subject: [PATCH 23/32] filter out unavailable filters in hook --- .../all_cases/table_filter_config/use_filter_config.tsx | 9 +++++---- .../table_filter_config/use_system_filter_config.tsx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) 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 index 9ef9668e93fce..fc8713b94889e 100644 --- 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 @@ -117,7 +117,10 @@ export const useFilterConfig = ({ setActiveByFilterKey(newActiveByFilterKey); }; - const selectableOptions = Array.from(filterConfigs.values()) + const filterConfigArray = Array.from(filterConfigs.values()).filter( + (filter) => filter.isAvailable + ); + const selectableOptions = filterConfigArray .map(({ key, label }) => ({ key, label, @@ -128,9 +131,7 @@ export const useFilterConfig = ({ return a.key > b.key ? 1 : -1; }); const source = - activeByFilterKey && activeByFilterKey.length > 0 - ? activeByFilterKey - : Array.from(filterConfigs.values()); + 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[]; 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 index dfae1c15b0fc6..70567fbbddd52 100644 --- 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 @@ -159,7 +159,7 @@ export const getSystemFilterConfig = ({ /> ), }, - ].filter((filter) => filter.isAvailable) as FilterConfig[]; + ]; }; export const useSystemFilterConfig = ({ From 177f26cf22b8155bd007a5e4b831e7504b733d9c Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Mon, 27 Nov 2023 12:10:22 +0100 Subject: [PATCH 24/32] test dissappearing filter effect + smaller changes --- .../public/common/mock/test_providers.tsx | 4 +- .../use_filter_config.test.tsx | 65 +++++++++++++++++++ .../table_filter_config/use_filter_config.tsx | 2 +- .../use_system_filter_config.tsx | 14 ++-- 4 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx 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/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..5bd471e90bf06 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx @@ -0,0 +1,65 @@ +/* + * 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 deactivateCb = jest.fn(); + const filters: FilterConfig[] = [ + { + key: 'severity', + label: 'Severity', + isActive: true, + isAvailable: true, + deactivate: deactivateCb, + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => null, + }, + ]; + + const { rerender } = renderHook(useFilterConfig, { + wrapper: ({ children }) => {children}, + initialProps: { + systemFilterConfig: filters, + onFilterOptionChange: () => {}, + }, + }); + + expect(deactivateCb).not.toHaveBeenCalled(); + rerender({ systemFilterConfig: [], onFilterOptionChange: () => {} }); + expect(deactivateCb).toHaveBeenCalled(); + }); +}); 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 index fc8713b94889e..d87f742d235d0 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; import { LOCAL_STORAGE_KEYS } from '../../../../common/constants'; import type { FilterChangeHandler, FilterConfig, FilterConfigState } from './types'; 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 index 70567fbbddd52..aa4dbfb4f6335 100644 --- 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 @@ -17,7 +17,7 @@ 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'; +import type { FilterChangeHandler, FilterConfig } from './types'; interface UseFilterConfigProps { availableSolutions: string[]; @@ -61,7 +61,7 @@ export const getSystemFilterConfig = ({ deactivate: () => { onFilterOptionChange({ filterId: 'severity', selectedOptionKeys: [] }); }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( + render: ({ filterOptions, onChange }) => ( ), }, @@ -73,7 +73,7 @@ export const getSystemFilterConfig = ({ deactivate: () => { onFilterOptionChange({ filterId: 'status', selectedOptionKeys: [] }); }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( + render: ({ filterOptions, onChange }) => ( { handleSelectedAssignees([]); }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { + render: ({ filterOptions, onChange }) => { return ( { onFilterOptionChange({ filterId: 'tags', selectedOptionKeys: [] }); }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( + render: ({ filterOptions, onChange }) => ( { onFilterOptionChange({ filterId: 'category', selectedOptionKeys: [] }); }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( + render: ({ filterOptions, onChange }) => ( { onFilterOptionChange({ filterId: 'owner', selectedOptionKeys: availableSolutions }); }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( + render: ({ filterOptions, onChange }) => ( Date: Mon, 27 Nov 2023 12:18:38 +0100 Subject: [PATCH 25/32] remove not needed comment --- .../all_cases/table_filter_config/use_filter_config.tsx | 1 - 1 file changed, 1 deletion(-) 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 index d87f742d235d0..7bfee49539f01 100644 --- 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 @@ -46,7 +46,6 @@ export const useFilterConfig = ({ [] ); - // this effect is used to clean up filters that are no longer available useEffect(() => { const newFilterConfig = mergeSystemAndCustomFieldConfigs({ systemFilterConfig, From ff764d0bc8351b208ab15dba34fbd279ab029cc9 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Mon, 27 Nov 2023 12:52:35 +0100 Subject: [PATCH 26/32] set back available filtering --- .../table_filter_config/use_filter_config.tsx | 4 +--- .../use_system_filter_config.tsx | 16 ++++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) 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 index 7bfee49539f01..2153fe30b2fc4 100644 --- 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 @@ -116,9 +116,7 @@ export const useFilterConfig = ({ setActiveByFilterKey(newActiveByFilterKey); }; - const filterConfigArray = Array.from(filterConfigs.values()).filter( - (filter) => filter.isAvailable - ); + const filterConfigArray = Array.from(filterConfigs.values()); const selectableOptions = filterConfigArray .map(({ key, label }) => ({ key, 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 index aa4dbfb4f6335..dfae1c15b0fc6 100644 --- 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 @@ -17,7 +17,7 @@ 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 } from './types'; +import type { FilterChangeHandler, FilterConfig, FilterConfigRenderParams } from './types'; interface UseFilterConfigProps { availableSolutions: string[]; @@ -61,7 +61,7 @@ export const getSystemFilterConfig = ({ deactivate: () => { onFilterOptionChange({ filterId: 'severity', selectedOptionKeys: [] }); }, - render: ({ filterOptions, onChange }) => ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( ), }, @@ -73,7 +73,7 @@ export const getSystemFilterConfig = ({ deactivate: () => { onFilterOptionChange({ filterId: 'status', selectedOptionKeys: [] }); }, - render: ({ filterOptions, onChange }) => ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { handleSelectedAssignees([]); }, - render: ({ filterOptions, onChange }) => { + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { return ( { onFilterOptionChange({ filterId: 'tags', selectedOptionKeys: [] }); }, - render: ({ filterOptions, onChange }) => ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { onFilterOptionChange({ filterId: 'category', selectedOptionKeys: [] }); }, - render: ({ filterOptions, onChange }) => ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( { onFilterOptionChange({ filterId: 'owner', selectedOptionKeys: availableSolutions }); }, - render: ({ filterOptions, onChange }) => ( + render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( ), }, - ]; + ].filter((filter) => filter.isAvailable) as FilterConfig[]; }; export const useSystemFilterConfig = ({ From 4ac9a16771a11755e3c55d45a8ed3fd4e6afe7fb Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 28 Nov 2023 14:27:19 +0100 Subject: [PATCH 27/32] fix more than one field removed but + comments --- .../table_filter_config/use_filter_config.tsx | 18 ++++++- .../use_system_filter_config.tsx | 1 + .../all_cases/table_filters.test.tsx | 49 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) 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 index 2153fe30b2fc4..64be7ba7b2f3b 100644 --- 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 @@ -41,11 +41,19 @@ export const useFilterConfig = ({ 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, @@ -59,6 +67,11 @@ export const useFilterConfig = ({ }); }, [filterConfigs, systemFilterConfig, customFieldsFilterConfig]); + /** + * 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({ @@ -73,7 +86,8 @@ export const useFilterConfig = ({ const deactivatedFilters: string[] = []; // for each filter in the current state, this way we keep the order - (activeByFilterKey || []).forEach(({ key, isActive: prevIsActive }, currentIndex) => { + (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) { @@ -86,7 +100,7 @@ export const useFilterConfig = ({ newActiveByFilterKey[currentIndex] = { key, isActive: false }; } } else { - // clean up filters that are no longer available + // we might have in local storage a key of a field that don't exist anymore newActiveByFilterKey.splice(currentIndex, 1); } }); 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 index dfae1c15b0fc6..1f084eae25559 100644 --- 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 @@ -108,6 +108,7 @@ export const getSystemFilterConfig = ({ label: i18n.TAGS, isActive: true, isAvailable: true, + deactivate: () => { onFilterOptionChange({ filterId: 'tags', selectedOptionKeys: [] }); }, 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 4735343e8959a..791b9912b3851 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 @@ -763,5 +763,54 @@ describe('CasesTableFilters ', () => { 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", + }, + ] + `); + }); }); }); From 4b260200ecd4475b6bb94941cfae1df967251d4a Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 28 Nov 2023 17:45:56 +0100 Subject: [PATCH 28/32] remove more button from selector view + pr review --- .../use_custom_fields_filter_config.tsx | 45 +++++++++++-------- .../use_filter_config.test.tsx | 3 +- .../table_filter_config/use_filter_config.tsx | 11 +++-- .../all_cases/table_filters.test.tsx | 5 +++ .../components/all_cases/table_filters.tsx | 14 +++--- .../components/custom_fields/translations.ts | 7 --- 6 files changed, 49 insertions(+), 36 deletions(-) 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 index 92724442e0060..04d078d6f8b19 100644 --- 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 @@ -6,7 +6,7 @@ */ import React, { useState, useEffect } from 'react'; -import { CustomFieldTypes } from '../../../../common/types/domain'; +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'; @@ -15,23 +15,25 @@ import { MultiSelectFilter } from '../multi_select_filter'; export const CUSTOM_FIELD_KEY_PREFIX = 'cf_'; interface CustomFieldFilterOptionFactoryProps { - fieldKey: string; buttonLabel: string; - type: CustomFieldTypes; - onFilterOptionChange: FilterChangeHandler; customFieldOptions: Array<{ key: string; label: string }>; + fieldKey: string; + isSelectorView: boolean; + onFilterOptionChange: FilterChangeHandler; + type: CustomFieldTypes; } const customFieldFilterOptionFactory = ({ - fieldKey, buttonLabel, - type, - onFilterOptionChange, customFieldOptions, + fieldKey, + isSelectorView, + onFilterOptionChange, + 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: type === CustomFieldTypes.TOGGLE, + isAvailable: !isSelectorView, label: buttonLabel, deactivate: () => { onFilterOptionChange({ @@ -72,8 +74,10 @@ const customFieldFilterOptionFactory = ({ }; export const useCustomFieldsFilterConfig = ({ + isSelectorView, onFilterOptionChange, }: { + isSelectorView: boolean; onFilterOptionChange: FilterChangeHandler; }) => { const [filterConfig, setFilterConfig] = useState([]); @@ -86,22 +90,25 @@ export const useCustomFieldsFilterConfig = ({ const customFieldsFilterConfig: FilterConfig[] = []; for (const { key: fieldKey, type, label: buttonLabel } of customFields ?? []) { if (customFieldsBuilder[type]) { - const { filterOptions: customFieldOptions = [] } = customFieldsBuilder[type](); + const { filterOptions: customFieldOptions } = customFieldsBuilder[type](); - customFieldsFilterConfig.push( - customFieldFilterOptionFactory({ - fieldKey, - buttonLabel, - type, - onFilterOptionChange, - customFieldOptions, - }) - ); + if (customFieldOptions) { + customFieldsFilterConfig.push( + customFieldFilterOptionFactory({ + buttonLabel, + customFieldOptions, + fieldKey, + isSelectorView, + onFilterOptionChange, + type, + }) + ); + } } } setFilterConfig(customFieldsFilterConfig); - }, [customFields, onFilterOptionChange]); + }, [customFields, isSelectorView, onFilterOptionChange]); 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 index 5bd471e90bf06..04d7fcf83534f 100644 --- 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 @@ -55,11 +55,12 @@ describe('useFilterConfig', () => { initialProps: { systemFilterConfig: filters, onFilterOptionChange: () => {}, + isSelectorView: false, }, }); expect(deactivateCb).not.toHaveBeenCalled(); - rerender({ systemFilterConfig: [], onFilterOptionChange: () => {} }); + rerender({ systemFilterConfig: [], onFilterOptionChange: () => {}, isSelectorView: false }); expect(deactivateCb).toHaveBeenCalled(); }); }); 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 index 64be7ba7b2f3b..eba2061617b5d 100644 --- 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 @@ -30,14 +30,19 @@ const mergeSystemAndCustomFieldConfigs = ({ }; export const useFilterConfig = ({ - systemFilterConfig, + isSelectorView, onFilterOptionChange, + systemFilterConfig, }: { - systemFilterConfig: FilterConfig[]; + isSelectorView: boolean; onFilterOptionChange: FilterChangeHandler; + systemFilterConfig: FilterConfig[]; }) => { const { appId } = useCasesContext(); - const { customFieldsFilterConfig } = useCustomFieldsFilterConfig({ onFilterOptionChange }); + const { customFieldsFilterConfig } = useCustomFieldsFilterConfig({ + onFilterOptionChange, + isSelectorView, + }); const [filterConfigs, setFilterConfigs] = useState>( () => new Map([...systemFilterConfig].map((filter) => [filter.key, filter])) ); 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 791b9912b3851..1b40bb0a4d657 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 @@ -464,6 +464,11 @@ describe('CasesTableFilters ', () => { 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(); 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 2e3f4464e6099..daf682c4ae618 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 @@ -113,7 +113,7 @@ const CasesTableFiltersComponent = ({ selectableOptions, activeSelectableOptionKeys, onFilterConfigChange, - } = useFilterConfig({ systemFilterConfig, onFilterOptionChange }); + } = useFilterConfig({ systemFilterConfig, onFilterOptionChange, isSelectorView }); const handleOnSearch = useCallback( (newSearch) => { @@ -163,11 +163,13 @@ const CasesTableFiltersComponent = ({ {filter.render({ filterOptions, onChange: onFilterOptionChange })} ))} - + {isSelectorView || ( + + )} diff --git a/x-pack/plugins/cases/public/components/custom_fields/translations.ts b/x-pack/plugins/cases/public/components/custom_fields/translations.ts index e60f6d4dcee81..e26e204422b6c 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/translations.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/translations.ts @@ -112,10 +112,3 @@ export const TOGGLE_FIELD_OFF_LABEL = i18n.translate( defaultMessage: 'Off', } ); - -export const TOGGLE_FIELD_UNSET_LABEL = i18n.translate( - 'xpack.cases.customFields.tableFilters.toggle.unset', - { - defaultMessage: 'Unset', - } -); From 8b1f35110813778c2140585b8a21c29930c3ec4b Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:03:22 +0100 Subject: [PATCH 29/32] pr review --- .../use_custom_fields_filter_config.tsx | 7 +++---- .../all_cases/table_filter_config/use_filter_config.tsx | 2 -- .../cases/public/components/all_cases/table_filters.tsx | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) 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 index 04d078d6f8b19..97c392a49d790 100644 --- 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 @@ -18,7 +18,6 @@ interface CustomFieldFilterOptionFactoryProps { buttonLabel: string; customFieldOptions: Array<{ key: string; label: string }>; fieldKey: string; - isSelectorView: boolean; onFilterOptionChange: FilterChangeHandler; type: CustomFieldTypes; } @@ -26,14 +25,13 @@ const customFieldFilterOptionFactory = ({ buttonLabel, customFieldOptions, fieldKey, - isSelectorView, onFilterOptionChange, 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: !isSelectorView, + isAvailable: true, label: buttonLabel, deactivate: () => { onFilterOptionChange({ @@ -87,6 +85,8 @@ export const useCustomFieldsFilterConfig = ({ } = useGetCaseConfiguration(); useEffect(() => { + if (isSelectorView) return; + const customFieldsFilterConfig: FilterConfig[] = []; for (const { key: fieldKey, type, label: buttonLabel } of customFields ?? []) { if (customFieldsBuilder[type]) { @@ -98,7 +98,6 @@ export const useCustomFieldsFilterConfig = ({ buttonLabel, customFieldOptions, fieldKey, - isSelectorView, onFilterOptionChange, type, }) 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 index eba2061617b5d..d3731e857052e 100644 --- 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 @@ -10,7 +10,6 @@ import useLocalStorage from 'react-use/lib/useLocalStorage'; import { LOCAL_STORAGE_KEYS } from '../../../../common/constants'; import type { FilterChangeHandler, FilterConfig, FilterConfigState } from './types'; import { useCustomFieldsFilterConfig } from './use_custom_fields_filter_config'; -import { MoreFiltersSelectable } from './more_filters_selectable'; import { useCasesContext } from '../../cases_context/use_cases_context'; const mergeSystemAndCustomFieldConfigs = ({ @@ -156,7 +155,6 @@ export const useFilterConfig = ({ return { activeSelectableOptionKeys: activeFilterKeys, filters: activeFilters, - moreFiltersSelectableComponent: MoreFiltersSelectable, onFilterConfigChange: onChange, selectableOptions, }; 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 daf682c4ae618..5d8f319d62599 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,6 +8,7 @@ import React, { useCallback, useState } from 'react'; import { isEqual } from 'lodash/fp'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; +import { MoreFiltersSelectable } from './table_filter_config/more_filters_selectable'; import type { CaseStatuses } from '../../../common/types/domain'; import type { FilterOptions } from '../../containers/types'; import * as i18n from './translations'; @@ -109,7 +110,6 @@ const CasesTableFiltersComponent = ({ const { filters: activeFilters, - moreFiltersSelectableComponent: MoreFiltersSelectable, selectableOptions, activeSelectableOptionKeys, onFilterConfigChange, From 6b5f78b63ed1f48942ebf033fef422dcadab2cd9 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 29 Nov 2023 12:50:13 +0100 Subject: [PATCH 30/32] review --- .../all_cases/all_cases_list.test.tsx | 4 + .../components/all_cases/all_cases_list.tsx | 3 + .../all_cases/table_filter_config/types.ts | 10 +- .../use_custom_fields_filter_config.tsx | 40 ++++---- .../use_filter_config.test.tsx | 30 ++++-- .../table_filter_config/use_filter_config.tsx | 29 ++++-- .../use_system_filter_config.tsx | 94 +++++++++++++------ .../all_cases/table_filters.test.tsx | 26 +++++ .../components/all_cases/table_filters.tsx | 44 ++++----- 9 files changed, 183 insertions(+), 97 deletions(-) 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/table_filter_config/types.ts b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/types.ts index bcd33198b7a2b..368c86d545bff 100644 --- 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 @@ -5,7 +5,6 @@ * 2.0. */ -import type { CustomFieldTypes } from '../../../../common/types/domain'; import type { FilterOptions } from '../../../../common/ui'; export interface FilterConfigState { @@ -13,15 +12,10 @@ export interface FilterConfigState { isActive: boolean; } -export type FilterChangeHandler = (params: { - filterId: string; - selectedOptionKeys: string[]; - customFieldType?: CustomFieldTypes; -}) => void; +export type FilterChangeHandler = (params: Partial) => void; export interface FilterConfigRenderParams { filterOptions: FilterOptions; - onChange: FilterChangeHandler; } export interface FilterConfig { @@ -29,6 +23,6 @@ export interface FilterConfig { label: string; isActive: boolean; isAvailable: boolean; - deactivate: () => void; + 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 index 97c392a49d790..3193b1a1cc70f 100644 --- 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 @@ -18,14 +18,14 @@ interface CustomFieldFilterOptionFactoryProps { buttonLabel: string; customFieldOptions: Array<{ key: string; label: string }>; fieldKey: string; - onFilterOptionChange: FilterChangeHandler; + onFilterOptionsChange: FilterChangeHandler; type: CustomFieldTypes; } const customFieldFilterOptionFactory = ({ buttonLabel, customFieldOptions, fieldKey, - onFilterOptionChange, + onFilterOptionsChange, type, }: CustomFieldFilterOptionFactoryProps) => { return { @@ -33,14 +33,17 @@ const customFieldFilterOptionFactory = ({ isActive: false, isAvailable: true, label: buttonLabel, - deactivate: () => { - onFilterOptionChange({ - filterId: fieldKey, - selectedOptionKeys: [], - customFieldType: type, - }); + getEmptyOptions: () => { + return { + customFields: { + [fieldKey]: { + type, + options: [], + }, + }, + }; }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { + render: ({ filterOptions }: FilterConfigRenderParams) => { const onCustomFieldChange = ({ filterId, selectedOptionKeys, @@ -48,10 +51,13 @@ const customFieldFilterOptionFactory = ({ filterId: string; selectedOptionKeys: string[]; }) => { - onChange({ - filterId: filterId.replace(CUSTOM_FIELD_KEY_PREFIX, ''), - selectedOptionKeys, - customFieldType: type, + onFilterOptionsChange({ + customFields: { + [filterId.replace(CUSTOM_FIELD_KEY_PREFIX, '')]: { + options: selectedOptionKeys, + type, + }, + }, }); }; @@ -73,10 +79,10 @@ const customFieldFilterOptionFactory = ({ export const useCustomFieldsFilterConfig = ({ isSelectorView, - onFilterOptionChange, + onFilterOptionsChange, }: { isSelectorView: boolean; - onFilterOptionChange: FilterChangeHandler; + onFilterOptionsChange: FilterChangeHandler; }) => { const [filterConfig, setFilterConfig] = useState([]); @@ -98,7 +104,7 @@ export const useCustomFieldsFilterConfig = ({ buttonLabel, customFieldOptions, fieldKey, - onFilterOptionChange, + onFilterOptionsChange, type, }) ); @@ -107,7 +113,7 @@ export const useCustomFieldsFilterConfig = ({ } setFilterConfig(customFieldsFilterConfig); - }, [customFields, isSelectorView, onFilterOptionChange]); + }, [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 index 04d7fcf83534f..cb4de39427463 100644 --- 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 @@ -38,15 +38,26 @@ describe('useFilterConfig', () => { getCaseConfigureMock.mockReturnValue(() => { return []; }); - const deactivateCb = jest.fn(); + const onFilterOptionsChange = jest.fn(); + const getEmptyOptions = jest.fn().mockReturnValue({ severity: [] }); const filters: FilterConfig[] = [ { key: 'severity', label: 'Severity', isActive: true, isAvailable: true, - deactivate: deactivateCb, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => null, + getEmptyOptions, + render: ({ filterOptions }: FilterConfigRenderParams) => null, + }, + { + key: 'tags', + label: 'Tags', + isActive: true, + isAvailable: true, + getEmptyOptions() { + return { tags: ['initialValue'] }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => null, }, ]; @@ -54,13 +65,18 @@ describe('useFilterConfig', () => { wrapper: ({ children }) => {children}, initialProps: { systemFilterConfig: filters, - onFilterOptionChange: () => {}, + onFilterOptionsChange, isSelectorView: false, }, }); - expect(deactivateCb).not.toHaveBeenCalled(); - rerender({ systemFilterConfig: [], onFilterOptionChange: () => {}, isSelectorView: false }); - expect(deactivateCb).toHaveBeenCalled(); + 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 index d3731e857052e..9c30264b274a4 100644 --- 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 @@ -7,8 +7,10 @@ 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 { FilterChangeHandler, FilterConfig, FilterConfigState } from './types'; +import type { FilterConfig, FilterConfigState } from './types'; import { useCustomFieldsFilterConfig } from './use_custom_fields_filter_config'; import { useCasesContext } from '../../cases_context/use_cases_context'; @@ -30,17 +32,17 @@ const mergeSystemAndCustomFieldConfigs = ({ export const useFilterConfig = ({ isSelectorView, - onFilterOptionChange, + onFilterOptionsChange, systemFilterConfig, }: { isSelectorView: boolean; - onFilterOptionChange: FilterChangeHandler; + onFilterOptionsChange: (params: Partial) => void; systemFilterConfig: FilterConfig[]; }) => { const { appId } = useCasesContext(); const { customFieldsFilterConfig } = useCustomFieldsFilterConfig({ - onFilterOptionChange, isSelectorView, + onFilterOptionsChange, }); const [filterConfigs, setFilterConfigs] = useState>( () => new Map([...systemFilterConfig].map((filter) => [filter.key, filter])) @@ -64,12 +66,18 @@ export const useFilterConfig = ({ customFieldsFilterConfig, }); + const emptyOptions: Array> = []; filterConfigs.forEach((filter) => { if (!newFilterConfig.has(filter.key)) { - filter.deactivate(); + emptyOptions.push(filter.getEmptyOptions()); } }); - }, [filterConfigs, systemFilterConfig, customFieldsFilterConfig]); + + 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 @@ -127,9 +135,14 @@ export const useFilterConfig = ({ } }); - deactivatedFilters + const emptyOptions = deactivatedFilters .filter((key) => filterConfigs.has(key)) - .forEach((key) => (filterConfigs.get(key) as FilterConfig).deactivate()); + .map((key) => (filterConfigs.get(key) as FilterConfig).getEmptyOptions()); + + if (emptyOptions.length > 0) { + const mergedEmptyOptions = merge({}, ...emptyOptions); + onFilterOptionsChange(mergedEmptyOptions); + } setActiveByFilterKey(newActiveByFilterKey); }; 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 index 1f084eae25559..7d50e591d2252 100644 --- 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 @@ -7,6 +7,7 @@ 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'; @@ -29,9 +30,10 @@ interface UseFilterConfigProps { currentUserProfile: CurrentUserProfile; handleSelectedAssignees: (newAssignees: AssigneesFilteringSelection[]) => void; hiddenStatuses?: CaseStatuses[]; + initialFilterOptions: Partial; isLoading: boolean; isSelectorView?: boolean; - onFilterOptionChange: FilterChangeHandler; + onFilterOptionsChange: FilterChangeHandler; selectedAssignees: AssigneesFilteringSelection[]; tags: string[]; } @@ -46,23 +48,40 @@ export const getSystemFilterConfig = ({ currentUserProfile, handleSelectedAssignees, hiddenStatuses, + initialFilterOptions, isLoading, isSelectorView, - onFilterOptionChange, + 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, - deactivate: () => { - onFilterOptionChange({ filterId: 'severity', selectedOptionKeys: [] }); + getEmptyOptions: () => { + return { + severity: initialFilterOptions.severity, + }; }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( - + render: ({ filterOptions }: FilterConfigRenderParams) => ( + ), }, { @@ -70,13 +89,15 @@ export const getSystemFilterConfig = ({ label: i18n.STATUS, isActive: true, isAvailable: true, - deactivate: () => { - onFilterOptionChange({ filterId: 'status', selectedOptionKeys: [] }); + getEmptyOptions: () => { + return { + status: initialFilterOptions.status, + }; }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( + render: ({ filterOptions }: FilterConfigRenderParams) => ( { - handleSelectedAssignees([]); + getEmptyOptions: () => { + return { + assignees: initialFilterOptions.assignees, + }; }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => { + render: ({ filterOptions }: FilterConfigRenderParams) => { return ( { - onFilterOptionChange({ filterId: 'tags', selectedOptionKeys: [] }); + getEmptyOptions: () => { + return { + tags: initialFilterOptions.tags, + }; }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( + render: ({ filterOptions }: FilterConfigRenderParams) => ( @@ -129,16 +153,18 @@ export const getSystemFilterConfig = ({ label: i18n.CATEGORIES, isActive: true, isAvailable: true, - deactivate: () => { - onFilterOptionChange({ filterId: 'category', selectedOptionKeys: [] }); + getEmptyOptions: () => { + return { + category: initialFilterOptions.category, + }; }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( + render: ({ filterOptions }: FilterConfigRenderParams) => ( @@ -149,18 +175,20 @@ export const getSystemFilterConfig = ({ label: i18n.SOLUTION, isActive: true, isAvailable: availableSolutions.length > 1, - deactivate: () => { - onFilterOptionChange({ filterId: 'owner', selectedOptionKeys: availableSolutions }); + getEmptyOptions: () => { + return { + owner: initialFilterOptions.owner, + }; }, - render: ({ filterOptions, onChange }: FilterConfigRenderParams) => ( + render: ({ filterOptions }: FilterConfigRenderParams) => ( ), }, - ].filter((filter) => filter.isAvailable) as FilterConfig[]; + ]; }; export const useSystemFilterConfig = ({ @@ -173,9 +201,10 @@ export const useSystemFilterConfig = ({ currentUserProfile, handleSelectedAssignees, hiddenStatuses, + initialFilterOptions, isLoading, isSelectorView, - onFilterOptionChange, + onFilterOptionsChange, selectedAssignees, tags, }: UseFilterConfigProps) => { @@ -190,9 +219,10 @@ export const useSystemFilterConfig = ({ currentUserProfile, handleSelectedAssignees, hiddenStatuses, + initialFilterOptions, isLoading, isSelectorView, - onFilterOptionChange, + onFilterOptionsChange, selectedAssignees, tags, }) @@ -210,9 +240,10 @@ export const useSystemFilterConfig = ({ currentUserProfile, handleSelectedAssignees, hiddenStatuses, + initialFilterOptions, isLoading, isSelectorView, - onFilterOptionChange, + onFilterOptionsChange, selectedAssignees, tags, }) @@ -227,9 +258,10 @@ export const useSystemFilterConfig = ({ currentUserProfile, handleSelectedAssignees, hiddenStatuses, + initialFilterOptions, isLoading, isSelectorView, - onFilterOptionChange, + onFilterOptionsChange, selectedAssignees, tags, ]); 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 1b40bb0a4d657..cd310cfc1c952 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 @@ -48,6 +48,7 @@ const props = { filterOptions: DEFAULT_FILTER_OPTIONS, availableSolutions: [], isLoading: false, + initialFilterOptions: DEFAULT_FILTER_OPTIONS, currentUserProfile: undefined, }; @@ -253,6 +254,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', () => { @@ -578,6 +598,12 @@ describe('CasesTableFilters ', () => { expect(onFilterChanged).toHaveBeenCalledWith({ ...DEFAULT_FILTER_OPTIONS, status: [], + customFields: { + toggle: { + type: CustomFieldTypes.TOGGLE, + options: [], + }, + }, }); }); 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 5d8f319d62599..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,6 +8,7 @@ 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 type { FilterOptions } from '../../containers/types'; @@ -19,7 +20,6 @@ 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'; -import type { FilterChangeHandler } from './table_filter_config/types'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -30,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, @@ -44,6 +51,7 @@ const CasesTableFiltersComponent = ({ availableSolutions, isSelectorView = false, onCreateCasePressed, + initialFilterOptions, isLoading, currentUserProfile, filterOptions, @@ -66,26 +74,11 @@ const CasesTableFiltersComponent = ({ [selectedAssignees, onFilterChanged] ); - const onFilterOptionChange: FilterChangeHandler = useCallback( - ({ filterId, selectedOptionKeys, customFieldType }) => { - const newFilters = customFieldType - ? { - ...filterOptions, - customFields: { - ...filterOptions.customFields, - [filterId]: { - type: customFieldType, - options: selectedOptionKeys, - }, - }, - } - : { - ...filterOptions, - [filterId]: selectedOptionKeys, - }; - - if (!isEqual(newFilters, filterOptions)) { - onFilterChanged(newFilters); + const onFilterOptionsChange = useCallback( + (partialFilterOptions: Partial) => { + const newFilterOptions = mergeWith({}, filterOptions, partialFilterOptions, mergeCustomizer); + if (!isEqual(newFilterOptions, filterOptions)) { + onFilterChanged(newFilterOptions); } }, [filterOptions, onFilterChanged] @@ -101,9 +94,10 @@ const CasesTableFiltersComponent = ({ currentUserProfile, handleSelectedAssignees, hiddenStatuses, + initialFilterOptions, isLoading, isSelectorView, - onFilterOptionChange, + onFilterOptionsChange, selectedAssignees, tags, }); @@ -113,7 +107,7 @@ const CasesTableFiltersComponent = ({ selectableOptions, activeSelectableOptionKeys, onFilterConfigChange, - } = useFilterConfig({ systemFilterConfig, onFilterOptionChange, isSelectorView }); + } = useFilterConfig({ systemFilterConfig, onFilterOptionsChange, isSelectorView }); const handleOnSearch = useCallback( (newSearch) => { @@ -159,9 +153,7 @@ const CasesTableFiltersComponent = ({ {activeFilters.map((filter) => ( - - {filter.render({ filterOptions, onChange: onFilterOptionChange })} - + {filter.render({ filterOptions })} ))} {isSelectorView || ( Date: Wed, 29 Nov 2023 16:13:57 +0100 Subject: [PATCH 31/32] multi select key type to work with case statuses --- .../all_cases/multi_select_filter.tsx | 40 +++++++++---------- .../components/all_cases/status_filter.tsx | 22 +++++----- .../components/all_cases/translations.ts | 5 +++ .../cases/public/containers/utils.test.ts | 9 ++++- 4 files changed, 44 insertions(+), 32 deletions(-) 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 a649f6a01347c..d6d7756b31adb 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,16 +52,17 @@ 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; @@ -69,11 +70,11 @@ interface UseFilterParams { limit?: number; limitReachedMessage?: string; onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; - options: Array>; + options: Array>; selectedOptionKeys?: string[]; - renderOption?: (option: FilterOption) => React.ReactNode; + renderOption?: (option: FilterOption) => React.ReactNode; } -export const MultiSelectFilter = ({ +export const MultiSelectFilter = ({ buttonLabel, buttonIconType, hideActiveOptionsNumber, @@ -84,16 +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) => @@ -107,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; @@ -153,7 +151,7 @@ export const MultiSelectFilter = ({ )} - > + > options={options} searchable searchProps={{ placeholder: buttonLabel, compressed: false }} 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 b6ce440e81c15..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 { @@ -23,7 +23,11 @@ interface Props { 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, @@ -43,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 ( @@ -62,7 +66,7 @@ export const StatusFilterComponent = ({ ); }; return ( - + { }, 'dbeb8e9c-240b-4adb-b83e-e645e86c07ed': { type: CustomFieldTypes.TOGGLE, - options: ['off', 'unset'], + 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, null], + 'dbeb8e9c-240b-4adb-b83e-e645e86c07ed': [false], + 'e0e8c50a-8d65-4f00-b6f0-d8a131fd34b4': [true, false], }, }); }); From 4d439b6440b73c2dc9b64e67d96a0995003dc9e1 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 29 Nov 2023 16:24:49 +0100 Subject: [PATCH 32/32] search by label test --- .../components/all_cases/multi_select_filter.tsx | 6 +++++- .../components/all_cases/table_filters.test.tsx | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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 d6d7756b31adb..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 @@ -154,7 +154,11 @@ 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/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index cd310cfc1c952..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 @@ -190,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,