From d696e91f97ef5b5aefebfebb407f00e7bbe0d515 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 9 Feb 2024 19:57:28 +0200 Subject: [PATCH] [Cases] Persist the cases table state on the URL (#175237) ## Summary This PR persists the whole state of the cases table in the URL. Specifically: - The format of the URL changed. The Rison format will be used which is widely used in Kibana. - The old format will be supported to support BWC. - All filters are persisted in the URL including custom fields. - The URL is the source of truth and it takes precedence over everything. - The state is also persisted in the local storage. The state from the local storage is loaded only when the URL is empty. - The state in the local storage is stored under a new key called `cases.list.state`. The old key will not be taken into account. - Navigate through pages and back to the cases table do not lose the state of the table. - ## Testing - Test that the filtering is working as expected with all possible filters and values. - Test that the URL is updating correctly as you change the filters and the pagination. - Test that submitting a URL takes priority over the state persisted in the local storage. - Test that submitting a URL changes the filters and the pagination as expected. - Test that submitting a URL makes any hidden filters visible. - Test that legacy URLs are working as expected. - Put malformed values and keys in the URL and see how the application behaves. - Test that the extra query params put by the Security solution persisted and do not affect the cases filtering. - Test that the configuration of the filters (which ones are visible and which ones are hidden) is not affected. - Hide all filters and put a URL that contains all filters. They should be shown. - Remove the local storage state and check how the application behaves when you enter a URL with filters - Ensure that when you navigate between pages your filtering is persisted. - Ensure that the state from the local storage is loaded when you enter a URL without query parameters. - No assignees filtering is working. - Passing non-existing custom fields on the URL does not lead to an error. - Paste a URL that is not the same as the state in the local storage, navigate to a case, and come back to the cases table. The filters set by the URL should be the same. Blocked by: https://github.com/elastic/kibana/pull/176546 Flaky test runner: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5114, https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5119 ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ## Release notes Persist all filter options of the cases table, including custom fields, in the URL. The filtering also persists when navigating back and forth between pages. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/cases/common/constants/index.ts | 3 +- x-pack/plugins/cases/common/ui/types.ts | 18 +- .../all_cases/all_cases_list.test.tsx | 24 +- .../components/all_cases/all_cases_list.tsx | 43 +- .../public/components/all_cases/constants.ts | 18 + .../all_cases/multi_select_filter.test.tsx | 7 + .../all_cases/multi_select_filter.tsx | 7 +- .../components/all_cases/schema.test.ts | 96 +++ .../public/components/all_cases/schema.ts | 46 ++ .../components/all_cases/search.test.tsx | 72 ++ .../public/components/all_cases/search.tsx | 49 ++ .../components/all_cases/severity_filter.tsx | 1 + .../components/all_cases/solution_filter.tsx | 1 + .../components/all_cases/status_filter.tsx | 1 + .../public/components/all_cases/table.tsx | 21 +- .../more_filters_selectable.tsx | 3 + .../use_custom_fields_filter_config.tsx | 21 +- .../use_filter_config.test.tsx | 101 ++- .../table_filter_config/use_filter_config.tsx | 38 +- .../use_system_filter_config.tsx | 2 + .../all_cases/table_filters.test.tsx | 293 ++++--- .../components/all_cases/table_filters.tsx | 57 +- .../components/all_cases/translations.ts | 9 +- .../public/components/all_cases/types.ts | 24 +- .../all_cases/use_all_cases_state.test.tsx | 761 ++++++++++++++---- .../all_cases/use_all_cases_state.tsx | 382 ++++----- .../all_cases/use_cases_columns_selection.tsx | 2 +- .../components/all_cases/utility_bar.test.tsx | 131 +-- .../components/all_cases/utility_bar.tsx | 18 + .../all_cases_url_state_deserializer.test.ts | 217 +++++ .../utils/all_cases_url_state_deserializer.ts | 85 ++ .../all_cases_url_state_serializer.test.ts | 119 +++ .../utils/all_cases_url_state_serializer.ts | 53 ++ .../components/all_cases/utils/index.test.ts | 66 ++ .../components/all_cases/utils/index.ts | 76 +- ...rge_selected_columns_with_configuration.ts | 52 ++ .../all_cases/utils/parse_url_params.test.tsx | 254 ++++++ .../all_cases/utils/parse_url_params.tsx | 129 +++ .../parse_url_with_filter_options.test.tsx | 56 -- .../utils/parse_url_with_filter_options.tsx | 42 - .../utils/sanitize_filter_options.test.tsx | 46 -- .../utils/sanitize_filter_options.tsx | 37 - .../all_cases/utils/sanitize_state.test.ts | 55 ++ .../all_cases/utils/sanitize_state.ts | 78 ++ .../utils/serialize_url_params.test.tsx | 77 -- .../all_cases/utils/serialize_url_params.tsx | 27 - .../utils/stringify_url_params.test.tsx | 131 +++ .../all_cases/utils/stringify_url_params.tsx | 35 + .../components/all_cases/utils/utils.test.tsx | 71 -- .../plugins/cases/public/components/utils.ts | 1 + .../cases/public/containers/constants.ts | 7 + x-pack/plugins/cases/tsconfig.json | 1 + .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - x-pack/test/functional/services/cases/list.ts | 68 ++ .../functional/services/cases/navigation.ts | 8 +- .../apps/cases/group2/list_view.ts | 288 ++++++- 58 files changed, 3228 insertions(+), 1103 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/all_cases/constants.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/schema.test.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/schema.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/search.test.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/search.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.test.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_serializer.test.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_serializer.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/index.test.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/merge_selected_columns_with_configuration.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/parse_url_params.test.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/parse_url_params.tsx delete mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.test.tsx delete mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.tsx delete mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.test.tsx delete mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/sanitize_state.test.ts create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/sanitize_state.ts delete mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.test.tsx delete mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/stringify_url_params.test.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/stringify_url_params.tsx delete mode 100644 x-pack/plugins/cases/public/components/all_cases/utils/utils.test.tsx diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 50dda0c185176..b4a21607a293c 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -201,10 +201,9 @@ export const SEARCH_DEBOUNCE_MS = 500; * Local storage keys */ export const LOCAL_STORAGE_KEYS = { - casesQueryParams: 'cases.list.queryParams', - casesFilterOptions: 'cases.list.filterOptions', casesTableColumns: 'cases.list.tableColumns', casesTableFiltersConfig: 'cases.list.tableFiltersConfig', + casesTableState: 'cases.list.state', }; /** diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 2ab04f058179d..a6e747ac6e85b 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -137,18 +137,6 @@ export interface QueryParams extends SortingParams { page: number; perPage: number; } -export type PartialQueryParams = Partial; - -export interface UrlQueryParams extends SortingParams { - page: string; - perPage: string; -} - -export interface ParsedUrlQueryParams extends Partial { - [index: string]: string | string[] | undefined | null; -} - -export type LocalStorageQueryParams = Partial>; export interface SystemFilterOptions { search: string; @@ -171,11 +159,13 @@ export interface FilterOptions extends SystemFilterOptions { }; } -export type PartialFilterOptions = Partial; - export type SingleCaseMetrics = SingleCaseMetricsResponse; export type SingleCaseMetricsFeature = Exclude; +/** + * If you add a new value here and you want to support it on the URL + * you have to also add it here x-pack/plugins/cases/public/components/all_cases/schema.ts + */ export enum SortFieldCase { closedAt = 'closedAt', createdAt = 'createdAt', 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 028849f48fdb4..6e96596a41ab9 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 @@ -39,7 +39,11 @@ import { useGetTags } from '../../containers/use_get_tags'; import { useGetCategories } from '../../containers/use_get_categories'; import { useUpdateCase } from '../../containers/use_update_case'; import { useGetCases } from '../../containers/use_get_cases'; -import { DEFAULT_QUERY_PARAMS, DEFAULT_FILTER_OPTIONS } from '../../containers/constants'; +import { + DEFAULT_QUERY_PARAMS, + DEFAULT_FILTER_OPTIONS, + DEFAULT_CASES_TABLE_STATE, +} from '../../containers/constants'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; @@ -202,7 +206,9 @@ describe('AllCasesListGeneric', () => { expect(screen.getByTestId('case-table-case-count')).toHaveTextContent( `Showing 10 of ${useGetCasesMockState.data.total} cases` ); + expect(screen.queryByTestId('all-cases-maximum-limit-warning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('all-cases-clear-filters-link-icon')).not.toBeInTheDocument(); }); }); @@ -643,6 +649,22 @@ describe('AllCasesListGeneric', () => { expect(alertCounts.length).toBeGreaterThan(0); }); + it('should clear the filters correctly', async () => { + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true }); + + appMockRenderer.render(); + + userEvent.click(await screen.findByTestId('options-filter-popover-button-category')); + await waitForEuiPopoverOpen(); + userEvent.click(await screen.findByTestId('options-filter-popover-item-twix')); + + userEvent.click(await screen.findByTestId('all-cases-clear-filters-link-icon')); + + await waitFor(() => { + expect(useGetCasesMock).toHaveBeenLastCalledWith(DEFAULT_CASES_TABLE_STATE); + }); + }); + describe('Solutions', () => { it('should hide the solutions filter if the owner is provided', async () => { const { queryByTestId } = render( 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 735ff95a5edf7..ea4810b5a8db3 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 @@ -8,18 +8,17 @@ import React, { useCallback, useMemo, useState } from 'react'; import type { EuiTableSelectionType } from '@elastic/eui'; import { EuiProgress } from '@elastic/eui'; -import { difference, head, isEmpty } from 'lodash/fp'; import styled, { css } from 'styled-components'; +import deepEqual from 'react-fast-compare'; import type { CaseUI, FilterOptions, CasesUI } from '../../../common/ui/types'; import type { EuiBasicTableOnChange } from './types'; import { SortFieldCase } from '../../../common/ui/types'; import type { CaseStatuses } from '../../../common/types/domain'; -import { caseStatuses } from '../../../common/types/domain'; import { useCasesColumns } from './use_cases_columns'; import { CasesTableFilters } from './table_filters'; -import { CASES_TABLE_PERPAGE_VALUES } from './types'; +import { CASES_TABLE_PER_PAGE_VALUES } from './types'; import { CasesTable } from './table'; import { useCasesContext } from '../cases_context/use_cases_context'; import { CasesMetrics } from './cases_metrics'; @@ -32,6 +31,8 @@ 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_CASES_TABLE_STATE } from '../../containers/constants'; +import { CasesTableUtilityBar } from './utility_bar'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -64,15 +65,9 @@ export const AllCasesList = React.memo( const hasOwner = !!owner.length; - const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses)); - const initialFilterOptions = { - ...(!isEmpty(hiddenStatuses) && firstAvailableStatus && { status: [firstAvailableStatus] }), - }; + const { queryParams, setQueryParams, filterOptions, setFilterOptions } = + useAllCasesState(isSelectorView); - const { queryParams, setQueryParams, filterOptions, setFilterOptions } = useAllCasesState( - isSelectorView, - initialFilterOptions - ); const [selectedCases, setSelectedCases] = useState([]); const { data = initialData, isFetching: isLoadingCases } = useGetCases({ @@ -164,7 +159,7 @@ export const AllCasesList = React.memo( pageIndex: queryParams.page - 1, pageSize: queryParams.perPage, totalItemCount: data.total ?? 0, - pageSizeOptions: CASES_TABLE_PERPAGE_VALUES, + pageSizeOptions: CASES_TABLE_PER_PAGE_VALUES, }), [data, queryParams] ); @@ -190,6 +185,15 @@ export const AllCasesList = React.memo( onRowClick?.(undefined, true); }, [onRowClick]); + const onClearFilters = useCallback(() => { + setFilterOptions(DEFAULT_CASES_TABLE_STATE.filterOptions); + }, [setFilterOptions]); + + const showClearFiltersButton = !deepEqual( + DEFAULT_CASES_TABLE_STATE.filterOptions, + filterOptions + ); + return ( <> ( currentUserProfile={currentUserProfile} filterOptions={filterOptions} /> + ( isSelectorView={isSelectorView} onChange={tableOnChangeCallback} pagination={pagination} - selectedCases={selectedCases} selection={euiBasicTableSelectionProps} sorting={sorting} tableRowProps={tableRowProps} - deselectCases={deselectCases} - selectedColumns={selectedColumns} - onSelectedColumnsChange={setSelectedColumns} /> ); diff --git a/x-pack/plugins/cases/public/components/all_cases/constants.ts b/x-pack/plugins/cases/public/components/all_cases/constants.ts new file mode 100644 index 0000000000000..d55a1c4810f35 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const CUSTOM_FIELD_KEY_PREFIX = 'cf_'; +export const ALL_CASES_STATE_URL_KEY = 'cases'; + +export const LEGACY_SUPPORTED_STATE_KEYS = [ + 'status', + 'severity', + 'page', + 'perPage', + 'sortField', + 'sortOrder', +] as const; 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 5210e3d52e215..50ea3a82974bf 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 @@ -23,6 +23,7 @@ describe('multi select filter', () => { { label: 'tag d', key: 'tag d' }, ], onChange, + isLoading: false, }; render(); @@ -46,6 +47,7 @@ describe('multi select filter', () => { selectedOptionKeys: ['tag a'], limit: 1, limitReachedMessage: 'Limit reached', + isLoading: false, }; const { rerender } = render(); @@ -76,6 +78,7 @@ describe('multi select filter', () => { selectedOptionKeys: ['tag a'], limit: 2, limitReachedMessage: 'Limit reached', + isLoading: false, }; const { rerender } = render(); @@ -109,6 +112,7 @@ describe('multi select filter', () => { selectedOptionKeys: ['tag a'], limit: 1, limitReachedMessage: 'Limit reached', + isLoading: false, }; render(); @@ -134,6 +138,7 @@ describe('multi select filter', () => { ], onChange, selectedOptionKeys: ['tag b'], + isLoading: false, }; const { rerender } = render(); @@ -154,6 +159,7 @@ describe('multi select filter', () => { ], onChange, renderOption, + isLoading: false, }; render(); @@ -173,6 +179,7 @@ describe('multi select filter', () => { ], onChange, selectedOptionKeys: ['tag b'], + isLoading: false, }; const { rerender } = render(); 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 b56c926bab965..080fe6df352c7 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 @@ -76,6 +76,7 @@ interface UseFilterParams { renderOption?: (option: FilterOption) => React.ReactNode; selectedOptionKeys?: string[]; transparentBackground?: boolean; + isLoading: boolean; } export const MultiSelectFilter = ({ buttonLabel, @@ -89,6 +90,7 @@ export const MultiSelectFilter = ({ selectedOptionKeys = [], renderOption, transparentBackground, + isLoading, }: UseFilterParams) => { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -101,13 +103,14 @@ export const MultiSelectFilter = ({ const newSelectedOptions = selectedOptionKeys.filter((selectedOptionKey) => rawOptions.some(({ key: optionKey }) => optionKey === selectedOptionKey) ); - if (!isEqual(newSelectedOptions, selectedOptionKeys)) { + + if (!isEqual(newSelectedOptions, selectedOptionKeys) && !isLoading) { onChange({ filterId: id, selectedOptionKeys: newSelectedOptions, }); } - }, [selectedOptionKeys, rawOptions, id, onChange]); + }, [selectedOptionKeys, rawOptions, id, onChange, isLoading]); const _onChange = (newOptions: Array>) => { const newSelectedOptions = getEuiSelectableCheckedOptions(newOptions); diff --git a/x-pack/plugins/cases/public/components/all_cases/schema.test.ts b/x-pack/plugins/cases/public/components/all_cases/schema.test.ts new file mode 100644 index 0000000000000..bdf6e627131f8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/schema.test.ts @@ -0,0 +1,96 @@ +/* + * 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 { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; +import { omit, pick } from 'lodash'; +import { DEFAULT_CASES_TABLE_STATE } from '../../containers/constants'; +import { AllCasesURLQueryParamsRt, validateSchema } from './schema'; + +describe('Schema', () => { + const supportedFilterOptions = pick(DEFAULT_CASES_TABLE_STATE.filterOptions, [ + 'search', + 'severity', + 'status', + 'tags', + 'assignees', + 'category', + ]); + + const defaultState = { + ...supportedFilterOptions, + ...DEFAULT_CASES_TABLE_STATE.queryParams, + }; + + describe('AllCasesURLQueryParamsRt', () => { + it('decodes correctly with defaults', () => { + const [params, errors] = validateNonExact(defaultState, AllCasesURLQueryParamsRt); + + expect(params).toEqual(defaultState); + expect(errors).toEqual(null); + }); + + it('decodes correctly with values', () => { + const state = { + assignees: ['elastic'], + tags: ['a', 'b'], + category: ['my category'], + status: ['open'], + search: 'My title', + severity: ['high'], + customFields: { my_field: ['one', 'two'] }, + sortOrder: 'asc', + sortField: 'updatedAt', + page: 5, + perPage: 20, + }; + + const [params, errors] = validateNonExact(state, AllCasesURLQueryParamsRt); + + expect(params).toEqual(state); + expect(errors).toEqual(null); + }); + + it('does not throws an error when missing fields', () => { + for (const [key] of Object.entries(defaultState)) { + const stateWithoutKey = omit(defaultState, key); + const [params, errors] = validateNonExact(stateWithoutKey, AllCasesURLQueryParamsRt); + + expect(params).toEqual(stateWithoutKey); + expect(errors).toEqual(null); + } + }); + + it('removes unknown properties', () => { + const [params, errors] = validateNonExact({ page: 10, foo: 'bar' }, AllCasesURLQueryParamsRt); + + expect(params).toEqual({ page: 10 }); + expect(errors).toEqual(null); + }); + + it.each(['status', 'severity', 'sortOrder', 'sortField', 'page', 'perPage'])( + 'throws if %s has invalid value', + (key) => { + const [params, errors] = validateNonExact({ [key]: 'foo' }, AllCasesURLQueryParamsRt); + + expect(params).toEqual(null); + expect(errors).toEqual(`Invalid value "foo" supplied to "${key}"`); + } + ); + }); + + describe('validateSchema', () => { + it('validates schema correctly', () => { + const params = validateSchema(defaultState, AllCasesURLQueryParamsRt); + expect(params).toEqual(defaultState); + }); + + it('throws an error if the schema is not valid', () => { + const params = validateSchema({ severity: 'foo' }, AllCasesURLQueryParamsRt); + expect(params).toEqual(null); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/schema.ts b/x-pack/plugins/cases/public/components/all_cases/schema.ts new file mode 100644 index 0000000000000..75f8e2be12dd4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/schema.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isLeft } from 'fp-ts/lib/Either'; +import * as rt from 'io-ts'; +import { CaseSeverityRt, CaseStatusRt } from '../../../common/types/domain'; + +export const AllCasesURLQueryParamsRt = rt.exact( + rt.partial({ + search: rt.string, + severity: rt.array(CaseSeverityRt), + status: rt.array(CaseStatusRt), + tags: rt.array(rt.string), + category: rt.array(rt.string), + assignees: rt.array(rt.union([rt.string, rt.null])), + customFields: rt.record(rt.string, rt.array(rt.string)), + sortOrder: rt.union([rt.literal('asc'), rt.literal('desc')]), + sortField: rt.union([ + rt.literal('closedAt'), + rt.literal('createdAt'), + rt.literal('updatedAt'), + rt.literal('severity'), + rt.literal('status'), + rt.literal('title'), + rt.literal('category'), + ]), + page: rt.number, + perPage: rt.number, + }) +); + +export const validateSchema = ( + obj: unknown, + schema: T +): rt.TypeOf | null => { + const decoded = schema.decode(obj); + if (isLeft(decoded)) { + return null; + } else { + return decoded.right; + } +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/search.test.tsx b/x-pack/plugins/cases/public/components/all_cases/search.test.tsx new file mode 100644 index 0000000000000..8ea775e5e10a6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/search.test.tsx @@ -0,0 +1,72 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { TableSearch } from './search'; + +describe('TableSearch', () => { + const onFilterOptionsChange = jest.fn(); + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders with empty value correctly', async () => { + appMockRender.render( + + ); + + await screen.findByDisplayValue(''); + }); + + it('renders with initial value correctly', async () => { + appMockRender.render( + + ); + + await screen.findByDisplayValue('My search'); + }); + + it('calls onFilterOptionsChange correctly', async () => { + appMockRender.render( + + ); + + userEvent.type(await screen.findByTestId('search-cases'), 'My search{enter}'); + + expect(onFilterOptionsChange).toHaveBeenCalledWith({ search: 'My search' }); + }); + + it('calls onFilterOptionsChange if the search term is empty', async () => { + appMockRender.render( + + ); + + userEvent.type(await screen.findByTestId('search-cases'), ' {enter}'); + + expect(onFilterOptionsChange).toHaveBeenCalledWith({ search: '' }); + }); + + it('calls onFilterOptionsChange when clearing the search bar', async () => { + appMockRender.render( + + ); + + await screen.findByDisplayValue('My search'); + + userEvent.click(await screen.findByTestId('clearSearchButton')); + + expect(onFilterOptionsChange).toHaveBeenCalledWith({ search: '' }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/search.tsx b/x-pack/plugins/cases/public/components/all_cases/search.tsx new file mode 100644 index 0000000000000..265c42470cdbd --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/search.tsx @@ -0,0 +1,49 @@ +/* + * 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 { EuiFieldSearch } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import * as i18n from './translations'; +import type { FilterOptions } from '../../containers/types'; + +interface TableSearchComponentProps { + filterOptionsSearch: string; + onFilterOptionsChange: (filterOptions: Partial) => void; +} + +const TableSearchComponent: React.FC = ({ + filterOptionsSearch, + onFilterOptionsChange, +}) => { + const [search, setSearch] = useState(filterOptionsSearch); + + const onSearch = useCallback( + (newSearch) => { + const trimSearch = newSearch.trim(); + setSearch(trimSearch); + onFilterOptionsChange({ search: trimSearch }); + }, + [onFilterOptionsChange] + ); + + return ( + setSearch(e.target.value)} + onSearch={onSearch} + value={search} + /> + ); +}; + +TableSearchComponent.displayName = 'TableSearchComponent'; + +export const TableSearch = React.memo(TableSearchComponent); 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 650e5215c6aac..dbc7bdef31c2a 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 @@ -40,6 +40,7 @@ export const SeverityFilter: React.FC = ({ selectedOptionKeys, onChange } options={options} renderOption={renderOption} selectedOptionKeys={selectedOptionKeys} + isLoading={false} /> ); }; 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 f2002e4c7899b..39e024161144f 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 @@ -63,6 +63,7 @@ export const SolutionFilterComponent = ({ options={options} renderOption={renderOption} selectedOptionKeys={selectedOptionKeys} + isLoading={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 cc4b032f96c71..6e665a3c19236 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 @@ -73,6 +73,7 @@ export const StatusFilterComponent = ({ options={options} renderOption={renderOption} selectedOptionKeys={selectedOptionKeys} + isLoading={false} /> ); }; diff --git a/x-pack/plugins/cases/public/components/all_cases/table.tsx b/x-pack/plugins/cases/public/components/all_cases/table.tsx index edd7ab7e9955b..b3a943a2c31d9 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table.tsx @@ -12,11 +12,9 @@ import { EuiEmptyPrompt, EuiSkeletonText, EuiBasicTable } from '@elastic/eui'; import classnames from 'classnames'; import styled from 'styled-components'; -import { CasesTableUtilityBar } from './utility_bar'; import { LinkButton } from '../links'; -import type { CasesFindResponseUI, CasesUI, CaseUI } from '../../../common/ui/types'; -import type { CasesColumnSelection } from './types'; +import type { CasesFindResponseUI, CaseUI } from '../../../common/ui/types'; import * as i18n from './translations'; import { useCreateCaseNavigation } from '../../common/navigation'; @@ -32,14 +30,10 @@ interface CasesTableProps { isSelectorView?: boolean; onChange: EuiBasicTableProps['onChange']; pagination: Pagination; - selectedCases: CasesUI; selection: EuiTableSelectionType; sorting: EuiBasicTableProps['sorting']; tableRef?: MutableRefObject; tableRowProps: EuiBasicTableProps['rowProps']; - deselectCases: () => void; - selectedColumns: CasesColumnSelection[]; - onSelectedColumnsChange: (columns: CasesColumnSelection[]) => void; isLoadingColumns: boolean; } @@ -57,14 +51,10 @@ export const CasesTable: FunctionComponent = ({ isSelectorView, onChange, pagination, - selectedCases, selection, sorting, tableRef, tableRowProps, - deselectCases, - selectedColumns, - onSelectedColumnsChange, isLoadingColumns, }) => { const { permissions } = useCasesContext(); @@ -87,15 +77,6 @@ export const CasesTable: FunctionComponent = ({ ) : ( <> - >; activeFilters: string[]; + isLoading: boolean; onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void; }) => { return ( @@ -28,6 +30,7 @@ export const MoreFiltersSelectable = ({ options={options} selectedOptionKeys={activeFilters} transparentBackground={true} + isLoading={isLoading} /> ); }; 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 6901bca807319..ed5fa48838602 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,13 +6,12 @@ */ import React from 'react'; +import type { CasesConfigurationUI } from '../../../../common/ui'; import type { CustomFieldTypes } from '../../../../common/types/domain'; import { builderMap as customFieldsBuilder } from '../../custom_fields/builder'; -import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; import type { FilterChangeHandler, FilterConfig, FilterConfigRenderParams } from './types'; import { MultiSelectFilter } from '../multi_select_filter'; - -export const CUSTOM_FIELD_KEY_PREFIX = 'cf_'; +import { deflattenCustomFieldKey, flattenCustomFieldKey } from '../utils'; interface CustomFieldFilterOptionFactoryProps { buttonLabel: string; @@ -20,6 +19,7 @@ interface CustomFieldFilterOptionFactoryProps { fieldKey: string; onFilterOptionsChange: FilterChangeHandler; type: CustomFieldTypes; + isLoading: boolean; } const customFieldFilterOptionFactory = ({ buttonLabel, @@ -27,9 +27,10 @@ const customFieldFilterOptionFactory = ({ fieldKey, onFilterOptionsChange, type, + isLoading, }: CustomFieldFilterOptionFactoryProps) => { return { - key: `${CUSTOM_FIELD_KEY_PREFIX}${fieldKey}`, // this prefix is set in case custom field has the same key as a system field + key: flattenCustomFieldKey(fieldKey), // this prefix is set in case custom field has the same key as a system field isActive: false, isAvailable: true, label: buttonLabel, @@ -53,7 +54,7 @@ const customFieldFilterOptionFactory = ({ }) => { onFilterOptionsChange({ customFields: { - [filterId.replace(CUSTOM_FIELD_KEY_PREFIX, '')]: { + [deflattenCustomFieldKey(filterId)]: { options: selectedOptionKeys, type, }, @@ -71,6 +72,7 @@ const customFieldFilterOptionFactory = ({ label: option.label, }))} selectedOptionKeys={filterOptions.customFields[fieldKey]?.options || []} + isLoading={isLoading} /> ); }, @@ -79,15 +81,15 @@ const customFieldFilterOptionFactory = ({ export const useCustomFieldsFilterConfig = ({ isSelectorView, + customFields, + isLoading, onFilterOptionsChange, }: { isSelectorView: boolean; + customFields: CasesConfigurationUI['customFields']; + isLoading: boolean; onFilterOptionsChange: FilterChangeHandler; }) => { - const { - data: { customFields }, - } = useGetCaseConfiguration(); - const customFieldsFilterConfig: FilterConfig[] = []; if (isSelectorView) { @@ -106,6 +108,7 @@ export const useCustomFieldsFilterConfig = ({ fieldKey, onFilterOptionsChange, type, + isLoading, }) ); } 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 62dd688cae29a..25dd550a3ecf3 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 @@ -9,17 +9,10 @@ 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'; import type { FilterOptions } from '../../../../common/ui'; - -jest.mock('../../../containers/configure/api', () => { - const originalModule = jest.requireActual('../../../containers/configure/api'); - return { - ...originalModule, - getCaseConfigure: jest.fn(), - }; -}); +import { CUSTOM_FIELD_KEY_PREFIX } from '../constants'; +import { CustomFieldTypes } from '../../../../common/types/domain'; const emptyFilterOptions: FilterOptions = { search: '', @@ -33,9 +26,31 @@ const emptyFilterOptions: FilterOptions = { category: [], customFields: {}, }; -const getCaseConfigureMock = getCaseConfigure as jest.Mock; describe('useFilterConfig', () => { + const onFilterOptionsChange = jest.fn(); + const getEmptyOptions = jest.fn().mockReturnValue({ severity: [] }); + const filters: FilterConfig[] = [ + { + key: 'severity', + label: 'Severity', + isActive: true, + isAvailable: true, + getEmptyOptions, + render: ({ filterOptions }: FilterConfigRenderParams) => null, + }, + { + key: 'tags', + label: 'Tags', + isActive: true, + isAvailable: true, + getEmptyOptions() { + return { tags: ['initialValue'] }; + }, + render: ({ filterOptions }: FilterConfigRenderParams) => null, + }, + ]; + let appMockRender: AppMockRenderer; beforeEach(() => { @@ -48,32 +63,6 @@ describe('useFilterConfig', () => { }); it('should remove a selected option if the filter is deleted', async () => { - getCaseConfigureMock.mockReturnValue(() => { - return []; - }); - const onFilterOptionsChange = jest.fn(); - const getEmptyOptions = jest.fn().mockReturnValue({ severity: [] }); - const filters: FilterConfig[] = [ - { - key: 'severity', - label: 'Severity', - isActive: true, - isAvailable: true, - getEmptyOptions, - render: ({ filterOptions }: FilterConfigRenderParams) => null, - }, - { - key: 'tags', - label: 'Tags', - isActive: true, - isAvailable: true, - getEmptyOptions() { - return { tags: ['initialValue'] }; - }, - render: ({ filterOptions }: FilterConfigRenderParams) => null, - }, - ]; - const { rerender } = renderHook(useFilterConfig, { wrapper: ({ children }) => {children}, initialProps: { @@ -81,16 +70,22 @@ describe('useFilterConfig', () => { onFilterOptionsChange, isSelectorView: false, filterOptions: emptyFilterOptions, + customFields: [], + isLoading: false, }, }); expect(onFilterOptionsChange).not.toHaveBeenCalled(); + rerender({ systemFilterConfig: [], onFilterOptionsChange, isSelectorView: false, filterOptions: emptyFilterOptions, + customFields: [], + isLoading: false, }); + expect(getEmptyOptions).toHaveBeenCalledTimes(1); expect(onFilterOptionsChange).toHaveBeenCalledTimes(1); expect(onFilterOptionsChange).toHaveBeenCalledWith({ @@ -98,4 +93,38 @@ describe('useFilterConfig', () => { tags: ['initialValue'], }); }); + + it('should activate custom fields correctly when they are hidden', async () => { + const customFieldKey = 'toggleKey'; + const uiCustomFieldKey = `${CUSTOM_FIELD_KEY_PREFIX}${customFieldKey}`; + + localStorage.setItem( + 'testAppId.cases.list.tableFiltersConfig', + JSON.stringify([{ key: uiCustomFieldKey, isActive: false }]) + ); + + const { result } = renderHook(useFilterConfig, { + wrapper: ({ children }) => {children}, + initialProps: { + systemFilterConfig: filters, + onFilterOptionsChange, + isSelectorView: false, + filterOptions: { + ...emptyFilterOptions, + customFields: { [customFieldKey]: { type: CustomFieldTypes.TOGGLE, options: ['on'] } }, + }, + customFields: [ + { + key: customFieldKey, + type: CustomFieldTypes.TOGGLE, + required: false, + label: 'My toggle', + }, + ], + isLoading: false, + }, + }); + + expect(result.current.activeSelectableOptionKeys).toEqual([uiCustomFieldKey]); + }); }); 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 8d721cd13daa7..6c342770a12f3 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 @@ -9,11 +9,12 @@ import type { SetStateAction } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; import useLocalStorage from 'react-use/lib/useLocalStorage'; import { merge, isEqual, isEmpty } from 'lodash'; -import type { FilterOptions } from '../../../../common/ui'; +import type { CasesConfigurationUI, FilterOptions } from '../../../../common/ui'; import { LOCAL_STORAGE_KEYS } from '../../../../common/constants'; import type { FilterConfig, FilterConfigState } from './types'; import { useCustomFieldsFilterConfig } from './use_custom_fields_filter_config'; import { useCasesContext } from '../../cases_context/use_cases_context'; +import { deflattenCustomFieldKey, isFlattenCustomField } from '../utils'; const mergeSystemAndCustomFieldConfigs = ({ systemFilterConfig, @@ -38,6 +39,13 @@ const shouldBeActive = ({ filter: FilterConfigState; filterOptions: FilterOptions; }) => { + if (isFlattenCustomField(filter.key)) { + return ( + !filter.isActive && + !isEmpty(filterOptions.customFields[deflattenCustomFieldKey(filter.key)]?.options) + ); + } + return !filter.isActive && !isEmpty(filterOptions[filter.key as keyof FilterOptions]); }; @@ -52,6 +60,7 @@ const useActiveByFilterKeyState = ({ filterOptions }: { filterOptions: FilterOpt * Activates filters that aren't active but have a value in the filterOptions */ const newActiveByFilterKey = [...(activeByFilterKey || [])]; + newActiveByFilterKey.forEach((filter) => { if (shouldBeActive({ filter, filterOptions })) { const currentIndex = newActiveByFilterKey.findIndex((_filter) => filter.key === _filter.key); @@ -98,25 +107,45 @@ export const useFilterConfig = ({ onFilterOptionsChange, systemFilterConfig, filterOptions, + customFields, + isLoading, }: { isSelectorView: boolean; + isLoading: boolean; onFilterOptionsChange: (params: Partial) => void; systemFilterConfig: FilterConfig[]; filterOptions: FilterOptions; + customFields: CasesConfigurationUI['customFields']; }) => { /** * 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] = useActiveByFilterKeyState({ filterOptions }); + const [activeByFilterKey, setActiveByFilterKey] = useActiveByFilterKeyState({ + filterOptions, + }); + const { customFieldsFilterConfig } = useCustomFieldsFilterConfig({ isSelectorView, + customFields, + isLoading, onFilterOptionsChange, }); + const activeCustomFieldsConfig = customFieldsFilterConfig.map((customField) => { + return { + ...customField, + isActive: Object.entries(filterOptions.customFields).find( + ([key, _]) => key === deflattenCustomFieldKey(customField.key) + ) + ? true + : customField.isActive, + }; + }); + const filterConfigs = mergeSystemAndCustomFieldConfigs({ systemFilterConfig, - customFieldsFilterConfig, + customFieldsFilterConfig: activeCustomFieldsConfig, }); const prevFilterConfigs = usePrevious(filterConfigs) ?? new Map(); @@ -192,11 +221,14 @@ export const useFilterConfig = ({ if (a.label < b.label) return -1; return a.key > b.key ? 1 : -1; }); + const source = activeByFilterKey && activeByFilterKey.length > 0 ? activeByFilterKey : filterConfigArray; + const activeFilters = source .filter((filter) => filter.isActive && filterConfigs.has(filter.key)) .map((filter) => filterConfigs.get(filter.key)) as FilterConfig[]; + const activeFilterKeys = activeFilters.map((filter) => filter.key); return { 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 ba2ca2d5f363f..e186e5c990d06 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 @@ -137,6 +137,7 @@ export const getSystemFilterConfig = ({ onChange={onSystemFilterChange} options={mapToMultiSelectOption(tags)} selectedOptionKeys={filterOptions?.tags} + isLoading={isLoading} /> ), }, @@ -159,6 +160,7 @@ export const getSystemFilterConfig = ({ onChange={onSystemFilterChange} options={mapToMultiSelectOption(categories)} selectedOptionKeys={filterOptions?.category} + isLoading={isLoading} /> ), }, 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 0abed867a29b3..d407d03517b25 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -17,30 +17,26 @@ import { SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER } from '../../../common/co import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { DEFAULT_FILTER_OPTIONS } from '../../containers/constants'; +import type { CasesTableFiltersProps } from './table_filters'; import { CasesTableFilters } from './table_filters'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetCategories } from '../../containers/use_get_categories'; import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; import { userProfiles } from '../../containers/user_profiles/api.mock'; -import { getCaseConfigure } from '../../containers/configure/api'; -import { CUSTOM_FIELD_KEY_PREFIX } from './table_filter_config/use_custom_fields_filter_config'; +import { CUSTOM_FIELD_KEY_PREFIX } from './constants'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { useCaseConfigureResponse } from '../configure_cases/__mock__'; 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(), - }; -}); +jest.mock('../../containers/configure/use_get_case_configuration'); -const getCaseConfigureMock = getCaseConfigure as jest.Mock; +const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; const onFilterChanged = jest.fn(); -const props = { +const props: CasesTableFiltersProps = { countClosedCases: 1234, countOpenCases: 99, countInProgressCases: 54, @@ -48,7 +44,6 @@ const props = { filterOptions: DEFAULT_FILTER_OPTIONS, availableSolutions: [], isLoading: false, - initialFilterOptions: DEFAULT_FILTER_OPTIONS, currentUserProfile: undefined, }; @@ -100,6 +95,8 @@ describe('CasesTableFilters ', () => { isLoading: false, }); (useSuggestUserProfiles as jest.Mock).mockReturnValue({ data: userProfiles, isLoading: false }); + + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); }); afterEach(() => { @@ -107,22 +104,22 @@ describe('CasesTableFilters ', () => { window.localStorage.clear(); }); - it('should render the case status filter dropdown', () => { + it('should render the case status filter dropdown', async () => { appMockRender.render(); - expect(screen.getByTestId('options-filter-popover-button-status')).toBeInTheDocument(); + expect(await screen.findByTestId('options-filter-popover-button-status')).toBeInTheDocument(); }); - it('should render the case severity filter dropdown', () => { + it('should render the case severity filter dropdown', async () => { appMockRender.render(); - expect(screen.getByTestId('options-filter-popover-button-severity')).toBeTruthy(); + expect(await screen.findByTestId('options-filter-popover-button-severity')).toBeTruthy(); }); it('should call onFilterChange when the severity filter changes', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('options-filter-popover-button-severity')); + userEvent.click(await screen.findByTestId('options-filter-popover-button-severity')); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('options-filter-popover-item-high')); + userEvent.click(await screen.findByTestId('options-filter-popover-item-high')); expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, severity: ['high'] }); }); @@ -130,9 +127,9 @@ describe('CasesTableFilters ', () => { it('should call onFilterChange when selected tags change', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('options-filter-popover-button-tags')); + userEvent.click(await screen.findByTestId('options-filter-popover-button-tags')); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('options-filter-popover-item-coke')); + userEvent.click(await screen.findByTestId('options-filter-popover-item-coke')); expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, tags: ['coke'] }); }); @@ -140,9 +137,9 @@ describe('CasesTableFilters ', () => { it('should call onFilterChange when selected category changes', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('options-filter-popover-button-category')); + userEvent.click(await screen.findByTestId('options-filter-popover-button-category')); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('options-filter-popover-item-twix')); + userEvent.click(await screen.findByTestId('options-filter-popover-item-twix')); expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, category: ['twix'] }); }); @@ -184,17 +181,39 @@ describe('CasesTableFilters ', () => { it('should call onFilterChange when search changes', async () => { appMockRender.render(); - userEvent.type(screen.getByTestId('search-cases'), 'My search{enter}'); + userEvent.type(await screen.findByTestId('search-cases'), 'My search{enter}'); + + await waitFor(() => { + expect(onFilterChanged.mock.calls[0][0].search).toEqual('My search'); + }); + }); + + it('should change the initial value of search when the state changes', async () => { + const { rerender } = appMockRender.render( + + ); + + await screen.findByDisplayValue('My search'); + + rerender( + + ); - expect(onFilterChanged).toBeCalledWith({ search: 'My search' }); + await screen.findByDisplayValue('My new search'); }); it('should call onFilterChange when changing status', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('options-filter-popover-button-status')); + userEvent.click(await screen.findByTestId('options-filter-popover-button-status')); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('options-filter-popover-item-closed')); + userEvent.click(await screen.findByTestId('options-filter-popover-item-closed')); expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, @@ -205,10 +224,10 @@ 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')); + userEvent.click(await screen.findByTestId('options-filter-popover-button-status')); await waitForEuiPopoverOpen(); - userEvent.type(screen.getByTestId('status-search-input'), 'in p'); + userEvent.type(await screen.findByTestId('status-search-input'), 'in p'); const allOptions = screen.getAllByRole('option'); expect(allOptions).toHaveLength(1); @@ -234,7 +253,7 @@ describe('CasesTableFilters ', () => { appMockRender = createAppMockRenderer({ license }); appMockRender.render(); - userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + userEvent.click(await screen.findByTestId('options-filter-popover-button-assignees')); await waitForEuiPopoverOpen(); userEvent.click(screen.getByText('Physical Dinosaur')); @@ -261,7 +280,7 @@ describe('CasesTableFilters ', () => { }); describe('Solution filter', () => { - it('shows Solution filter when provided more than 1 availableSolutions', () => { + it('shows Solution filter when provided more than 1 availableSolutions', async () => { appMockRender = createAppMockRenderer({ owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER], }); @@ -271,7 +290,7 @@ describe('CasesTableFilters ', () => { availableSolutions={[SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER]} /> ); - expect(screen.getByTestId('options-filter-popover-button-owner')).toBeInTheDocument(); + expect(await screen.findByTestId('options-filter-popover-button-owner')).toBeInTheDocument(); }); it('does not show Solution filter when provided less than 1 availableSolutions', () => { @@ -282,7 +301,7 @@ describe('CasesTableFilters ', () => { expect(screen.queryByTestId('options-filter-popover-button-owner')).not.toBeInTheDocument(); }); - it('does not select a solution on initial render', () => { + it('does not select a solution on initial render', async () => { appMockRender = createAppMockRenderer({ owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER], }); @@ -293,7 +312,7 @@ describe('CasesTableFilters ', () => { /> ); - expect(screen.getByTestId('options-filter-popover-button-owner')).not.toHaveAttribute( + expect(await screen.findByTestId('options-filter-popover-button-owner')).not.toHaveAttribute( 'hasActiveFilters' ); }); @@ -375,10 +394,12 @@ describe('CasesTableFilters ', () => { appMockRender = createAppMockRenderer({ license }); appMockRender.render(); - expect(screen.getByTestId('options-filter-popover-button-assignees')).toBeInTheDocument(); + expect( + await screen.findByTestId('options-filter-popover-button-assignees') + ).toBeInTheDocument(); }); - it('shuld reset the assignees when deactivating the filter', async () => { + it('should reset the assignees when deactivating the filter', async () => { const overrideProps = { ...props, filterOptions: { @@ -411,7 +432,7 @@ describe('CasesTableFilters ', () => { expect(screen.queryByTestId('cases-table-add-case-filter-bar')).not.toBeInTheDocument(); }); - it('should render the create case button when isSelectorView is true and onCreateCasePressed are passed', () => { + it('should render the create case button when isSelectorView is true and onCreateCasePressed are passed', async () => { const onCreateCasePressed = jest.fn(); appMockRender.render( { onCreateCasePressed={onCreateCasePressed} /> ); - expect(screen.getByTestId('cases-table-add-case-filter-bar')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-table-add-case-filter-bar')).toBeInTheDocument(); }); it('should call the onCreateCasePressed when create case is clicked', async () => { @@ -433,7 +454,7 @@ describe('CasesTableFilters ', () => { /> ); - userEvent.click(screen.getByTestId('cases-table-add-case-filter-bar')); + userEvent.click(await screen.findByTestId('cases-table-add-case-filter-bar')); await waitForComponentToUpdate(); // NOTE: intentionally checking no arguments are passed @@ -451,11 +472,14 @@ describe('CasesTableFilters ', () => { 'testAppId.cases.list.tableFiltersConfig', JSON.stringify(previousState) ); - getCaseConfigureMock.mockImplementation(() => { - return { + + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, customFields: [{ type: 'toggle', key: customFieldKey, label: 'Toggle', required: false }], - }; - }); + }, + })); }); afterEach(() => { @@ -481,7 +505,7 @@ describe('CasesTableFilters ', () => { userEvent.click(await screen.findByRole('button', { name: 'Toggle' })); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('options-filter-popover-item-on')); + userEvent.click(await screen.findByTestId('options-filter-popover-item-on')); expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, @@ -500,7 +524,7 @@ describe('CasesTableFilters ', () => { userEvent.click(await screen.findByRole('button', { name: 'Toggle' })); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('options-filter-popover-item-off')); + userEvent.click(await screen.findByTestId('options-filter-popover-item-off')); expect(onFilterChanged).toBeCalledWith({ ...DEFAULT_FILTER_OPTIONS, @@ -531,7 +555,7 @@ describe('CasesTableFilters ', () => { userEvent.click(await screen.findByRole('button', { name: 'Toggle' })); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('options-filter-popover-item-off')); + userEvent.click(await screen.findByTestId('options-filter-popover-item-off')); expect(onFilterChanged).toHaveBeenCalledWith({ ...DEFAULT_FILTER_OPTIONS, @@ -581,21 +605,23 @@ describe('CasesTableFilters ', () => { describe('custom filters configuration', () => { beforeEach(() => { - getCaseConfigureMock.mockImplementation(() => { - return { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, customFields: [ { type: 'toggle', key: 'toggle', label: 'Toggle', required: false }, { type: 'text', key: 'text', label: 'Text', required: false }, ], - }; - }); + }, + })); }); afterEach(() => { jest.clearAllMocks(); }); - it('shouldnt render the more button when in selector view', async () => { + it('should not render the more button when in selector view', async () => { appMockRender.render(); expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument(); }); @@ -607,9 +633,9 @@ describe('CasesTableFilters ', () => { 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(await screen.findByTestId('options-filter-popover-item-status')).toBeInTheDocument(); expect( - screen.getByTestId(`options-filter-popover-item-${CUSTOM_FIELD_KEY_PREFIX}toggle`) + await screen.findByTestId(`options-filter-popover-item-${CUSTOM_FIELD_KEY_PREFIX}toggle`) ).toBeInTheDocument(); }); @@ -632,7 +658,7 @@ describe('CasesTableFilters ', () => { userEvent.click(screen.getByRole('option', { name: 'Toggle' })); expect(screen.getByRole('button', { name: 'Toggle' })).toBeInTheDocument(); - const filterBar = screen.getByTestId('cases-table-filters'); + const filterBar = await screen.findByTestId('cases-table-filters'); const allFilters = within(filterBar).getAllByRole('button'); const orderedFilterLabels = ['Severity', 'Status', 'Tags', 'Categories', 'Toggle', 'More']; orderedFilterLabels.forEach((label, index) => { @@ -685,7 +711,7 @@ describe('CasesTableFilters ', () => { userEvent.click(screen.getByRole('option', { name: 'Status' })); expect(screen.queryByRole('button', { name: 'Status' })).not.toBeInTheDocument(); - const filterBar = screen.getByTestId('cases-table-filters'); + const filterBar = await screen.findByTestId('cases-table-filters'); const allFilters = within(filterBar).getAllByRole('button'); const orderedFilterLabels = ['Severity', 'Tags', 'Categories', 'More']; orderedFilterLabels.forEach((label, index) => { @@ -764,6 +790,7 @@ describe('CasesTableFilters ', () => { { key: 'status', isActive: false }, { key: 'severity', isActive: true }, ]; + localStorage.setItem( 'testAppId.cases.list.tableFiltersConfig', JSON.stringify(previousState) @@ -771,7 +798,7 @@ describe('CasesTableFilters ', () => { appMockRender.render(); - const filterBar = screen.getByTestId('cases-table-filters'); + const filterBar = await screen.findByTestId('cases-table-filters'); let allFilters: HTMLElement[]; await waitFor(() => { allFilters = within(filterBar).getAllByRole('button'); @@ -801,7 +828,7 @@ describe('CasesTableFilters ', () => { appMockRender.render(); - const filterBar = screen.getByTestId('cases-table-filters'); + const filterBar = await screen.findByTestId('cases-table-filters'); let allFilters: HTMLElement[]; await waitFor(() => { allFilters = within(filterBar).getAllByRole('button'); @@ -815,8 +842,10 @@ describe('CasesTableFilters ', () => { }); it('should sort the labels shown in the popover (on equal label, sort by key)', async () => { - getCaseConfigureMock.mockImplementation(() => { - return { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, customFields: [ { type: 'toggle', key: 'za', label: 'ZToggle', required: false }, { type: 'toggle', key: 'tc', label: 'Toggle', required: false }, @@ -824,8 +853,9 @@ describe('CasesTableFilters ', () => { { 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' })); @@ -851,10 +881,10 @@ describe('CasesTableFilters ', () => { }); }); - 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 () => { + it('when a filter is active and is not 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'); + const filterBar = await screen.findByTestId('cases-table-filters'); let allFilters = within(filterBar).getAllByRole('button'); let orderedFilterLabels = ['Severity', 'Status', 'Tags', 'Categories', 'More']; orderedFilterLabels.forEach((label, index) => { @@ -876,17 +906,20 @@ describe('CasesTableFilters ', () => { }); it('should avoid key collisions between custom fields and default fields', async () => { - getCaseConfigureMock.mockImplementation(() => { - return { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, 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'); + const filterBar = await screen.findByTestId('cases-table-filters'); let allFilters: HTMLElement[]; await waitFor(() => { allFilters = within(filterBar).getAllByRole('button'); @@ -906,7 +939,7 @@ describe('CasesTableFilters ', () => { }); }); - it('should delete stored filters that dont exist anymore', async () => { + it('should delete stored filters that do not exist anymore', async () => { const previousState = [ { key: 'severity', isActive: true }, { key: 'status', isActive: false }, @@ -954,35 +987,111 @@ describe('CasesTableFilters ', () => { ] `); }); - }); - it('should activate a filter when there is a value in the global state as this means that it has a value set in the url', async () => { - const previousState = [ - { key: 'severity', isActive: false }, // notice severity filter not active - { key: 'status', isActive: false }, // notice status filter not active - { key: 'tags', isActive: true }, - { key: 'category', isActive: false }, - ]; + it('should activate all filters when there is a value in the global state and is not active in the local storage', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); - localStorage.setItem('testAppId.cases.list.tableFiltersConfig', JSON.stringify(previousState)); + const previousState = [ + { key: 'severity', isActive: false }, // notice severity filter not active + { key: 'status', isActive: false }, // notice status filter not active + { key: 'tags', isActive: false }, + { key: 'category', isActive: false }, + { key: 'cf_toggle', isActive: false }, + { key: 'assignees', isActive: false }, + ]; - const overrideProps = { - ...props, - filterOptions: { - ...DEFAULT_FILTER_OPTIONS, - severity: [CaseSeverity.MEDIUM], // but they have values - status: [CaseStatuses.open, CaseStatuses['in-progress']], - }, - }; + localStorage.setItem( + 'testAppId.cases.list.tableFiltersConfig', + JSON.stringify(previousState) + ); - appMockRender.render(); + const overrideProps = { + ...props, + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + severity: [CaseSeverity.MEDIUM], // but they have values + status: [CaseStatuses.open, CaseStatuses['in-progress']], + tags: ['coke'], + category: ['twix'], + assignees: [userProfiles[0].uid], + customFields: { toggle: { type: CustomFieldTypes.TOGGLE, options: ['on'] } }, + }, + }; + + appMockRender = createAppMockRenderer({ license }); + appMockRender.render(); - const statusButton = await screen.findByRole('button', { name: 'Status' }); - expect(statusButton).toBeInTheDocument(); - expect(within(statusButton).getByLabelText('2 active filters')).toBeInTheDocument(); + const filters = [ + { name: 'Status', active: 2 }, + { name: 'Severity', active: 1 }, + { name: 'Tags', active: 1 }, + { name: 'Categories', active: 1 }, + { name: 'Toggle', active: 1 }, + { name: 'click to filter assignees', active: 1 }, + ]; + + await waitForComponentToUpdate(); + + const totalFilters = await screen.findAllByRole('button'); + // plus the more button + expect(totalFilters.length).toBe(filters.length + 1); + + for (const filter of filters) { + const button = await screen.findByRole('button', { name: filter.name }); + expect(button).toBeInTheDocument(); + expect( + await within(button).findByLabelText(`${filter.active} active filters`) + ).toBeInTheDocument(); + } + }); - const severityButton = await screen.findByRole('button', { name: 'Severity' }); - expect(severityButton).toBeInTheDocument(); - expect(within(severityButton).getByLabelText('1 active filters')).toBeInTheDocument(); + it('should activate all filters when there is a value in the global state and the local storage is empty', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + localStorage.setItem('testAppId.cases.list.tableFiltersConfig', JSON.stringify([])); + + const overrideProps = { + ...props, + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + severity: [CaseSeverity.MEDIUM], // but they have values + status: [CaseStatuses.open, CaseStatuses['in-progress']], + tags: ['coke'], + category: ['twix'], + assignees: [userProfiles[0].uid], + customFields: { toggle: { type: CustomFieldTypes.TOGGLE, options: ['on'] } }, + }, + }; + + appMockRender = createAppMockRenderer({ license }); + appMockRender.render(); + + const filters = [ + { name: 'Status', active: 2 }, + { name: 'Severity', active: 1 }, + { name: 'Tags', active: 1 }, + { name: 'Categories', active: 1 }, + { name: 'Toggle', active: 1 }, + { name: 'click to filter assignees', active: 1 }, + ]; + + await waitForComponentToUpdate(); + + const totalFilters = await screen.findAllByRole('button'); + // plus the more button + expect(totalFilters.length).toBe(filters.length + 1); + + for (const filter of filters) { + const button = await screen.findByRole('button', { name: filter.name }); + expect(button).toBeInTheDocument(); + expect( + await within(button).findByLabelText(`${filter.active} active filters`) + ).toBeInTheDocument(); + } + }); }); }); 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 dbdc947418eca..adece041c67ad 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import { mergeWith, isEqual } from 'lodash'; import { MoreFiltersSelectable } from './table_filter_config/more_filters_selectable'; import type { CaseStatuses } from '../../../common/types/domain'; @@ -18,6 +18,8 @@ import type { CurrentUserProfile } from '../types'; import { useCasesFeatures } from '../../common/use_cases_features'; import { useSystemFilterConfig } from './table_filter_config/use_system_filter_config'; import { useFilterConfig } from './table_filter_config/use_filter_config'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { TableSearch } from './search'; export interface CasesTableFiltersProps { countClosedCases: number | null; @@ -52,10 +54,13 @@ const CasesTableFiltersComponent = ({ currentUserProfile, filterOptions, }: CasesTableFiltersProps) => { - const [search, setSearch] = useState(filterOptions.search); - const { data: tags = [] } = useGetTags(); - const { data: categories = [] } = useGetCategories(); + const { data: tags = [], isLoading: isLoadingTags } = useGetTags(); + const { data: categories = [], isLoading: isLoadingCategories } = useGetCategories(); const { caseAssignmentAuthorized } = useCasesFeatures(); + const { + data: { customFields }, + isFetching: isLoadingCasesConfiguration, + } = useGetCaseConfiguration(); const onFilterOptionsChange = useCallback( (partialFilterOptions: Partial) => { @@ -67,6 +72,9 @@ const CasesTableFiltersComponent = ({ [filterOptions, onFilterChanged] ); + const isLoadingFilters = + isLoading || isLoadingTags || isLoadingCategories || isLoadingCasesConfiguration; + const { systemFilterConfig } = useSystemFilterConfig({ availableSolutions, caseAssignmentAuthorized, @@ -76,7 +84,7 @@ const CasesTableFiltersComponent = ({ countOpenCases, currentUserProfile, hiddenStatuses, - isLoading, + isLoading: isLoadingFilters, isSelectorView, onFilterOptionsChange, tags, @@ -87,18 +95,14 @@ const CasesTableFiltersComponent = ({ selectableOptions, activeSelectableOptionKeys, onFilterConfigChange, - } = useFilterConfig({ systemFilterConfig, onFilterOptionsChange, isSelectorView, filterOptions }); - - const handleOnSearch = useCallback( - (newSearch) => { - const trimSearch = newSearch.trim(); - if (!isEqual(trimSearch, search)) { - setSearch(trimSearch); - onFilterChanged({ search: trimSearch }); - } - }, - [onFilterChanged, search] - ); + } = useFilterConfig({ + systemFilterConfig, + onFilterOptionsChange, + isSelectorView, + filterOptions, + customFields, + isLoading: isLoadingFilters, + }); const handleOnCreateCasePressed = useCallback(() => { if (onCreateCasePressed) { @@ -126,13 +130,15 @@ const CasesTableFiltersComponent = ({ ) : null} - {activeFilters.map((filter) => ( @@ -147,6 +153,7 @@ const CasesTableFiltersComponent = ({ options={selectableOptions} activeFilters={activeSelectableOptionKeys} onChange={onFilterConfigChange} + isLoading={isLoadingFilters} /> )} diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts index f0c402d097e8d..e29019516e911 100644 --- a/x-pack/plugins/cases/public/components/all_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -139,12 +139,9 @@ export const FILTER_ASSIGNEES_ARIA_LABEL = i18n.translate( } ); -export const CLEAR_FILTERS = i18n.translate( - 'xpack.cases.allCasesView.filterAssignees.clearFilters', - { - defaultMessage: 'Clear filters', - } -); +export const CLEAR_FILTERS = i18n.translate('xpack.cases.allCasesView.clearFilters', { + defaultMessage: 'Clear filters', +}); export const TOTAL_ASSIGNEES_FILTERED = (total: number) => i18n.translate('xpack.cases.allCasesView.totalFilteredUsers', { diff --git a/x-pack/plugins/cases/public/components/all_cases/types.ts b/x-pack/plugins/cases/public/components/all_cases/types.ts index c0872b63cd892..4a1dd61aa505c 100644 --- a/x-pack/plugins/cases/public/components/all_cases/types.ts +++ b/x-pack/plugins/cases/public/components/all_cases/types.ts @@ -5,9 +5,11 @@ * 2.0. */ -import type { SortOrder } from '../../../common/ui'; +import type * as rt from 'io-ts'; +import type { FilterOptions, QueryParams, SortOrder } from '../../../common/ui'; +import type { AllCasesURLQueryParamsRt } from './schema'; -export const CASES_TABLE_PERPAGE_VALUES = [10, 25, 50, 100]; +export const CASES_TABLE_PER_PAGE_VALUES = [10, 25, 50, 100]; export interface EuiBasicTableSortTypes { field: string; @@ -32,3 +34,21 @@ export interface CasesColumnSelection { name: string; isChecked: boolean; } + +type SupportedFilterOptionsInURL = Pick< + FilterOptions, + 'search' | 'severity' | 'status' | 'tags' | 'assignees' | 'category' +>; + +export interface AllCasesTableState { + filterOptions: FilterOptions; + queryParams: QueryParams; +} + +export interface AllCasesURLState { + filterOptions: Partial & + Partial>; + queryParams: Partial; +} + +export type AllCasesURLQueryParams = rt.TypeOf; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx index 30de5acb0bfac..43aec66176c6f 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx @@ -6,351 +6,780 @@ */ import React from 'react'; -import { useHistory } from 'react-router-dom'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; +import { CaseStatuses } from '@kbn/cases-components'; import { TestProviders } from '../../common/mock'; -import { - useAllCasesState, - getQueryParamsLocalStorageKey, - getFilterOptionsLocalStorageKey, -} from './use_all_cases_state'; -import { - DEFAULT_FILTER_OPTIONS, - DEFAULT_QUERY_PARAMS, - DEFAULT_TABLE_ACTIVE_PAGE, - DEFAULT_TABLE_LIMIT, -} from '../../containers/constants'; -import { CaseStatuses } from '../../../common/types/domain'; +import { useAllCasesState } from './use_all_cases_state'; +import { DEFAULT_CASES_TABLE_STATE, DEFAULT_TABLE_LIMIT } from '../../containers/constants'; import { SortFieldCase } from '../../containers/types'; -import { stringifyToURL } from '../utils'; - -const LOCAL_STORAGE_QUERY_PARAMS_DEFAULTS = { - perPage: DEFAULT_QUERY_PARAMS.perPage, - sortOrder: DEFAULT_QUERY_PARAMS.sortOrder, -}; - -const LOCAL_STORAGE_FILTER_OPTIONS_DEFAULTS = { - severity: DEFAULT_FILTER_OPTIONS.severity, - status: DEFAULT_FILTER_OPTIONS.status, -}; - -const URL_DEFAULTS = { - ...DEFAULT_QUERY_PARAMS, - ...LOCAL_STORAGE_FILTER_OPTIONS_DEFAULTS, -}; +import { stringifyUrlParams } from './utils/stringify_url_params'; +import { CaseSeverity } from '../../../common'; +import type { AllCasesTableState } from './types'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { useCaseConfigureResponse } from '../configure_cases/__mock__'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; const mockLocation = { search: '' }; +const mockPush = jest.fn(); +const mockReplace = jest.fn(); + +jest.mock('../../containers/configure/use_get_case_configuration'); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: jest.fn().mockImplementation(() => { return mockLocation; }), - useHistory: jest.fn().mockReturnValue({ - replace: jest.fn(), - push: jest.fn(), + useHistory: jest.fn().mockImplementation(() => ({ + replace: mockReplace, + push: mockPush, location: { search: '', }, - }), + })), })); -const APP_ID = 'testAppId'; -const LOCALSTORAGE_QUERY_PARAMS_KEY = getQueryParamsLocalStorageKey(APP_ID); -const LOCALSTORAGE_FILTER_OPTIONS_KEY = getFilterOptionsLocalStorageKey(APP_ID); +const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; + +const LS_KEY = 'testAppId.cases.list.state'; describe('useAllCasesQueryParams', () => { beforeEach(() => { localStorage.clear(); + mockLocation.search = ''; + + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); }); afterEach(() => { jest.clearAllMocks(); }); - it('calls setState with default values on first run', () => { + it('returns default state with empty URL and local storage', () => { const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(result.current.queryParams).toStrictEqual(DEFAULT_QUERY_PARAMS); - expect(result.current.filterOptions).toStrictEqual(DEFAULT_FILTER_OPTIONS); + expect(result.current.queryParams).toStrictEqual(DEFAULT_CASES_TABLE_STATE.queryParams); + expect(result.current.filterOptions).toStrictEqual(DEFAULT_CASES_TABLE_STATE.filterOptions); }); - it('updates localstorage with default values on first run', () => { - expect(localStorage.getItem(LOCALSTORAGE_QUERY_PARAMS_KEY)).toStrictEqual(null); - expect(localStorage.getItem(LOCALSTORAGE_FILTER_OPTIONS_KEY)).toStrictEqual(null); + it('takes into account existing localStorage query params on first run', () => { + const existingLocalStorageValues = { + queryParams: { + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_TABLE_LIMIT + 10, + sortOrder: 'asc', + sortField: SortFieldCase.severity, + }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, + }; + + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); - renderHook(() => useAllCasesState(), { + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.queryParams).toMatchObject(existingLocalStorageValues.queryParams); + }); + + it('takes into account existing localStorage filter options values on first run', () => { + const existingLocalStorageValues = { + queryParams: DEFAULT_CASES_TABLE_STATE.queryParams, + filterOptions: { + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + severity: ['critical'], + status: ['open'], + }, + }; + + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); + + const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(JSON.parse(localStorage.getItem(LOCALSTORAGE_QUERY_PARAMS_KEY)!)).toMatchObject({ - ...LOCAL_STORAGE_QUERY_PARAMS_DEFAULTS, + expect(result.current.filterOptions).toMatchObject(existingLocalStorageValues.filterOptions); + }); + + it('takes into account existing url query params on first run', () => { + mockLocation.search = stringifyUrlParams({ page: 2, perPage: 15 }); + + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, }); - expect(JSON.parse(localStorage.getItem(LOCALSTORAGE_FILTER_OPTIONS_KEY)!)).toMatchObject({ - ...LOCAL_STORAGE_FILTER_OPTIONS_DEFAULTS, + + expect(result.current.queryParams).toMatchObject({ + ...DEFAULT_CASES_TABLE_STATE.queryParams, + ...{ page: 2, perPage: 15 }, }); }); - it('takes into account input filter options', () => { - const existingLocalStorageValues = { owner: ['foobar'], status: [CaseStatuses.open] }; + it('takes into account existing url filter options on first run', () => { + mockLocation.search = stringifyUrlParams({ + severity: [CaseSeverity.CRITICAL], + status: [CaseStatuses.open], + }); - const { result } = renderHook(() => useAllCasesState(false, existingLocalStorageValues), { + const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(result.current.filterOptions).toStrictEqual({ - ...DEFAULT_FILTER_OPTIONS, - ...existingLocalStorageValues, + expect(result.current.filterOptions).toMatchObject({ + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + ...{ severity: ['critical'], status: ['open'] }, }); }); - it('calls history.replace on every run', () => { + it('takes into account legacy url filter option "all"', () => { + const nonDefaultUrlParams = new URLSearchParams(); + nonDefaultUrlParams.append('severity', 'all'); + nonDefaultUrlParams.append('status', 'all'); + nonDefaultUrlParams.append('status', 'open'); + nonDefaultUrlParams.append('severity', 'low'); + + mockLocation.search = nonDefaultUrlParams.toString(); + const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(useHistory().replace).toHaveBeenCalledTimes(1); - expect(useHistory().push).toHaveBeenCalledTimes(0); + expect(result.current.filterOptions).toMatchObject({ + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + ...{ severity: ['low'], status: ['open'] }, + }); + }); + + it('preserves non cases state url parameters', () => { + mockLocation.search = `${stringifyUrlParams({ + status: [CaseStatuses.open], + })}&foo=bar&foo=baz&test=my-test`; + + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); act(() => { - result.current.setQueryParams({ perPage: DEFAULT_TABLE_LIMIT + 10 }); + result.current.setFilterOptions({ severity: [CaseSeverity.MEDIUM] }); }); - expect(useHistory().replace).toHaveBeenCalledTimes(2); - expect(useHistory().push).toHaveBeenCalledTimes(0); + expect(mockPush).toHaveBeenCalledWith({ + search: + 'cases=(page:1,perPage:10,severity:!(medium),sortField:createdAt,sortOrder:desc,status:!(open))&foo=bar&foo=baz&test=my-test', + }); }); - it('takes into account existing localStorage query params on first run', () => { + it('does not preserve cases state in the url when clearing filters', async () => { + const defaultStateWithValues: AllCasesTableState = { + filterOptions: { + search: 'my search', + searchFields: ['title'], + severity: [CaseSeverity.MEDIUM], + assignees: ['elastic'], + reporters: [], + status: [CaseStatuses.closed], + tags: ['test-tag'], + owner: ['cases'], + category: ['test-category'], + customFields: { + testCustomField: { options: ['foo'], type: CustomFieldTypes.TEXT }, + }, + }, + queryParams: { + page: DEFAULT_CASES_TABLE_STATE.queryParams.page + 10, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 50, + sortField: SortFieldCase.closedAt, + sortOrder: 'asc', + }, + }; + + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.setFilterOptions(defaultStateWithValues.filterOptions); + }); + + act(() => { + result.current.setQueryParams(defaultStateWithValues.queryParams); + }); + + await waitFor(() => { + expect(result.current.queryParams).toStrictEqual(defaultStateWithValues.queryParams); + expect(result.current.filterOptions).toStrictEqual(defaultStateWithValues.filterOptions); + }); + + act(() => { + result.current.setFilterOptions(DEFAULT_CASES_TABLE_STATE.filterOptions); + }); + + act(() => { + result.current.setQueryParams(DEFAULT_CASES_TABLE_STATE.queryParams); + }); + + await waitFor(() => { + expect(result.current.queryParams).toStrictEqual(DEFAULT_CASES_TABLE_STATE.queryParams); + expect(result.current.filterOptions).toStrictEqual(DEFAULT_CASES_TABLE_STATE.filterOptions); + }); + }); + + it('urlParams take precedence over localStorage query params values', () => { + mockLocation.search = stringifyUrlParams({ perPage: 15 }); + const existingLocalStorageValues = { - perPage: DEFAULT_TABLE_LIMIT + 10, - sortOrder: 'asc', - sortField: SortFieldCase.severity, + queryParams: { ...DEFAULT_CASES_TABLE_STATE.queryParams, perPage: 20 }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, }; - localStorage.setItem(LOCALSTORAGE_QUERY_PARAMS_KEY, JSON.stringify(existingLocalStorageValues)); + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); expect(result.current.queryParams).toMatchObject({ - ...LOCAL_STORAGE_QUERY_PARAMS_DEFAULTS, - ...existingLocalStorageValues, + ...DEFAULT_CASES_TABLE_STATE.queryParams, + ...{ perPage: 15 }, }); }); - it('takes into account existing localStorage filter options values on first run', () => { - const existingLocalStorageValues = { severity: ['critical'], status: ['open'] }; + it('urlParams take precedence over localStorage filter options values', () => { + mockLocation.search = stringifyUrlParams({ + severity: [CaseSeverity.HIGH], + status: [CaseStatuses.open], + }); - localStorage.setItem( - LOCALSTORAGE_FILTER_OPTIONS_KEY, - JSON.stringify(existingLocalStorageValues) - ); + const existingLocalStorageValues = { + filterOptions: { + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + severity: ['low'], + status: ['closed'], + }, + queryParams: DEFAULT_CASES_TABLE_STATE.queryParams, + }; + + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(result.current.filterOptions).toMatchObject(existingLocalStorageValues); + expect(result.current.filterOptions).toMatchObject({ severity: ['high'], status: ['open'] }); }); - it('takes into account legacy localStorage filter values as string', () => { - const existingLocalStorageValues = { severity: 'critical', status: 'open' }; + it('loads the URL from the local storage when the URL is empty on first run', async () => { + const existingLocalStorageValues = { + queryParams: { + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, + }; - localStorage.setItem( - LOCALSTORAGE_FILTER_OPTIONS_KEY, - JSON.stringify(existingLocalStorageValues) - ); + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); - const { result } = renderHook(() => useAllCasesState(), { + renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(result.current.filterOptions).toMatchObject({ - severity: ['critical'], - status: ['open'], + expect(mockReplace).toHaveBeenCalledWith({ + search: 'cases=(page:1,perPage:30,sortField:createdAt,sortOrder:desc)', }); }); - it('takes into account legacy localStorage filter value all', () => { - const existingLocalStorageValues = { severity: 'all', status: 'all' }; + it('does not load the URL from the local storage when the URL is empty on the second run', async () => { + const existingLocalStorageValues = { + queryParams: { + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, + }; - localStorage.setItem( - LOCALSTORAGE_FILTER_OPTIONS_KEY, - JSON.stringify(existingLocalStorageValues) - ); + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); - const { result } = renderHook(() => useAllCasesState(), { + const { rerender } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(result.current.filterOptions).toMatchObject({ - severity: [], - status: [], - }); + rerender(); + + expect(mockReplace).toHaveBeenCalledTimes(1); }); - it('takes into account existing url query params on first run', () => { - const nonDefaultUrlParams = { - page: DEFAULT_TABLE_ACTIVE_PAGE + 1, - perPage: DEFAULT_TABLE_LIMIT + 5, + it('does not load the URL from the local storage when the URL is not empty', async () => { + mockLocation.search = stringifyUrlParams({ + severity: [CaseSeverity.HIGH], + status: [CaseStatuses.open], + }); + + const existingLocalStorageValues = { + queryParams: { + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, }; - const expectedUrl = { ...URL_DEFAULTS, ...nonDefaultUrlParams }; - mockLocation.search = stringifyToURL(nonDefaultUrlParams as unknown as Record); + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(useHistory().replace).toHaveBeenCalledWith({ - search: stringifyToURL(expectedUrl as unknown as Record), + expect(mockReplace).toHaveBeenCalledTimes(0); + }); + + it('loads the state from the URL correctly', () => { + mockLocation.search = stringifyUrlParams({ + severity: [CaseSeverity.HIGH], + status: [CaseStatuses['in-progress']], + }); + + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.queryParams).toStrictEqual(DEFAULT_CASES_TABLE_STATE.queryParams); + expect(result.current.filterOptions).toStrictEqual({ + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + status: [CaseStatuses['in-progress']], + severity: [CaseSeverity.HIGH], }); }); - it('takes into account existing url filter options on first run', () => { - const nonDefaultUrlParams = { severity: 'critical', status: 'open' }; + it('loads the state from the local storage if they URL is empty correctly', () => { + const existingLocalStorageValues = { + queryParams: { + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, + }; - mockLocation.search = stringifyToURL(nonDefaultUrlParams); + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); - renderHook(() => useAllCasesState(), { + const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(useHistory().replace).toHaveBeenCalledWith({ - search: 'severity=critical&status=open&page=1&perPage=10&sortField=createdAt&sortOrder=desc', + expect(result.current.queryParams).toStrictEqual({ + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, }); + expect(result.current.filterOptions).toStrictEqual(DEFAULT_CASES_TABLE_STATE.filterOptions); }); - it('takes into account legacy url filter option "all"', () => { - const nonDefaultUrlParams = new URLSearchParams(); - nonDefaultUrlParams.append('severity', 'all'); - nonDefaultUrlParams.append('status', 'all'); - nonDefaultUrlParams.append('status', 'open'); - nonDefaultUrlParams.append('severity', 'low'); + it('updates the query params correctly', () => { + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); - mockLocation.search = stringifyToURL(nonDefaultUrlParams); + act(() => { + result.current.setQueryParams({ + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }); + }); - renderHook(() => useAllCasesState(), { + expect(result.current.queryParams).toStrictEqual({ + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }); + expect(result.current.filterOptions).toStrictEqual(DEFAULT_CASES_TABLE_STATE.filterOptions); + }); + + it('updates URL when updating the query params', () => { + const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(useHistory().replace).toHaveBeenCalledWith({ - search: 'severity=low&status=open&page=1&perPage=10&sortField=createdAt&sortOrder=desc', + act(() => { + result.current.setQueryParams({ + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }); + }); + + expect(mockPush).toHaveBeenCalledWith({ + search: 'cases=(page:1,perPage:30,sortField:createdAt,sortOrder:desc)', }); }); - it('preserves other url parameters', () => { - const nonDefaultUrlParams = { - foo: 'bar', - }; + it('updates the local storage when updating the query params', () => { + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); - mockLocation.search = stringifyToURL(nonDefaultUrlParams); + act(() => { + result.current.setQueryParams({ + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }); + }); - renderHook(() => useAllCasesState(), { + const localStorageState = JSON.parse(localStorage.getItem(LS_KEY) ?? '{}'); + expect(localStorageState).toEqual({ + ...DEFAULT_CASES_TABLE_STATE, + queryParams: { + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }, + }); + }); + + it('updates the filter options correctly', () => { + const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(useHistory().replace).toHaveBeenCalledWith({ - search: 'foo=bar&page=1&perPage=10&sortField=createdAt&sortOrder=desc&severity=&status=', + act(() => { + result.current.setFilterOptions({ status: [CaseStatuses.closed] }); + }); + + expect(result.current.queryParams).toStrictEqual(DEFAULT_CASES_TABLE_STATE.queryParams); + expect(result.current.filterOptions).toStrictEqual({ + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + status: [CaseStatuses.closed], }); }); - it('urlParams take precedence over localStorage query params values', () => { - const nonDefaultUrlParams = { - perPage: DEFAULT_TABLE_LIMIT + 5, - }; + it('updates the URL when updating the filter options', () => { + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); - mockLocation.search = stringifyToURL(nonDefaultUrlParams as unknown as Record); + act(() => { + result.current.setFilterOptions({ status: [CaseStatuses.closed] }); + }); - localStorage.setItem( - LOCALSTORAGE_QUERY_PARAMS_KEY, - JSON.stringify({ perPage: DEFAULT_TABLE_LIMIT + 10 }) - ); + expect(mockPush).toHaveBeenCalledWith({ + search: 'cases=(page:1,perPage:10,sortField:createdAt,sortOrder:desc,status:!(closed))', + }); + }); + it('updates the local storage when updating the filter options', () => { const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(result.current.queryParams).toMatchObject({ - ...DEFAULT_QUERY_PARAMS, - ...nonDefaultUrlParams, + act(() => { + result.current.setFilterOptions({ status: [CaseStatuses.closed] }); + }); + + const localStorageState = JSON.parse(localStorage.getItem(LS_KEY) ?? '{}'); + expect(localStorageState).toEqual({ + ...DEFAULT_CASES_TABLE_STATE, + filterOptions: { + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + status: [CaseStatuses.closed], + }, }); }); - it('urlParams take precedence over localStorage filter options values', () => { - const nonDefaultUrlParams = { - severity: 'high', - status: 'open', + it('updates the local storage when navigating to a URL and the query params are not empty', () => { + mockLocation.search = stringifyUrlParams({ + severity: [CaseSeverity.HIGH], + status: [CaseStatuses['in-progress']], + customFields: { my_field: ['foo'] }, + }); + + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: [ + { key: 'my_field', required: false, type: CustomFieldTypes.TEXT, label: 'foo' }, + ], + }, + })); + + renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); + + const localStorageState = JSON.parse(localStorage.getItem(LS_KEY) ?? '{}'); + + expect(localStorageState).toEqual({ + ...DEFAULT_CASES_TABLE_STATE, + filterOptions: { + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + severity: [CaseSeverity.HIGH], + status: [CaseStatuses['in-progress']], + customFields: { my_field: { options: ['foo'], type: CustomFieldTypes.TEXT } }, + }, + }); + }); + + it('does not update the local storage when navigating to an empty URL', () => { + const lsSpy = jest.spyOn(Storage.prototype, 'setItem'); + + renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); + + // first call is the initial call made by useLocalStorage + expect(lsSpy).toBeCalledTimes(1); + }); + + it('does not update the local storage on the second run', () => { + mockLocation.search = stringifyUrlParams({ + severity: [CaseSeverity.HIGH], + status: [CaseStatuses['in-progress']], + }); + + const lsSpy = jest.spyOn(Storage.prototype, 'setItem'); + + const { rerender } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); + + rerender(); + + // first call is the initial call made by useLocalStorage + expect(lsSpy).toBeCalledTimes(2); + }); + + it('does not update the local storage when the URL and the local storage are the same', async () => { + mockLocation.search = stringifyUrlParams({ + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }); + + const lsSpy = jest.spyOn(Storage.prototype, 'setItem'); + + const existingLocalStorageValues = { + queryParams: { + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, }; - mockLocation.search = stringifyToURL(nonDefaultUrlParams); + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); - localStorage.setItem( - LOCALSTORAGE_FILTER_OPTIONS_KEY, - JSON.stringify({ severity: ['low'], status: ['closed'] }) - ); + renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); - const { result } = renderHook(() => useAllCasesState(), { + // first call is the initial call made by useLocalStorage + expect(lsSpy).toBeCalledTimes(1); + }); + + it('does not update the local storage when the custom field configuration is loading', async () => { + mockLocation.search = stringifyUrlParams({ + severity: [CaseSeverity.HIGH], + status: [CaseStatuses['in-progress']], + }); + + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + isFetching: true, + })); + + const lsSpy = jest.spyOn(Storage.prototype, 'setItem'); + + renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(result.current.filterOptions).toMatchObject({ severity: ['high'], status: ['open'] }); + // first call is the initial call made by useLocalStorage + expect(lsSpy).toBeCalledTimes(1); }); describe('validation', () => { it('localStorage perPage query param cannot be > 100', () => { - localStorage.setItem(LOCALSTORAGE_QUERY_PARAMS_KEY, JSON.stringify({ perPage: 1000 })); + const existingLocalStorageValues = { + queryParams: { ...DEFAULT_CASES_TABLE_STATE.queryParams, perPage: 1000 }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, + }; + + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); expect(result.current.queryParams).toMatchObject({ - ...LOCAL_STORAGE_QUERY_PARAMS_DEFAULTS, perPage: 100, }); }); it('url perPage query param cannot be > 100', () => { - mockLocation.search = stringifyToURL({ perPage: '1000' }); + mockLocation.search = stringifyUrlParams({ perPage: 1000 }); - renderHook(() => useAllCasesState(), { + const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(useHistory().replace).toHaveBeenCalledWith({ - search: 'perPage=100&page=1&sortField=createdAt&sortOrder=desc&severity=&status=', + expect(result.current.queryParams).toMatchObject({ + ...DEFAULT_CASES_TABLE_STATE.queryParams, + ...{ perPage: 100 }, }); - - mockLocation.search = ''; }); it('validate spelling of localStorage sortOrder', () => { - localStorage.setItem(LOCALSTORAGE_QUERY_PARAMS_KEY, JSON.stringify({ sortOrder: 'foobar' })); + const existingLocalStorageValues = { + queryParams: { ...DEFAULT_CASES_TABLE_STATE.queryParams, sortOrder: 'foobar' }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, + }; + + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); const { result } = renderHook(() => useAllCasesState(), { wrapper: ({ children }) => {children}, }); - expect(result.current.queryParams).toMatchObject({ - ...LOCAL_STORAGE_QUERY_PARAMS_DEFAULTS, - }); + expect(result.current.queryParams).toMatchObject({ sortOrder: 'desc' }); }); it('validate spelling of url sortOrder', () => { - mockLocation.search = stringifyToURL({ sortOrder: 'foobar' }); + // @ts-expect-error: testing invalid sortOrder + mockLocation.search = stringifyUrlParams({ sortOrder: 'foobar' }); + + const { result } = renderHook(() => useAllCasesState(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.queryParams).toMatchObject({ sortOrder: 'desc' }); + }); + }); + + describe('Modal', () => { + it('returns default state with empty URL and local storage', () => { + const { result } = renderHook(() => useAllCasesState(true), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.queryParams).toStrictEqual(DEFAULT_CASES_TABLE_STATE.queryParams); + expect(result.current.filterOptions).toStrictEqual(DEFAULT_CASES_TABLE_STATE.filterOptions); + }); + + it('updates the query params correctly', () => { + const { result } = renderHook(() => useAllCasesState(true), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.setQueryParams({ + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }); + }); + + expect(result.current.queryParams).toStrictEqual({ + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }); + expect(result.current.filterOptions).toStrictEqual(DEFAULT_CASES_TABLE_STATE.filterOptions); + }); + + it('updates the filter options correctly', () => { + const { result } = renderHook(() => useAllCasesState(true), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.setFilterOptions({ status: [CaseStatuses.closed] }); + }); + + expect(result.current.queryParams).toStrictEqual(DEFAULT_CASES_TABLE_STATE.queryParams); + expect(result.current.filterOptions).toStrictEqual({ + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + status: [CaseStatuses.closed], + }); + }); + + it('does not update the URL when changing the state of the table', () => { + const { result } = renderHook(() => useAllCasesState(true), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.setQueryParams({ perPage: 20 }); + }); + + expect(mockPush).not.toHaveBeenCalled(); + }); + + it('does not update the local storage when changing the state of the table', () => { + const { result } = renderHook(() => useAllCasesState(true), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.setQueryParams({ + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }); + }); + + const localStorageState = JSON.parse(localStorage.getItem(LS_KEY) ?? '{}'); + expect(localStorageState).toEqual(DEFAULT_CASES_TABLE_STATE); + }); + + it('does not load the URL from the local storage when the URL is empty on first run', () => { + const existingLocalStorageValues = { + queryParams: { + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, + }; + + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); - renderHook(() => useAllCasesState(), { + renderHook(() => useAllCasesState(true), { wrapper: ({ children }) => {children}, }); - expect(useHistory().replace).toHaveBeenCalledWith({ - search: 'sortOrder=desc&page=1&perPage=10&sortField=createdAt&severity=&status=', + expect(mockPush).not.toHaveBeenCalled(); + }); + + it('does not load the state from the URL', () => { + mockLocation.search = stringifyUrlParams({ + severity: [CaseSeverity.HIGH], + status: [CaseStatuses['in-progress']], }); + + const { result } = renderHook(() => useAllCasesState(true), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.queryParams).toStrictEqual(DEFAULT_CASES_TABLE_STATE.queryParams); + expect(result.current.filterOptions).toStrictEqual(DEFAULT_CASES_TABLE_STATE.filterOptions); + }); + + it('does not load the state from the local storage', () => { + const existingLocalStorageValues = { + queryParams: { + ...DEFAULT_CASES_TABLE_STATE.queryParams, + perPage: DEFAULT_CASES_TABLE_STATE.queryParams.perPage + 20, + }, + filterOptions: DEFAULT_CASES_TABLE_STATE.filterOptions, + }; + + localStorage.setItem(LS_KEY, JSON.stringify(existingLocalStorageValues)); + + const { result } = renderHook(() => useAllCasesState(true), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.queryParams).toStrictEqual(DEFAULT_CASES_TABLE_STATE.queryParams); + expect(result.current.filterOptions).toStrictEqual(DEFAULT_CASES_TABLE_STATE.filterOptions); + }); + + it('does not update the local storage when navigating to a URL and the query params are not empty', () => { + mockLocation.search = stringifyUrlParams({ + severity: [CaseSeverity.HIGH], + status: [CaseStatuses['in-progress']], + }); + + renderHook(() => useAllCasesState(true), { + wrapper: ({ children }) => {children}, + }); + + const localStorageState = JSON.parse(localStorage.getItem(LS_KEY) ?? '{}'); + + expect(localStorageState).toEqual(DEFAULT_CASES_TABLE_STATE); }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx index b988cf501cddb..39bac2c05d569 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx @@ -5,244 +5,202 @@ * 2.0. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { useLocation, useHistory } from 'react-router-dom'; -import { isEqual } from 'lodash'; - +import deepEqual from 'react-fast-compare'; import useLocalStorage from 'react-use/lib/useLocalStorage'; - -import { removeLegacyValuesFromOptions, getStorableFilters } from './utils/sanitize_filter_options'; -import type { - FilterOptions, - PartialFilterOptions, - LocalStorageQueryParams, - QueryParams, - PartialQueryParams, - ParsedUrlQueryParams, -} from '../../../common/ui/types'; - -import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from '../../containers/constants'; -import { parseUrlQueryParams } from './utils'; -import { stringifyToURL, parseURL } from '../utils'; +import { isEmpty } from 'lodash'; + +import type { FilterOptions, QueryParams } from '../../../common/ui/types'; +import { + DEFAULT_CASES_TABLE_STATE, + DEFAULT_FILTER_OPTIONS, + DEFAULT_QUERY_PARAMS, +} from '../../containers/constants'; import { LOCAL_STORAGE_KEYS } from '../../../common/constants'; -import { SORT_ORDER_VALUES } from '../../../common/ui/types'; +import type { AllCasesTableState, AllCasesURLState } from './types'; +import { stringifyUrlParams } from './utils/stringify_url_params'; +import { allCasesUrlStateDeserializer } from './utils/all_cases_url_state_deserializer'; +import { allCasesUrlStateSerializer } from './utils/all_cases_url_state_serializer'; +import { parseUrlParams } from './utils/parse_url_params'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { CASES_TABLE_PERPAGE_VALUES } from './types'; -import { parseURLWithFilterOptions } from './utils/parse_url_with_filter_options'; -import { serializeUrlParams } from './utils/serialize_url_params'; +import { sanitizeState } from './utils/sanitize_state'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; + +interface UseAllCasesStateReturn { + filterOptions: FilterOptions; + setQueryParams: (queryParam: Partial) => void; + setFilterOptions: (filterOptions: Partial) => void; + queryParams: QueryParams; +} -export const getQueryParamsLocalStorageKey = (appId: string) => { - const filteringKey = LOCAL_STORAGE_KEYS.casesQueryParams; - return `${appId}.${filteringKey}`; -}; +export function useAllCasesState(isModalView: boolean = false): UseAllCasesStateReturn { + const isStateLoadedFromLocalStorage = useRef(false); + const isFirstRun = useRef(false); + const [tableState, setTableState] = useState(DEFAULT_CASES_TABLE_STATE); + const [urlState, setUrlState] = useAllCasesUrlState(); + const [localStorageState, setLocalStorageState] = useAllCasesLocalStorage(); + const { isFetching: isLoadingCasesConfiguration } = useGetCaseConfiguration(); + + const allCasesTableState: AllCasesTableState = useMemo( + () => (isModalView ? tableState : getAllCasesTableState(urlState, localStorageState)), + [isModalView, tableState, urlState, localStorageState] + ); -export const getFilterOptionsLocalStorageKey = (appId: string) => { - const filteringKey = LOCAL_STORAGE_KEYS.casesFilterOptions; - return `${appId}.${filteringKey}`; -}; + const setState = useCallback( + (state: AllCasesTableState) => { + if (isModalView) { + setTableState(state); + return; + } -const getQueryParams = ( - params: PartialQueryParams, - urlParams: PartialQueryParams, - localStorageQueryParams?: LocalStorageQueryParams -): QueryParams => { - const result = { ...DEFAULT_QUERY_PARAMS }; - - result.perPage = - params.perPage ?? - urlParams.perPage ?? - localStorageQueryParams?.perPage ?? - DEFAULT_QUERY_PARAMS.perPage; - - result.sortField = - params.sortField ?? - urlParams.sortField ?? - localStorageQueryParams?.sortField ?? - DEFAULT_QUERY_PARAMS.sortField; - - result.sortOrder = - params.sortOrder ?? - urlParams.sortOrder ?? - localStorageQueryParams?.sortOrder ?? - DEFAULT_QUERY_PARAMS.sortOrder; - - result.page = params.page ?? urlParams.page ?? DEFAULT_QUERY_PARAMS.page; - - return result; -}; + if (!deepEqual(state, urlState)) { + setUrlState(state); + } -const validateQueryParams = (queryParams: QueryParams): QueryParams => { - const perPage = Math.min( - queryParams.perPage, - CASES_TABLE_PERPAGE_VALUES[CASES_TABLE_PERPAGE_VALUES.length - 1] + if (!deepEqual(state, localStorageState)) { + setLocalStorageState(state); + } + }, + [localStorageState, urlState, isModalView, setLocalStorageState, setUrlState] ); - const sortOrder = !SORT_ORDER_VALUES.includes(queryParams.sortOrder) - ? DEFAULT_QUERY_PARAMS.sortOrder - : queryParams.sortOrder; - - return { ...queryParams, perPage, sortOrder }; -}; -/** - * Previously, 'status' and 'severity' were represented as single options (strings). - * To maintain backward compatibility while transitioning to the new type of string[], - * we map the legacy type to the new type. - */ -const convertToFilterOptionArray = (value: string | string[] | undefined) => { - if (typeof value === 'string') { - return [value]; + // use of useEffect because setUrlState calls history.push + useEffect(() => { + if ( + !isStateLoadedFromLocalStorage.current && + isURLStateEmpty(urlState) && + localStorageState && + !isModalView + ) { + setUrlState(localStorageState, 'replace'); + isStateLoadedFromLocalStorage.current = true; + } + }, [localStorageState, setUrlState, urlState, isModalView]); + + /** + * When navigating for the first time in a URL + * we need to persist the state on the local storage. + * We need to do it only on the first run and only when the URL is not empty. + * Otherwise we may introduce a race condition or loop with the above hook. + */ + if ( + !isFirstRun.current && + !isURLStateEmpty(urlState) && + localStorageState && + !deepEqual(allCasesTableState, localStorageState) && + !isLoadingCasesConfiguration && + !isModalView + ) { + setLocalStorageState(allCasesTableState); + isFirstRun.current = true; } - return value; -}; - -const getFilterOptions = ( - filterOptions: FilterOptions, - params: FilterOptions, - urlParams: PartialFilterOptions, - localStorageFilterOptions?: PartialFilterOptions -): FilterOptions => { - const severity = - params?.severity ?? - urlParams?.severity ?? - convertToFilterOptionArray(localStorageFilterOptions?.severity) ?? - DEFAULT_FILTER_OPTIONS.severity; - - const status = - params?.status ?? - urlParams?.status ?? - convertToFilterOptionArray(localStorageFilterOptions?.status) ?? - DEFAULT_FILTER_OPTIONS.status; return { - ...filterOptions, - ...params, - ...removeLegacyValuesFromOptions({ status, severity }), + ...allCasesTableState, + setQueryParams: (newQueryParams: Partial) => { + setState({ + filterOptions: allCasesTableState.filterOptions, + queryParams: { ...allCasesTableState.queryParams, ...newQueryParams }, + }); + }, + setFilterOptions: (newFilterOptions: Partial) => { + setState({ + filterOptions: { ...allCasesTableState.filterOptions, ...newFilterOptions }, + queryParams: allCasesTableState.queryParams, + }); + }, }; -}; +} -export function useAllCasesState( - isModalView: boolean = false, - initialFilterOptions?: PartialFilterOptions -) { - const { appId } = useCasesContext(); - const location = useLocation(); +const useAllCasesUrlState = (): [ + AllCasesURLState, + (updated: AllCasesTableState, mode?: 'push' | 'replace') => void +] => { const history = useHistory(); - const isFirstRenderRef = useRef(true); + const location = useLocation(); + const { + data: { customFields: customFieldsConfiguration }, + } = useGetCaseConfiguration(); + + const urlParams = parseUrlParams(new URLSearchParams(decodeURIComponent(location.search))); + const parsedUrlParams = allCasesUrlStateDeserializer(urlParams, customFieldsConfiguration); + + const updateQueryParams = useCallback( + (updated: AllCasesTableState, mode: 'push' | 'replace' = 'push') => { + const updatedQuery = allCasesUrlStateSerializer(updated); + const search = stringifyUrlParams(updatedQuery, location.search); + + history[mode]({ + ...location, + search, + }); + }, + [history, location] + ); - const [queryParams, setQueryParams] = useState({ ...DEFAULT_QUERY_PARAMS }); - const [filterOptions, setFilterOptions] = useState({ - ...DEFAULT_FILTER_OPTIONS, - ...initialFilterOptions, - }); + return [parsedUrlParams, updateQueryParams]; +}; - const [localStorageQueryParams, setLocalStorageQueryParams] = - useLocalStorage(getQueryParamsLocalStorageKey(appId)); +const getAllCasesTableState = ( + urlState: AllCasesURLState, + localStorageState?: AllCasesTableState +): AllCasesTableState => { + if (isURLStateEmpty(urlState)) { + return { + queryParams: { ...DEFAULT_CASES_TABLE_STATE.queryParams, ...localStorageState?.queryParams }, + filterOptions: { + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + ...localStorageState?.filterOptions, + }, + }; + } - const [localStorageFilterOptions, setLocalStorageFilterOptions] = - useLocalStorage(getFilterOptionsLocalStorageKey(appId)); + return { + queryParams: { ...DEFAULT_CASES_TABLE_STATE.queryParams, ...urlState.queryParams }, + filterOptions: { ...DEFAULT_CASES_TABLE_STATE.filterOptions, ...urlState.filterOptions }, + }; +}; - const persistAndUpdateQueryParams = useCallback( - (params) => { - if (isModalView) { - setQueryParams((prevParams) => ({ ...prevParams, ...params })); - return; - } +const isURLStateEmpty = (urlState: AllCasesURLState) => { + if (isEmpty(urlState)) { + return true; + } - const parsedUrlParams: ParsedUrlQueryParams = parseURL(location.search); - const urlParams: PartialQueryParams = parseUrlQueryParams(parsedUrlParams); + if (isEmpty(urlState.filterOptions) && isEmpty(urlState.queryParams)) { + return true; + } - let newQueryParams: QueryParams = getQueryParams(params, urlParams, localStorageQueryParams); + return false; +}; - newQueryParams = validateQueryParams(newQueryParams); +const useAllCasesLocalStorage = (): [ + AllCasesTableState | undefined, + Dispatch> +] => { + const { appId } = useCasesContext(); - const newLocalStorageQueryParams = { - perPage: newQueryParams.perPage, - sortField: newQueryParams.sortField, - sortOrder: newQueryParams.sortOrder, - }; - setLocalStorageQueryParams(newLocalStorageQueryParams); - setQueryParams(newQueryParams); - }, - [isModalView, location.search, localStorageQueryParams, setLocalStorageQueryParams] + const [state, setState] = useLocalStorage( + getAllCasesTableStateLocalStorageKey(appId), + { queryParams: DEFAULT_QUERY_PARAMS, filterOptions: DEFAULT_FILTER_OPTIONS } ); - const persistAndUpdateFilterOptions = useCallback( - (params) => { - if (isModalView) { - setFilterOptions((prevParams) => ({ ...prevParams, ...params })); - return; - } + const sanitizedState = sanitizeState(state); - const newFilterOptions: FilterOptions = getFilterOptions( - filterOptions, - params, - parseURLWithFilterOptions(location.search), - localStorageFilterOptions - ); - - const newPersistedFilterOptions: PartialFilterOptions = getStorableFilters(newFilterOptions); - - const newLocalStorageFilterOptions: PartialFilterOptions = { - ...localStorageFilterOptions, - ...newPersistedFilterOptions, - }; - setLocalStorageFilterOptions(newLocalStorageFilterOptions); - setFilterOptions(newFilterOptions); + return [ + { + queryParams: { ...DEFAULT_CASES_TABLE_STATE.queryParams, ...sanitizedState.queryParams }, + filterOptions: { + ...DEFAULT_CASES_TABLE_STATE.filterOptions, + ...sanitizedState.filterOptions, + }, }, - [ - filterOptions, - isModalView, - localStorageFilterOptions, - location.search, - setLocalStorageFilterOptions, - ] - ); - - const updateLocation = useCallback(() => { - const parsedUrlParams = parseURLWithFilterOptions(location.search); - const stateUrlParams = { - ...parsedUrlParams, - ...queryParams, - ...getStorableFilters(filterOptions), - page: queryParams.page.toString(), - perPage: queryParams.perPage.toString(), - }; - - if (!isEqual(parsedUrlParams, stateUrlParams)) { - try { - const urlParams = serializeUrlParams({ - ...parsedUrlParams, - ...stateUrlParams, - }); - - const newHistory = { - ...location, - search: stringifyToURL(urlParams), - }; - history.replace(newHistory); - } catch { - // silently fail - } - } - }, [filterOptions, history, location, queryParams]); - - if (isFirstRenderRef.current) { - persistAndUpdateQueryParams(isModalView ? queryParams : {}); - persistAndUpdateFilterOptions(isModalView ? filterOptions : initialFilterOptions); - - isFirstRenderRef.current = false; - } - - useEffect(() => { - if (!isModalView) { - updateLocation(); - } - }, [isModalView, updateLocation]); + setState, + ]; +}; - return { - queryParams, - setQueryParams: persistAndUpdateQueryParams, - filterOptions, - setFilterOptions: persistAndUpdateFilterOptions, - }; -} +const getAllCasesTableStateLocalStorageKey = (appId: string) => { + const key = LOCAL_STORAGE_KEYS.casesTableState; + return `${appId}.${key}`; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.tsx index a7c32ee939a19..7b81e88c0d383 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.tsx @@ -12,7 +12,7 @@ import type { CasesColumnSelection } from './types'; import { LOCAL_STORAGE_KEYS } from '../../../common/constants'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesColumnsConfiguration } from './use_cases_columns_configuration'; -import { mergeSelectedColumnsWithConfiguration } from './utils'; +import { mergeSelectedColumnsWithConfiguration } from './utils/merge_selected_columns_with_configuration'; const getTableColumnsLocalStorageKey = (appId: string) => { const filteringKey = LOCAL_STORAGE_KEYS.casesTableColumns; diff --git a/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx index ce776e77e8304..f58e7aa2698cc 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { act, waitFor, screen } from '@testing-library/react'; +import { act, waitFor, screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import type { AppMockRenderer } from '../../common/mock'; @@ -36,6 +36,8 @@ describe('Severity form field', () => { }, selectedColumns: [], onSelectedColumnsChange: jest.fn(), + onClearFilters: jest.fn(), + showClearFiltersButton: false, }; beforeEach(() => { @@ -44,11 +46,14 @@ describe('Severity form field', () => { it('renders', async () => { appMockRender.render(); - expect(screen.getByText('Showing 5 of 5 cases')).toBeInTheDocument(); - expect(screen.getByText('Selected 1 case')).toBeInTheDocument(); - expect(screen.getByTestId('case-table-bulk-actions-link-icon')).toBeInTheDocument(); - expect(screen.getByTestId('all-cases-refresh-link-icon')).toBeInTheDocument(); + + expect(await screen.findByText('Showing 5 of 5 cases')).toBeInTheDocument(); + expect(await screen.findByText('Selected 1 case')).toBeInTheDocument(); + expect(await screen.findByTestId('case-table-bulk-actions-link-icon')).toBeInTheDocument(); + expect(await screen.findByTestId('all-cases-refresh-link-icon')).toBeInTheDocument(); + expect(screen.queryByTestId('all-cases-maximum-limit-warning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('all-cases-clear-filters-link-icon')).not.toBeInTheDocument(); }); it('renders showing cases correctly', async () => { @@ -60,9 +65,11 @@ describe('Severity form field', () => { totalItemCount: 20, }, }; + appMockRender.render(); - expect(screen.getByText('Showing 10 of 20 cases')).toBeInTheDocument(); - expect(screen.getByText('Selected 1 case')).toBeInTheDocument(); + + expect(await screen.findByText('Showing 10 of 20 cases')).toBeInTheDocument(); + expect(await screen.findByText('Selected 1 case')).toBeInTheDocument(); }); it('renders showing cases correctly for second page', async () => { @@ -75,13 +82,16 @@ describe('Severity form field', () => { totalItemCount: 20, }, }; + appMockRender.render(); - expect(screen.getByText('Showing 10 of 20 cases')).toBeInTheDocument(); - expect(screen.getByText('Selected 1 case')).toBeInTheDocument(); + + expect(await screen.findByText('Showing 10 of 20 cases')).toBeInTheDocument(); + expect(await screen.findByText('Selected 1 case')).toBeInTheDocument(); }); it('renders showing cases correctly when no cases available', async () => { const updatedProps = { + ...props, totalCases: 0, selectedCases: [], deselectCases, @@ -90,16 +100,15 @@ describe('Severity form field', () => { pageIndex: 1, totalItemCount: 0, }, - selectedColumns: [], - onSelectedColumnsChange: jest.fn(), }; + appMockRender.render(); - expect(screen.getByText('Showing 0 of 0 cases')).toBeInTheDocument(); + expect(await screen.findByText('Showing 0 of 0 cases')).toBeInTheDocument(); }); it('renders columns popover button when isSelectorView=False', async () => { appMockRender.render(); - expect(screen.getByTestId('column-selection-popover-button')).toBeInTheDocument(); + expect(await screen.findByTestId('column-selection-popover-button')).toBeInTheDocument(); }); it('does not render columns popover button when isSelectorView=True', async () => { @@ -110,34 +119,28 @@ describe('Severity form field', () => { it('opens the bulk actions correctly', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('case-table-bulk-actions-link-icon')); + userEvent.click(await screen.findByTestId('case-table-bulk-actions-link-icon')); - await waitFor(() => { - expect(screen.getByTestId('case-table-bulk-actions-context-menu')); - }); + expect(await screen.findByTestId('case-table-bulk-actions-context-menu')); }); it('closes the bulk actions correctly', async () => { appMockRender.render(); - userEvent.click(screen.getByTestId('case-table-bulk-actions-link-icon')); + userEvent.click(await screen.findByTestId('case-table-bulk-actions-link-icon')); - await waitFor(() => { - expect(screen.getByTestId('case-table-bulk-actions-context-menu')); - }); + expect(await screen.findByTestId('case-table-bulk-actions-context-menu')); - userEvent.click(screen.getByTestId('case-table-bulk-actions-link-icon')); + userEvent.click(await screen.findByTestId('case-table-bulk-actions-link-icon')); - await waitFor(() => { - expect(screen.queryByTestId('case-table-bulk-actions-context-menu')).toBeFalsy(); - }); + await waitForElementToBeRemoved(screen.queryByTestId('case-table-bulk-actions-context-menu')); }); it('refresh correctly', async () => { appMockRender.render(); const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); - userEvent.click(screen.getByTestId('all-cases-refresh-link-icon')); + userEvent.click(await screen.findByTestId('all-cases-refresh-link-icon')); await waitFor(() => { expect(deselectCases).toHaveBeenCalled(); @@ -151,28 +154,44 @@ describe('Severity form field', () => { appMockRender = createAppMockRenderer({ permissions: noCasesPermissions() }); appMockRender.render(); - expect(screen.queryByTestId('case-table-bulk-actions-link-icon')).toBeFalsy(); + expect(screen.queryByTestId('case-table-bulk-actions-link-icon')).not.toBeInTheDocument(); }); it('does show the bulk actions with only delete permissions', async () => { appMockRender = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() }); appMockRender.render(); - expect(screen.getByTestId('case-table-bulk-actions-link-icon')).toBeInTheDocument(); + expect(await screen.findByTestId('case-table-bulk-actions-link-icon')).toBeInTheDocument(); }); it('does show the bulk actions with update permissions', async () => { appMockRender = createAppMockRenderer({ permissions: writeCasesPermissions() }); appMockRender.render(); - expect(screen.getByTestId('case-table-bulk-actions-link-icon')).toBeInTheDocument(); + expect(await screen.findByTestId('case-table-bulk-actions-link-icon')).toBeInTheDocument(); }); it('does not show the bulk actions if there are not selected cases', async () => { appMockRender.render(); - expect(screen.queryByTestId('case-table-bulk-actions-link-icon')).toBeFalsy(); - expect(screen.queryByText('Showing 0 cases')).toBeFalsy(); + expect(screen.queryByTestId('case-table-bulk-actions-link-icon')).not.toBeInTheDocument(); + expect(screen.queryByText('Showing 0 cases')).not.toBeInTheDocument(); + }); + + it('shows the clear filter button', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('all-cases-clear-filters-link-icon')).toBeInTheDocument(); + }); + + it('clears the filters correctly', async () => { + appMockRender.render(); + + userEvent.click(await screen.findByTestId('all-cases-clear-filters-link-icon')); + + await waitFor(() => { + expect(props.onClearFilters).toHaveBeenCalled(); + }); }); describe('Maximum number of cases', () => { @@ -190,7 +209,7 @@ describe('Severity form field', () => { it.each(allCasesPageSize)( `does not show warning when totalCases = ${MAX_DOCS_PER_PAGE} but pageSize(%s) * pageIndex + 1 < ${MAX_DOCS_PER_PAGE}`, - (size) => { + async (size) => { const newPageIndex = MAX_DOCS_PER_PAGE / size - 2; appMockRender.render( @@ -203,15 +222,16 @@ describe('Severity form field', () => { ); expect( - screen.getByText(`Showing ${size} of ${MAX_DOCS_PER_PAGE} cases`) + await screen.findByText(`Showing ${size} of ${MAX_DOCS_PER_PAGE} cases`) ).toBeInTheDocument(); + expect(screen.queryByTestId('all-cases-maximum-limit-warning')).not.toBeInTheDocument(); } ); it.each(allCasesPageSize)( `shows warning when totalCases = ${MAX_DOCS_PER_PAGE} but pageSize(%s) * pageIndex + 1 = ${MAX_DOCS_PER_PAGE}`, - (size) => { + async (size) => { const newPageIndex = MAX_DOCS_PER_PAGE / size - 1; appMockRender.render( @@ -224,15 +244,16 @@ describe('Severity form field', () => { ); expect( - screen.getByText(`Showing ${size} of ${MAX_DOCS_PER_PAGE} cases`) + await screen.findByText(`Showing ${size} of ${MAX_DOCS_PER_PAGE} cases`) ).toBeInTheDocument(); - expect(screen.getByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); + + expect(await screen.findByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); } ); it.each(allCasesPageSize)( `shows warning when totalCases = ${MAX_DOCS_PER_PAGE} but pageSize(%s) * pageIndex + 1 > ${MAX_DOCS_PER_PAGE}`, - (size) => { + async (size) => { const newPageIndex = MAX_DOCS_PER_PAGE / size; appMockRender.render( @@ -245,13 +266,14 @@ describe('Severity form field', () => { ); expect( - screen.getByText(`Showing ${size} of ${MAX_DOCS_PER_PAGE} cases`) + await screen.findByText(`Showing ${size} of ${MAX_DOCS_PER_PAGE} cases`) ).toBeInTheDocument(); - expect(screen.getByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); + + expect(await screen.findByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); } ); - it('should show dismiss and do not show again buttons correctly', () => { + it('should show dismiss and do not show again buttons correctly', async () => { appMockRender.render( { /> ); - expect(screen.getByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); - expect(screen.getByTestId('dismiss-warning')).toBeInTheDocument(); - - expect(screen.getByTestId('do-not-show-warning')).toBeInTheDocument(); + expect(await screen.findByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); + expect(await screen.findByTestId('dismiss-warning')).toBeInTheDocument(); + expect(await screen.findByTestId('do-not-show-warning')).toBeInTheDocument(); }); - it('should dismiss warning correctly', () => { + it('should dismiss warning correctly', async () => { appMockRender.render( { /> ); - expect(screen.getByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); - expect(screen.getByTestId('dismiss-warning')).toBeInTheDocument(); + expect(await screen.findByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); + expect(await screen.findByTestId('dismiss-warning')).toBeInTheDocument(); - userEvent.click(screen.getByTestId('dismiss-warning')); + userEvent.click(await screen.findByTestId('dismiss-warning')); expect(screen.queryByTestId('all-cases-maximum-limit-warning')).not.toBeInTheDocument(); }); @@ -303,7 +324,7 @@ describe('Severity form field', () => { jest.clearAllMocks(); }); - it('should set storage key correctly', () => { + it('should set storage key correctly', async () => { appMockRender.render( { /> ); - expect(screen.getByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); - expect(screen.getByTestId('do-not-show-warning')).toBeInTheDocument(); + expect(await screen.findByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); + expect(await screen.findByTestId('do-not-show-warning')).toBeInTheDocument(); expect(localStorage.getItem(localStorageKey)).toBe(null); }); - it('should hide warning correctly when do not show button clicked', () => { + it('should hide warning correctly when do not show button clicked', async () => { appMockRender.render( { /> ); - expect(screen.getByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); - expect(screen.getByTestId('do-not-show-warning')).toBeInTheDocument(); + expect(await screen.findByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); + expect(await screen.findByTestId('do-not-show-warning')).toBeInTheDocument(); - userEvent.click(screen.getByTestId('do-not-show-warning')); + userEvent.click(await screen.findByTestId('do-not-show-warning')); act(() => { jest.advanceTimersByTime(1000); diff --git a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx index ee80e0ffb38b1..afd9398d91cce 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx @@ -38,6 +38,8 @@ interface Props { pagination: Pagination; selectedColumns: CasesColumnSelection[]; onSelectedColumnsChange: (columns: CasesColumnSelection[]) => void; + onClearFilters: () => void; + showClearFiltersButton: boolean; } export const CasesTableUtilityBar: FunctionComponent = React.memo( @@ -49,6 +51,8 @@ export const CasesTableUtilityBar: FunctionComponent = React.memo( pagination, selectedColumns, onSelectedColumnsChange, + onClearFilters, + showClearFiltersButton, }) => { const { euiTheme } = useEuiTheme(); const refreshCases = useRefreshCases(); @@ -212,6 +216,20 @@ export const CasesTableUtilityBar: FunctionComponent = React.memo( {i18n.REFRESH} + {showClearFiltersButton ? ( + + + {i18n.CLEAR_FILTERS} + + + ) : null} diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.test.ts b/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.test.ts new file mode 100644 index 0000000000000..0b46456204dd2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.test.ts @@ -0,0 +1,217 @@ +/* + * 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 { CustomFieldTypes } from '../../../../common/types/domain'; +import { DEFAULT_CASES_TABLE_STATE } from '../../../containers/constants'; + +import { allCasesUrlStateDeserializer } from './all_cases_url_state_deserializer'; + +describe('allCasesUrlStateDeserializer', () => { + const { customFields, ...filterOptionsWithoutCustomFields } = + DEFAULT_CASES_TABLE_STATE.filterOptions; + + const defaultMap = { + ...filterOptionsWithoutCustomFields, + ...DEFAULT_CASES_TABLE_STATE.queryParams, + }; + + it('parses defaults correctly', () => { + expect(allCasesUrlStateDeserializer(defaultMap)).toMatchInlineSnapshot(` + Object { + "filterOptions": Object { + "assignees": Array [], + "category": Array [], + "owner": Array [], + "reporters": Array [], + "search": "", + "searchFields": Array [ + "title", + "description", + ], + "severity": Array [], + "status": Array [], + "tags": Array [], + }, + "queryParams": Object { + "page": 1, + "perPage": 10, + "sortField": "createdAt", + "sortOrder": "desc", + }, + } + `); + }); + + it('parses an empty object correctly', () => { + expect(allCasesUrlStateDeserializer({})).toMatchInlineSnapshot(` + Object { + "filterOptions": Object {}, + "queryParams": Object {}, + } + `); + }); + + it('does not return unknown values', () => { + // @ts-expect-error: testing unknown values + expect(allCasesUrlStateDeserializer({ foo: 'bar' })).toMatchInlineSnapshot(` + Object { + "filterOptions": Object {}, + "queryParams": Object {}, + } + `); + }); + + it('converts page to integer correctly', () => { + // @ts-expect-error: testing integer conversion + expect(allCasesUrlStateDeserializer({ page: '1' }).queryParams.page).toBe(1); + }); + + it('sets perPage to the maximum allowed value if it is set to over the limit', () => { + // @ts-expect-error: testing integer conversion + expect(allCasesUrlStateDeserializer({ perPage: '1000' }).queryParams.perPage).toBe(100); + }); + + it('converts perPage to integer correctly', () => { + // @ts-expect-error: testing integer conversion + expect(allCasesUrlStateDeserializer({ perPage: '2' }).queryParams.perPage).toBe(2); + }); + + it('sets the defaults to page and perPage correctly if they are not numbers', () => { + // @ts-expect-error: testing integer conversion + expect(allCasesUrlStateDeserializer({ page: 'foo', perPage: 'bar' }).queryParams.page).toBe( + DEFAULT_CASES_TABLE_STATE.queryParams.page + ); + + // @ts-expect-error: testing integer conversion + expect(allCasesUrlStateDeserializer({ page: 'foo', perPage: 'bar' }).queryParams.perPage).toBe( + DEFAULT_CASES_TABLE_STATE.queryParams.perPage + ); + }); + + it('does not return the page and perPage if they are not defined', () => { + expect(allCasesUrlStateDeserializer({})).toMatchInlineSnapshot(` + Object { + "filterOptions": Object {}, + "queryParams": Object {}, + } + `); + }); + + it('sets the sortOrder correctly', () => { + expect(allCasesUrlStateDeserializer({ sortOrder: 'asc' }).queryParams.sortOrder).toBe('asc'); + }); + + it('parses custom fields correctly', () => { + expect( + allCasesUrlStateDeserializer( + { + customFields: { + 'my-custom-field-1': ['foo', 'qux'], + 'my-custom-field-2': ['bar', 'baz'], + 'my-custom-field-4': [], + }, + }, + [ + { + key: 'my-custom-field-1', + type: CustomFieldTypes.TOGGLE, + required: false, + label: 'foo', + }, + { + key: 'my-custom-field-2', + type: CustomFieldTypes.TOGGLE, + required: false, + label: 'foo', + }, + { + key: 'my-custom-field-4', + type: CustomFieldTypes.TOGGLE, + required: false, + label: 'foo', + }, + ] + ) + ).toMatchInlineSnapshot(` + Object { + "filterOptions": Object { + "customFields": Object { + "my-custom-field-1": Object { + "options": Array [ + "foo", + "qux", + ], + "type": "toggle", + }, + "my-custom-field-2": Object { + "options": Array [ + "bar", + "baz", + ], + "type": "toggle", + }, + "my-custom-field-4": Object { + "options": Array [], + "type": "toggle", + }, + }, + }, + "queryParams": Object {}, + } + `); + }); + + it('removes unknown custom fields', () => { + expect( + allCasesUrlStateDeserializer( + { + customFields: { + 'my-custom-field-1': ['foo', 'qux'], + 'my-custom-field-2': ['bar', 'baz'], + }, + }, + [ + { + key: 'my-custom-field-1', + type: CustomFieldTypes.TOGGLE, + required: false, + label: 'foo', + }, + ] + ) + ).toMatchInlineSnapshot(` + Object { + "filterOptions": Object { + "customFields": Object { + "my-custom-field-1": Object { + "options": Array [ + "foo", + "qux", + ], + "type": "toggle", + }, + }, + }, + "queryParams": Object {}, + } + `); + }); + + it('parses none assignees correctly', () => { + expect(allCasesUrlStateDeserializer({ assignees: ['none', 'elastic'] })).toMatchInlineSnapshot(` + Object { + "filterOptions": Object { + "assignees": Array [ + null, + "elastic", + ], + }, + "queryParams": Object {}, + } + `); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.ts b/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.ts new file mode 100644 index 0000000000000..d07f693f2907c --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.ts @@ -0,0 +1,85 @@ +/* + * 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 { isEmpty } from 'lodash'; +import { NO_ASSIGNEES_FILTERING_KEYWORD } from '../../../../common/constants'; +import type { QueryParams, FilterOptions, CasesConfigurationUI } from '../../../../common/ui'; +import { DEFAULT_CASES_TABLE_STATE } from '../../../containers/constants'; +import type { AllCasesURLQueryParams, AllCasesURLState } from '../types'; +import { sanitizeState } from './sanitize_state'; +import { stringToIntegerWithDefault } from '.'; + +export const allCasesUrlStateDeserializer = ( + urlParamsMap: AllCasesURLQueryParams, + customFieldsConfiguration: CasesConfigurationUI['customFields'] = [] +): AllCasesURLState => { + const queryParams: Partial & Record = {}; + const filterOptions: Partial & Record = {}; + + for (const [key, value] of Object.entries(urlParamsMap)) { + if (Object.hasOwn(DEFAULT_CASES_TABLE_STATE.queryParams, key)) { + queryParams[key] = value; + } + + if (Object.hasOwn(DEFAULT_CASES_TABLE_STATE.filterOptions, key)) { + filterOptions[key] = value; + } + } + + const { page, perPage, ...restQueryParams } = queryParams; + const { assignees, customFields, ...restFilterOptions } = filterOptions; + + const queryParamsParsed: Partial = { + ...restQueryParams, + }; + + const filterOptionsParsed: Partial = { + ...restFilterOptions, + }; + + if (page) { + queryParamsParsed.page = stringToIntegerWithDefault( + page, + DEFAULT_CASES_TABLE_STATE.queryParams.page + ); + } + + if (perPage) { + queryParamsParsed.perPage = stringToIntegerWithDefault( + perPage, + DEFAULT_CASES_TABLE_STATE.queryParams.perPage + ); + } + + if (assignees) { + filterOptionsParsed.assignees = assignees.map((assignee) => + assignee === NO_ASSIGNEES_FILTERING_KEYWORD ? null : assignee + ); + } + + const customFieldsParams = Object.entries(customFields ?? {}).reduce((acc, [key, value]) => { + const foundCustomField = customFieldsConfiguration.find((cf) => cf.key === key); + + if (!foundCustomField) { + return acc; + } + + return { ...acc, [key]: { type: foundCustomField.type, options: value } }; + }, {}); + + const state: AllCasesURLState = { + queryParams: queryParamsParsed, + filterOptions: { + ...filterOptionsParsed, + ...(!isEmpty(customFieldsParams) && { + customFields: customFieldsParams, + }), + }, + }; + + return sanitizeState(state); +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_serializer.test.ts b/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_serializer.test.ts new file mode 100644 index 0000000000000..0e129cdc69489 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_serializer.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CustomFieldTypes } from '../../../../common/types/domain'; +import { DEFAULT_QUERY_PARAMS, DEFAULT_FILTER_OPTIONS } from '../../../containers/constants'; + +import { allCasesUrlStateSerializer } from './all_cases_url_state_serializer'; + +describe('allCasesUrlStateSerializer', () => { + it('serializes correctly with default values', () => { + expect( + allCasesUrlStateSerializer({ + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: DEFAULT_QUERY_PARAMS, + }) + ).toMatchInlineSnapshot(` + Object { + "page": 1, + "perPage": 10, + "sortField": "createdAt", + "sortOrder": "desc", + } + `); + }); + + it('serializes custom fields correctly', () => { + expect( + allCasesUrlStateSerializer({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + customFields: { + foo: { type: CustomFieldTypes.TEXT, options: ['bar'] }, + bar: { type: CustomFieldTypes.TEXT, options: ['foo'] }, + }, + }, + queryParams: DEFAULT_QUERY_PARAMS, + }) + ).toMatchInlineSnapshot(` + Object { + "customFields": Object { + "bar": Array [ + "foo", + ], + "foo": Array [ + "bar", + ], + }, + "page": 1, + "perPage": 10, + "sortField": "createdAt", + "sortOrder": "desc", + } + `); + }); + + it('removes unsupported filter options', () => { + expect( + allCasesUrlStateSerializer({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + searchFields: ['title'], + reporters: [{ username: 'elastic', email: null, full_name: null }], + owner: ['cases'], + }, + queryParams: DEFAULT_QUERY_PARAMS, + }) + ).toMatchInlineSnapshot(` + Object { + "page": 1, + "perPage": 10, + "sortField": "createdAt", + "sortOrder": "desc", + } + `); + }); + + it('removes empty values', () => { + expect( + allCasesUrlStateSerializer({ + filterOptions: { ...DEFAULT_FILTER_OPTIONS, status: [], search: '', customFields: {} }, + queryParams: DEFAULT_QUERY_PARAMS, + }) + ).toMatchInlineSnapshot(` + Object { + "page": 1, + "perPage": 10, + "sortField": "createdAt", + "sortOrder": "desc", + } + `); + }); + + it('converts null assignees correctly', () => { + expect( + allCasesUrlStateSerializer({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + assignees: [null, 'elastic'], + }, + queryParams: DEFAULT_QUERY_PARAMS, + }) + ).toMatchInlineSnapshot(` + Object { + "assignees": Array [ + "none", + "elastic", + ], + "page": 1, + "perPage": 10, + "sortField": "createdAt", + "sortOrder": "desc", + } + `); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_serializer.ts b/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_serializer.ts new file mode 100644 index 0000000000000..e5faeb6f36f90 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/all_cases_url_state_serializer.ts @@ -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 { isEmpty, pick, isNumber } from 'lodash'; +import { NO_ASSIGNEES_FILTERING_KEYWORD } from '../../../../common/constants'; +import type { AllCasesURLQueryParams, AllCasesTableState } from '../types'; + +export const allCasesUrlStateSerializer = (state: AllCasesTableState): AllCasesURLQueryParams => { + const supportedFilterOptions = pick(state.filterOptions, [ + 'search', + 'severity', + 'status', + 'tags', + 'assignees', + 'category', + ]); + + const customFieldsAsQueryParams = Object.entries(state.filterOptions.customFields).reduce( + (acc, [key, value]) => { + if (isEmpty(value.options)) { + return acc; + } + + return { ...acc, [key]: value.options }; + }, + {} + ); + + const combinedState = { + ...state.queryParams, + page: state.queryParams.page, + perPage: state.queryParams.perPage, + ...supportedFilterOptions, + assignees: supportedFilterOptions.assignees.map((assignee) => + assignee === null ? NO_ASSIGNEES_FILTERING_KEYWORD : assignee + ), + customFields: customFieldsAsQueryParams, + }; + + // filters empty values + return Object.entries(combinedState).reduce((acc, [key, value]) => { + // isEmpty returns true for numbers + if (isEmpty(value) && !isNumber(value)) { + return acc; + } + + return Object.assign(acc, { [key]: value }); + }, {}); +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/index.test.ts b/x-pack/plugins/cases/public/components/all_cases/utils/index.test.ts new file mode 100644 index 0000000000000..98eeef78f9765 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/index.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { + isFlattenCustomField, + flattenCustomFieldKey, + deflattenCustomFieldKey, + stringToInteger, + stringToIntegerWithDefault, +} from '.'; + +describe('utils', () => { + describe('isFlattenCustomField', () => { + it('returns true if the key is prefixed with cf_', () => { + expect(isFlattenCustomField('cf_foo')).toBe(true); + }); + + it('returns false if the key is not prefixed with cf_', () => { + expect(isFlattenCustomField('foo')).toBe(false); + }); + }); + + describe('flattenCustomFieldKey', () => { + it('flattens a custom field key correctly', () => { + expect(flattenCustomFieldKey('foo')).toBe('cf_foo'); + }); + }); + + describe('deflattenCustomFieldKey', () => { + it('deflattens a custom field key correctly', () => { + expect(deflattenCustomFieldKey('cf_foo')).toBe('foo'); + }); + }); + + describe('stringToInteger', () => { + it('converts a number correctly', () => { + expect(stringToInteger(5)).toBe(5); + }); + + it('converts a string to a number correctly', () => { + expect(stringToInteger('5')).toBe(5); + }); + + it('returns undefined if the value cannot converted to a number', () => { + expect(stringToInteger('foo')).toBe(undefined); + }); + }); + + describe('stringToIntegerWithDefault', () => { + it('converts a string to a number correctly', () => { + expect(stringToIntegerWithDefault('5', 10)).toBe(5); + }); + + it('sets the default value correctly if the number is zero', () => { + expect(stringToIntegerWithDefault(0, 10)).toBe(10); + }); + + it('sets the default value correctly if the value is not a number', () => { + expect(stringToIntegerWithDefault('foo', 10)).toBe(10); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/index.ts b/x-pack/plugins/cases/public/components/all_cases/utils/index.ts index bbc48210bfaa2..762882834b8ee 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utils/index.ts +++ b/x-pack/plugins/cases/public/components/all_cases/utils/index.ts @@ -5,71 +5,31 @@ * 2.0. */ -import { difference } from 'lodash'; -import type { ParsedUrlQueryParams, PartialQueryParams } from '../../../../common/ui/types'; -import type { CasesColumnSelection } from '../types'; -import type { CasesColumnsConfiguration } from '../use_cases_columns_configuration'; +import { CUSTOM_FIELD_KEY_PREFIX } from '../constants'; -export const parseUrlQueryParams = (parsedUrlParams: ParsedUrlQueryParams): PartialQueryParams => { - const urlParams: PartialQueryParams = { - ...(parsedUrlParams.sortField && { sortField: parsedUrlParams.sortField }), - ...(parsedUrlParams.sortOrder && { sortOrder: parsedUrlParams.sortOrder }), - }; +export const isFlattenCustomField = (key: string): boolean => + key.startsWith(CUSTOM_FIELD_KEY_PREFIX); - const intPage = parsedUrlParams.page && parseInt(parsedUrlParams.page, 10); - const intPerPage = parsedUrlParams.perPage && parseInt(parsedUrlParams.perPage, 10); +export const flattenCustomFieldKey = (key: string): string => `${CUSTOM_FIELD_KEY_PREFIX}${key}`; - // page=0 is deliberately ignored - if (intPage) { - urlParams.page = intPage; - } +export const deflattenCustomFieldKey = (key: string): string => + key.replace(CUSTOM_FIELD_KEY_PREFIX, ''); + +export const stringToInteger = (value?: string | number): number | undefined => { + const num = Number(value); - // perPage=0 is deliberately ignored - if (intPerPage) { - urlParams.perPage = intPerPage; + if (isNaN(num)) { + return; } - return urlParams; + return num; }; -export const mergeSelectedColumnsWithConfiguration = ({ - selectedColumns, - casesColumnsConfig, -}: { - selectedColumns: CasesColumnSelection[]; - casesColumnsConfig: CasesColumnsConfiguration; -}): CasesColumnSelection[] => { - const result = selectedColumns.reduce((accumulator, { field, isChecked }) => { - if ( - field in casesColumnsConfig && - casesColumnsConfig[field].field !== '' && - casesColumnsConfig[field].canDisplay - ) { - accumulator.push({ - field: casesColumnsConfig[field].field, - name: casesColumnsConfig[field].name, - isChecked, - }); - } - return accumulator; - }, [] as CasesColumnSelection[]); - - // This will include any new customFields and/or changes to the case attributes - const missingColumns = difference( - Object.keys(casesColumnsConfig), - selectedColumns.map(({ field }) => field) - ); - - missingColumns.forEach((field) => { - // can be an empty string - if (casesColumnsConfig[field].field && casesColumnsConfig[field].canDisplay) { - result.push({ - field: casesColumnsConfig[field].field, - name: casesColumnsConfig[field].name, - isChecked: casesColumnsConfig[field].isCheckedDefault, - }); - } - }); +export const stringToIntegerWithDefault = ( + value: string | number, + defaultValue: number +): number | undefined => { + const valueAsInteger = stringToInteger(value); - return result; + return valueAsInteger && valueAsInteger > 0 ? valueAsInteger : defaultValue; }; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/merge_selected_columns_with_configuration.ts b/x-pack/plugins/cases/public/components/all_cases/utils/merge_selected_columns_with_configuration.ts new file mode 100644 index 0000000000000..c56c5e70aed7e --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/merge_selected_columns_with_configuration.ts @@ -0,0 +1,52 @@ +/* + * 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 { difference } from 'lodash'; +import type { CasesColumnSelection } from '../types'; +import type { CasesColumnsConfiguration } from '../use_cases_columns_configuration'; + +export const mergeSelectedColumnsWithConfiguration = ({ + selectedColumns, + casesColumnsConfig, +}: { + selectedColumns: CasesColumnSelection[]; + casesColumnsConfig: CasesColumnsConfiguration; +}): CasesColumnSelection[] => { + const result = selectedColumns.reduce((accumulator, { field, isChecked }) => { + if ( + field in casesColumnsConfig && + casesColumnsConfig[field].field !== '' && + casesColumnsConfig[field].canDisplay + ) { + accumulator.push({ + field: casesColumnsConfig[field].field, + name: casesColumnsConfig[field].name, + isChecked, + }); + } + return accumulator; + }, [] as CasesColumnSelection[]); + + // This will include any new customFields and/or changes to the case attributes + const missingColumns = difference( + Object.keys(casesColumnsConfig), + selectedColumns.map(({ field }) => field) + ); + + missingColumns.forEach((field) => { + // can be an empty string + if (casesColumnsConfig[field].field && casesColumnsConfig[field].canDisplay) { + result.push({ + field: casesColumnsConfig[field].field, + name: casesColumnsConfig[field].name, + isChecked: casesColumnsConfig[field].isCheckedDefault, + }); + } + }); + + return result; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_params.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_params.test.tsx new file mode 100644 index 0000000000000..7daa7166f051b --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_params.test.tsx @@ -0,0 +1,254 @@ +/* + * 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 { encode } from '@kbn/rison'; +import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from '../../../containers/constants'; + +import { parseUrlParams } from './parse_url_params'; + +describe('parseUrlParams', () => { + const defaultValuesAsURL = new URLSearchParams({ + cases: encode({ + ...DEFAULT_FILTER_OPTIONS, + ...DEFAULT_QUERY_PARAMS, + }), + }); + + it('parses the default filter options and query params correctly', () => { + expect(parseUrlParams(defaultValuesAsURL)).toMatchInlineSnapshot(` + Object { + "assignees": Array [], + "category": Array [], + "customFields": Object {}, + "page": 1, + "perPage": 10, + "search": "", + "severity": Array [], + "sortField": "createdAt", + "sortOrder": "desc", + "status": Array [], + "tags": Array [], + } + `); + }); + + it('parses a mix of fields correctly', () => { + const state = { + assignees: ['elastic'], + tags: ['a', 'b'], + category: [], + status: ['open'], + search: 'My title', + owner: ['cases'], + customFields: { my_field: ['foo'] }, + }; + + const url = `cases=${encode(state)}`; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "assignees": Array [ + "elastic", + ], + "category": Array [], + "customFields": Object { + "my_field": Array [ + "foo", + ], + }, + "search": "My title", + "status": Array [ + "open", + ], + "tags": Array [ + "a", + "b", + ], + } + `); + }); + + it('protects against prototype attacks', () => { + const firstUrl = 'cases=(customFields:(__proto__:!(foo)))'; + const secondUrl = 'cases=(customFields:(__proto__:(property:payload)))'; + + // @ts-expect-error: testing prototype attacks + expect(parseUrlParams(new URLSearchParams(firstUrl)).__proto__).toEqual({}); + // @ts-expect-error: testing prototype attacks + expect(parseUrlParams(new URLSearchParams(secondUrl)).__proto__).toEqual({}); + }); + + it('parses empty query params correctly', () => { + expect(parseUrlParams(new URLSearchParams())).toMatchInlineSnapshot(`Object {}`); + }); + + it('parses an empty string correctly', () => { + expect(parseUrlParams(new URLSearchParams(''))).toMatchInlineSnapshot(`Object {}`); + }); + + it('parses an unrecognized query param correctly', () => { + expect(parseUrlParams(new URLSearchParams('foo='))).toMatchInlineSnapshot(`Object {}`); + }); + + it('parses an empty string correctly in the cases object correctly', () => { + expect(parseUrlParams(new URLSearchParams({ cases: '' }))).toMatchInlineSnapshot(`Object {}`); + }); + + it('parses a malformed rison url correctly', () => { + expect(parseUrlParams(new URLSearchParams({ cases: '!' }))).toMatchInlineSnapshot(`Object {}`); + }); + + it('parses a rison url that is not an object correctly', () => { + for (const value of ['foo', true, false, ['bar'], null, 0]) { + expect(parseUrlParams(new URLSearchParams({ cases: encode(value) }))).toEqual({}); + } + }); + + it('validates the query params schema correctly', () => { + expect( + parseUrlParams(new URLSearchParams({ cases: encode({ status: 'foo' }) })) + ).toMatchInlineSnapshot(`Object {}`); + }); + + describe('legacy URLs', () => { + it('parses a legacy url with all legacy supported keys correctly', () => { + const url = 'status=open&severity=low&page=2&perPage=50&sortField=closedAt&sortOrder=asc'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "page": 2, + "perPage": 50, + "severity": Array [ + "low", + ], + "sortField": "closedAt", + "sortOrder": "asc", + "status": Array [ + "open", + ], + } + `); + }); + + it('parses a url with status=open,closed', () => { + const url = 'status=open,closed'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "status": Array [ + "open", + "closed", + ], + } + `); + }); + + it('parses a url with status=in-progress', () => { + const url = 'status=in-progress'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "status": Array [ + "in-progress", + ], + } + `); + }); + + it('parses a url with status=open&status=closed', () => { + const url = 'status=open&status=closed'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "status": Array [ + "open", + "closed", + ], + } + `); + }); + + it('parses a url with status=open,closed&status=in-progress', () => { + const url = 'status=open,closed&status=in-progress'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "status": Array [ + "open", + "closed", + "in-progress", + ], + } + `); + }); + + it('parses a url with severity=low,medium&severity=high,critical', () => { + const url = 'severity=low,medium&severity=high,critical'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "severity": Array [ + "low", + "medium", + "high", + "critical", + ], + } + `); + }); + + it('parses a url with severity=low,medium&severity=high&severity=critical', () => { + const url = 'severity=low,medium&severity=high&severity=critical'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "severity": Array [ + "low", + "medium", + "high", + "critical", + ], + } + `); + }); + + it('parses a url with page=2&page=5&perPage=4&perPage=20', () => { + const url = 'page=2&page=5&perPage=4&perPage=20'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "page": 2, + "perPage": 4, + } + `); + }); + + it('validates the query params schema correctly', () => { + const url = 'status=foo'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(`Object {}`); + }); + + it('sets the defaults to page and perPage correctly if they are not numbers', () => { + const url = 'page=foo&perPage=bar'; + + expect(parseUrlParams(new URLSearchParams(url))).toMatchInlineSnapshot(` + Object { + "page": 1, + "perPage": 10, + } + `); + }); + + it('protects against prototype attacks', () => { + const url = '__proto__[property]=payload'; + + // @ts-expect-error: testing prototype attacks + expect(parseUrlParams(new URLSearchParams(url)).__proto__.property).toEqual(undefined); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_params.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_params.tsx new file mode 100644 index 0000000000000..7e67cdfab89b2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_params.tsx @@ -0,0 +1,129 @@ +/* + * 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 { safeDecode } from '@kbn/rison'; +import { isPlainObject } from 'lodash'; +import type { CaseStatuses } from '@kbn/cases-components'; +import type { CaseSeverity } from '../../../../common'; +import { DEFAULT_CASES_TABLE_STATE } from '../../../containers/constants'; +import { stringToIntegerWithDefault } from '.'; +import { SortFieldCase } from '../../../../common/ui'; +import { LEGACY_SUPPORTED_STATE_KEYS, ALL_CASES_STATE_URL_KEY } from '../constants'; +import { AllCasesURLQueryParamsRt, validateSchema } from '../schema'; +import type { AllCasesURLQueryParams } from '../types'; + +type LegacySupportedKeys = typeof LEGACY_SUPPORTED_STATE_KEYS[number]; + +const legacyDefaultState: Record = { + page: 1, + perPage: 10, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', + status: [], + severity: [], +}; + +/** + * Parses legacy state in URL. + * + * - Parameters in the query string can have multiple formats: + * 1. Comma-separated values (e.g., "status=foo,bar") + * 2. A single value (e.g., "status=foo") + * 3. Repeated keys (e.g., "status=foo&status=bar") + * + */ +const parseLegacyUrl = (urlParams: URLSearchParams): AllCasesURLQueryParams => { + const urlParamsMap = new Map>(); + + urlParams.forEach((value, key) => { + if (LEGACY_SUPPORTED_STATE_KEYS.includes(key as LegacySupportedKeys)) { + const values = urlParamsMap.get(key) ?? new Set(); + + value + .split(',') + .filter(Boolean) + .forEach((urlValue) => values.add(urlValue)); + + urlParamsMap.set(key, values); + } + }); + + const entries = new Map( + [...urlParamsMap].map(([key, value]) => [ + key, + parseValue(value, legacyDefaultState[key as LegacySupportedKeys]), + ]) + ); + + const params = Object.fromEntries(entries.entries()); + const allCasesParams: AllCasesURLQueryParams = { ...params }; + + if (params.page) { + allCasesParams.page = stringToIntegerWithDefault( + Array.isArray(params.page) ? params.page[0] : params.page, + DEFAULT_CASES_TABLE_STATE.queryParams.page + ); + } + + if (params.perPage) { + allCasesParams.perPage = stringToIntegerWithDefault( + Array.isArray(params.perPage) ? params.perPage[0] : params.perPage, + DEFAULT_CASES_TABLE_STATE.queryParams.perPage + ); + } + + if (params.status) { + const statusAsArray = Array.isArray(params.status) ? params.status : [params.status]; + allCasesParams.status = statusAsArray.filter(notAll).filter(Boolean) as CaseStatuses[]; + } + + if (params.severity) { + const severityAsArray = Array.isArray(params.severity) ? params.severity : [params.severity]; + allCasesParams.severity = severityAsArray.filter(notAll).filter(Boolean) as CaseSeverity[]; + } + + return allCasesParams; +}; + +const parseValue = (values: Set, defaultValue: unknown): string | string[] => { + const valuesAsArray = Array.from(values.values()); + return Array.isArray(defaultValue) ? valuesAsArray : valuesAsArray[0] ?? ''; +}; + +const notAll = (option: string) => option !== 'all'; + +export function parseUrlParams(urlParams: URLSearchParams): AllCasesURLQueryParams { + const allCasesParams = urlParams.get(ALL_CASES_STATE_URL_KEY); + + if (!allCasesParams) { + return parseAndValidateLegacyUrl(urlParams); + } + + const parsedAllCasesParams = safeDecode(allCasesParams); + + if (!parsedAllCasesParams || !isPlainObject(parsedAllCasesParams)) { + return {}; + } + + const validatedAllCasesParams = validateSchema(parsedAllCasesParams, AllCasesURLQueryParamsRt); + + if (!validatedAllCasesParams) { + return {}; + } + + return validatedAllCasesParams; +} + +const parseAndValidateLegacyUrl = (urlParams: URLSearchParams): AllCasesURLQueryParams => { + const validatedUrlParams = validateSchema(parseLegacyUrl(urlParams), AllCasesURLQueryParamsRt); + + if (!validatedUrlParams) { + return {}; + } + + return validatedUrlParams; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.test.tsx deleted file mode 100644 index 2cfa14f3af328..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// This file was contributed to by generative AI - -import { parseURLWithFilterOptions } from './parse_url_with_filter_options'; - -describe('parseURLWithFilterOptions', () => { - it('parses a url with search=foo', () => { - const url = 'search=foo'; - expect(parseURLWithFilterOptions(url)).toStrictEqual({ search: 'foo' }); - }); - - it('parses a url with status=foo,bar', () => { - const url = 'status=foo,bar'; - expect(parseURLWithFilterOptions(url)).toStrictEqual({ status: ['foo', 'bar'] }); - }); - - it('parses a url with status=foo', () => { - const url = 'status=foo'; - expect(parseURLWithFilterOptions(url)).toStrictEqual({ status: ['foo'] }); - }); - - it('parses a url with status=foo&status=bar', () => { - const url = 'status=foo&status=bar'; - expect(parseURLWithFilterOptions(url)).toStrictEqual({ status: ['foo', 'bar'] }); - }); - - it('parses a url with status=foo,bar&status=baz', () => { - const url = 'status=foo,bar&status=baz'; - expect(parseURLWithFilterOptions(url)).toStrictEqual({ status: ['foo', 'bar', 'baz'] }); - }); - - it('parses a url with status=foo,bar&status=baz,qux', () => { - const url = 'status=foo,bar&status=baz,qux'; - expect(parseURLWithFilterOptions(url)).toStrictEqual({ - status: ['foo', 'bar', 'baz', 'qux'], - }); - }); - - it('parses a url with status=foo,bar&status=baz,qux&status=quux', () => { - const url = 'status=foo,bar&status=baz,qux&status=quux'; - expect(parseURLWithFilterOptions(url)).toStrictEqual({ - status: ['foo', 'bar', 'baz', 'qux', 'quux'], - }); - }); - - it('parses a url with status=', () => { - const url = 'status='; - expect(parseURLWithFilterOptions(url)).toStrictEqual({ status: [] }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.tsx deleted file mode 100644 index 6467dc99ab440..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/utils/parse_url_with_filter_options.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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 { DEFAULT_FILTER_OPTIONS } from '../../../containers/constants'; - -/** - * Parses filter options from a URL query string. - * - * The behavior is influenced by the predefined DEFAULT_FILTER_OPTIONS: - * - If an option is defined as an array there, it will always be returned as an array. - * - Parameters in the query string can have multiple formats: - * 1. Comma-separated values (e.g., "status=foo,bar") - * 2. A single value (e.g., "status=foo") - * 3. Repeated keys (e.g., "status=foo&status=bar") - * - * This function ensures the output respects the format indicated in DEFAULT_FILTER_OPTIONS. - */ -export const parseURLWithFilterOptions = (search: string) => { - const urlParams = new URLSearchParams(search); - - const paramKeysWithTypeArray = Object.entries(DEFAULT_FILTER_OPTIONS) - .map(([key, val]) => (Array.isArray(val) ? key : undefined)) - .filter(Boolean); - - const parsedUrlParams: { [key in string]: string[] | string } = {}; - for (const [key, value] of urlParams.entries()) { - if (paramKeysWithTypeArray.includes(key)) { - if (!parsedUrlParams[key]) parsedUrlParams[key] = []; - // only applies if the value is separated by commas (e.g., "foo,bar") - const splittedValues = value.split(',').filter(Boolean); - (parsedUrlParams[key] as string[]).push(...splittedValues); - } else { - parsedUrlParams[key] = value; - } - } - - return parsedUrlParams; -}; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.test.tsx deleted file mode 100644 index b96dbc40fe668..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// This file was contributed to by generative AI - -import { CaseStatuses, CaseSeverity } from '../../../../common/types/domain'; -import { removeLegacyValuesFromOptions, getStorableFilters } from './sanitize_filter_options'; - -describe('removeLegacyValuesFromOptions', () => { - it('should remove legacy values from options', () => { - const options: { - status: Array; - severity: Array; - } = { - status: ['all', CaseStatuses.open, CaseStatuses['in-progress'], 'all'], - severity: ['all', CaseSeverity.LOW, 'all'], - }; - - expect(removeLegacyValuesFromOptions(options)).toEqual({ - status: ['open', 'in-progress'], - severity: ['low'], - }); - }); -}); - -describe('getStorableFilters', () => { - it('should return the filters if provided', () => { - expect( - getStorableFilters({ - status: [CaseStatuses.open, CaseStatuses['in-progress']], - severity: [CaseSeverity.LOW], - }) - ).toEqual({ - status: [CaseStatuses.open, CaseStatuses['in-progress']], - severity: [CaseSeverity.LOW], - }); - }); - - it('should return undefined if no filters are provided', () => { - expect(getStorableFilters({})).toEqual({ status: undefined, severity: undefined }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.tsx deleted file mode 100644 index 498d2998a0a20..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_filter_options.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FilterOptions } from '../../../../common/ui/types'; -import type { CaseStatuses, CaseSeverity } from '../../../../common/types/domain'; - -const notAll = (option: string) => option !== 'all'; - -/** - * In earlier versions, the options 'status' and 'severity' could have a value of 'all'. - * This function ensures such legacy values are removed from the URL parameters to maintain - * backwards compatibility. - */ -export const removeLegacyValuesFromOptions = ({ - status: legacyStatus, - severity: legacySeverity, -}: { - status: Array; - severity: Array; -}): { status: CaseStatuses[]; severity: CaseSeverity[] } => { - return { - status: legacyStatus.filter(notAll).filter(Boolean) as CaseStatuses[], - severity: legacySeverity.filter(notAll).filter(Boolean) as CaseSeverity[], - }; -}; - -export const getStorableFilters = ( - filterOptions: Partial -): { status: CaseStatuses[] | undefined; severity: CaseSeverity[] | undefined } => { - const { status, severity } = filterOptions; - - return { severity, status }; -}; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_state.test.ts b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_state.test.ts new file mode 100644 index 0000000000000..a23cbed01f4e2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_state.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { CaseStatuses } from '@kbn/cases-components'; +import { CaseSeverity } from '../../../../common'; +import { DEFAULT_CASES_TABLE_STATE } from '../../../containers/constants'; +import { sanitizeState } from './sanitize_state'; + +describe('sanitizeState', () => { + it('sanitize default state correctly', () => { + expect(sanitizeState(DEFAULT_CASES_TABLE_STATE)).toEqual(DEFAULT_CASES_TABLE_STATE); + }); + + it('sanitize perPage query param correctly if it is bigger than 100', () => { + expect(sanitizeState({ queryParams: { perPage: 1000 } })).toEqual({ + filterOptions: {}, + queryParams: { perPage: 100 }, + }); + }); + + it('sanitize sortOrder correctly', () => { + // @ts-expect-error: need to check unrecognized values + expect(sanitizeState({ queryParams: { sortOrder: 'foo' } })).toEqual({ + filterOptions: {}, + queryParams: { sortOrder: 'desc' }, + }); + }); + + it('returns empty state with no arguments', () => { + expect(sanitizeState()).toEqual({ + filterOptions: {}, + queryParams: {}, + }); + }); + + it('sanitize status correctly', () => { + // @ts-expect-error: need to check unrecognized values + expect(sanitizeState({ filterOptions: { status: ['foo', CaseStatuses.open] } })).toEqual({ + filterOptions: { status: ['open'] }, + queryParams: {}, + }); + }); + + it('sanitize severity correctly', () => { + // @ts-expect-error: need to check unrecognized values + expect(sanitizeState({ filterOptions: { severity: ['foo', CaseSeverity.MEDIUM] } })).toEqual({ + filterOptions: { severity: ['medium'] }, + queryParams: {}, + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_state.ts b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_state.ts new file mode 100644 index 0000000000000..a0553a5ade632 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/sanitize_state.ts @@ -0,0 +1,78 @@ +/* + * 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 { CaseStatuses } from '@kbn/cases-components'; +import { CaseSeverity } from '../../../../common'; +import { SORT_ORDER_VALUES } from '../../../../common/ui'; +import { DEFAULT_QUERY_PARAMS } from '../../../containers/constants'; +import type { AllCasesTableState } from '../types'; +import { CASES_TABLE_PER_PAGE_VALUES } from '../types'; + +interface PartialState { + queryParams?: Partial; + filterOptions?: Partial; +} + +interface PartialParams { + queryParams: Partial; + filterOptions: Partial; +} + +export const sanitizeState = (state: PartialState = {}): PartialParams => { + return { + queryParams: sanitizeQueryParams(state.queryParams) ?? {}, + filterOptions: sanitizeFilterOptions(state.filterOptions) ?? {}, + }; +}; + +const sanitizeQueryParams = ( + queryParams: PartialState['queryParams'] = {} +): PartialState['queryParams'] => { + const { perPage, sortOrder, ...restQueryParams } = queryParams; + + const queryParamsSanitized: PartialState['queryParams'] = { + ...restQueryParams, + }; + + if (perPage) { + queryParamsSanitized.perPage = Math.min( + perPage, + CASES_TABLE_PER_PAGE_VALUES[CASES_TABLE_PER_PAGE_VALUES.length - 1] + ); + } + + if (sortOrder) { + queryParamsSanitized.sortOrder = SORT_ORDER_VALUES.includes(sortOrder) + ? sortOrder + : DEFAULT_QUERY_PARAMS.sortOrder; + } + + return queryParamsSanitized; +}; + +const sanitizeFilterOptions = ( + filterOptions: PartialState['filterOptions'] = {} +): PartialState['filterOptions'] => { + const { status, severity, ...restFilterOptions } = filterOptions; + + const filterOptionsSanitized: PartialState['filterOptions'] = { + ...restFilterOptions, + }; + + if (status) { + filterOptionsSanitized.status = filterOutOptions(status, CaseStatuses); + } + + if (severity) { + filterOptionsSanitized.severity = filterOutOptions(severity, CaseSeverity); + } + + return filterOptionsSanitized; +}; + +const filterOutOptions = (collection: T[], validValues: Record): T[] => + collection.filter((value) => Object.values(validValues).includes(value)); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.test.tsx deleted file mode 100644 index ace5fdda934ab..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { serializeUrlParams } from './serialize_url_params'; - -describe('serializeUrlParams', () => { - const commonProps = { - page: '1', - perPage: '5', - sortField: 'createdAt', - sortOrder: 'desc', - }; - - it('empty severity and status', () => { - const urlParams = { - ...commonProps, - status: [], - severity: [], - }; - - expect(serializeUrlParams(urlParams).toString()).toEqual( - 'page=1&perPage=5&sortField=createdAt&sortOrder=desc&status=&severity=' - ); - }); - - it('severity and status with one value', () => { - const urlParams = { - ...commonProps, - status: ['open'], - severity: ['low'], - }; - - expect(serializeUrlParams(urlParams).toString()).toEqual( - 'page=1&perPage=5&sortField=createdAt&sortOrder=desc&status=open&severity=low' - ); - }); - - it('severity and status with multiple values', () => { - const urlParams = { - ...commonProps, - status: ['open', 'closed'], - severity: ['low', 'high'], - }; - - expect(serializeUrlParams(urlParams).toString()).toEqual( - 'page=1&perPage=5&sortField=createdAt&sortOrder=desc&status=open&status=closed&severity=low&severity=high' - ); - }); - - it('severity and status are undefined', () => { - const urlParams = { - ...commonProps, - status: undefined, - severity: undefined, - }; - - expect(serializeUrlParams(urlParams).toString()).toEqual( - 'page=1&perPage=5&sortField=createdAt&sortOrder=desc' - ); - }); - - it('severity and status are undefined but there are more filters to serialize', () => { - const urlParams = { - status: undefined, - severity: undefined, - ...commonProps, - }; - - expect(serializeUrlParams(urlParams).toString()).toEqual( - 'page=1&perPage=5&sortField=createdAt&sortOrder=desc' - ); - }); -}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.tsx deleted file mode 100644 index 4b3e352b894d0..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/utils/serialize_url_params.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export function serializeUrlParams(urlParams: { - [key in string]: string[] | string | undefined; -}) { - const urlSearchParams = new URLSearchParams(); - for (const [key, value] of Object.entries(urlParams)) { - if (value) { - if (Array.isArray(value)) { - if (value.length === 0) { - urlSearchParams.append(key, ''); - } else { - value.forEach((v) => urlSearchParams.append(key, v)); - } - } else { - urlSearchParams.append(key, value); - } - } - } - - return urlSearchParams; -} diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/stringify_url_params.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/stringify_url_params.test.tsx new file mode 100644 index 0000000000000..4f67764260bb3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/stringify_url_params.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 { CaseStatuses } from '@kbn/cases-components'; +import { DEFAULT_CASES_TABLE_STATE } from '../../../containers/constants'; +import { CaseSeverity } from '../../../../common'; +import { SortFieldCase } from '../../../../common/ui'; +import { stringifyUrlParams } from './stringify_url_params'; + +describe('stringifyUrlParams', () => { + const commonProps = { + page: 1, + perPage: 5, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc' as const, + }; + + it('empty severity and status', () => { + const urlParams = { + ...commonProps, + status: [], + severity: [], + }; + + expect(stringifyUrlParams(urlParams)).toMatchInlineSnapshot( + `"cases=(page:1,perPage:5,severity:!(),sortField:createdAt,sortOrder:desc,status:!())"` + ); + }); + + it('severity and status with one value', () => { + const urlParams = { + ...commonProps, + status: [CaseStatuses.open], + severity: [CaseSeverity.LOW], + }; + + expect(stringifyUrlParams(urlParams)).toMatchInlineSnapshot( + `"cases=(page:1,perPage:5,severity:!(low),sortField:createdAt,sortOrder:desc,status:!(open))"` + ); + }); + + it('severity and status with multiple values', () => { + const urlParams = { + ...commonProps, + status: [CaseStatuses.open, CaseStatuses.closed], + severity: [CaseSeverity.LOW, CaseSeverity.HIGH], + }; + + expect(stringifyUrlParams(urlParams)).toMatchInlineSnapshot( + `"cases=(page:1,perPage:5,severity:!(low,high),sortField:createdAt,sortOrder:desc,status:!(open,closed))"` + ); + }); + + it('severity and status are undefined', () => { + const urlParams = { + ...commonProps, + status: undefined, + severity: undefined, + }; + + expect(stringifyUrlParams(urlParams)).toMatchInlineSnapshot( + `"cases=(page:1,perPage:5,sortField:createdAt,sortOrder:desc)"` + ); + }); + + it('severity and status are undefined but there are more filters to serialize', () => { + const urlParams = { + status: undefined, + severity: undefined, + ...commonProps, + }; + + expect(stringifyUrlParams(urlParams)).toMatchInlineSnapshot( + `"cases=(page:1,perPage:5,sortField:createdAt,sortOrder:desc)"` + ); + }); + + it('encodes defaults correctly', () => { + const { customFields, ...filterOptionsWithoutCustomFields } = + DEFAULT_CASES_TABLE_STATE.filterOptions; + + const urlParams = { + ...filterOptionsWithoutCustomFields, + ...DEFAULT_CASES_TABLE_STATE.queryParams, + customFields: { my_field: ['foo', 'bar'] }, + }; + + expect(stringifyUrlParams(urlParams)).toMatchInlineSnapshot( + `"cases=(assignees:!(),category:!(),customFields:(my_field:!(foo,bar)),owner:!(),page:1,perPage:10,reporters:!(),search:'',searchFields:!(title,description),severity:!(),sortField:createdAt,sortOrder:desc,status:!(),tags:!())"` + ); + }); + + it('replaces the cases query param correctly', () => { + expect( + stringifyUrlParams( + { + perPage: 100, + }, + 'cases=(perPage:5)' + ) + ).toMatchInlineSnapshot(`"cases=(perPage:100)"`); + }); + + it('removes legacy keys from URL', () => { + const search = 'status=foo&severity=foo&page=2&perPage=50&sortField=closedAt&sortOrder=asc'; + + expect( + stringifyUrlParams( + { + perPage: 100, + }, + search + ) + ).toMatchInlineSnapshot(`"cases=(perPage:100)"`); + }); + + it('keeps non cases state', () => { + expect( + stringifyUrlParams( + { + perPage: 100, + }, + 'foo=bar' + ) + ).toMatchInlineSnapshot(`"cases=(perPage:100)&foo=bar"`); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/stringify_url_params.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/stringify_url_params.tsx new file mode 100644 index 0000000000000..8c43112c093aa --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils/stringify_url_params.tsx @@ -0,0 +1,35 @@ +/* + * 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 { encode } from '@kbn/rison'; +import { ALL_CASES_STATE_URL_KEY, LEGACY_SUPPORTED_STATE_KEYS } from '../constants'; +import type { AllCasesURLQueryParams } from '../types'; + +export function stringifyUrlParams( + allCasesUrlParams: AllCasesURLQueryParams, + currentSearch: string = '' +): string { + const encodedUrlParams = encode({ ...allCasesUrlParams }); + + const searchUrlParams = removeLegacyStateFromUrl( + new URLSearchParams(decodeURIComponent(currentSearch)) + ); + + searchUrlParams.delete(ALL_CASES_STATE_URL_KEY); + const casesQueryParam = `${ALL_CASES_STATE_URL_KEY}=${encodedUrlParams}`; + + return searchUrlParams.size > 0 + ? `${casesQueryParam}&${searchUrlParams.toString()}` + : casesQueryParam; +} + +const removeLegacyStateFromUrl = (urlParams: URLSearchParams): URLSearchParams => { + const newUrlParams = new URLSearchParams(urlParams); + LEGACY_SUPPORTED_STATE_KEYS.forEach((key) => newUrlParams.delete(key)); + + return newUrlParams; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/utils/utils.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils/utils.test.tsx deleted file mode 100644 index 3fdb9f035f417..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/utils/utils.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 { parseUrlQueryParams } from '.'; -import { DEFAULT_QUERY_PARAMS } from '../../../containers/constants'; - -const DEFAULT_STRING_QUERY_PARAMS = { - ...DEFAULT_QUERY_PARAMS, - page: String(DEFAULT_QUERY_PARAMS.page), - perPage: String(DEFAULT_QUERY_PARAMS.perPage), -}; - -describe('utils', () => { - describe('parseUrlQueryParams', () => { - it('valid input is processed correctly', () => { - expect(parseUrlQueryParams(DEFAULT_STRING_QUERY_PARAMS)).toStrictEqual(DEFAULT_QUERY_PARAMS); - }); - - it('empty string value for page/perPage is ignored', () => { - expect( - parseUrlQueryParams({ - ...DEFAULT_STRING_QUERY_PARAMS, - page: '', - perPage: '', - }) - ).toStrictEqual({ - sortField: DEFAULT_QUERY_PARAMS.sortField, - sortOrder: DEFAULT_QUERY_PARAMS.sortOrder, - }); - }); - - it('0 value for page/perPage is ignored', () => { - expect( - parseUrlQueryParams({ - ...DEFAULT_STRING_QUERY_PARAMS, - page: '0', - perPage: '0', - }) - ).toStrictEqual({ - sortField: DEFAULT_QUERY_PARAMS.sortField, - sortOrder: DEFAULT_QUERY_PARAMS.sortOrder, - }); - }); - - it('invalid string values for page/perPage are ignored', () => { - expect( - parseUrlQueryParams({ - ...DEFAULT_STRING_QUERY_PARAMS, - page: 'foo', - perPage: 'bar', - }) - ).toStrictEqual({ - sortField: DEFAULT_QUERY_PARAMS.sortField, - sortOrder: DEFAULT_QUERY_PARAMS.sortOrder, - }); - }); - - it('additional URL parameters are ignored', () => { - expect( - parseUrlQueryParams({ - ...DEFAULT_STRING_QUERY_PARAMS, - foo: 'bar', - }) - ).toStrictEqual(DEFAULT_QUERY_PARAMS); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 97e84b8bbdc82..72fbbc24c15ec 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -179,6 +179,7 @@ export const removeItemFromSessionStorage = (key: string) => { export const stringifyToURL = (parsedParams: Record | URLSearchParams) => new URLSearchParams(parsedParams).toString(); + export const parseURL = (queryString: string) => Object.fromEntries(new URLSearchParams(queryString)); diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index 76d95a8bd0375..54e7cebba9025 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { AllCasesTableState } from '../components/all_cases/types'; import type { FilterOptions, QueryParams, SingleCaseMetricsFeature } from './types'; import { SortFieldCase } from './types'; @@ -66,6 +67,7 @@ export const casesMutationsKeys = { const DEFAULT_SEARCH_FIELDS = ['title', 'description']; +// TODO: Remove reporters. Move searchFields to API. export const DEFAULT_FILTER_OPTIONS: FilterOptions = { search: '', searchFields: DEFAULT_SEARCH_FIELDS, @@ -85,3 +87,8 @@ export const DEFAULT_QUERY_PARAMS: QueryParams = { sortField: SortFieldCase.createdAt, sortOrder: 'desc', }; + +export const DEFAULT_CASES_TABLE_STATE: AllCasesTableState = { + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: DEFAULT_QUERY_PARAMS, +}; diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json index f231a8d69b548..ec596cf815833 100644 --- a/x-pack/plugins/cases/tsconfig.json +++ b/x-pack/plugins/cases/tsconfig.json @@ -71,6 +71,7 @@ "@kbn/alerting-plugin", "@kbn/content-management-plugin", "@kbn/index-management-plugin", + "@kbn/rison", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index b35f69de50dbd..0fda2e3915518 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -10999,7 +10999,6 @@ "xpack.cases.allCases.comments": "Commentaires", "xpack.cases.allCases.noCategoriesAvailable": "Pas de catégories disponibles", "xpack.cases.allCases.noTagsAvailable": "Aucune balise disponible", - "xpack.cases.allCasesView.filterAssignees.clearFilters": "Effacer les filtres", "xpack.cases.allCasesView.filterAssignees.noAssigneesLabel": "Aucun utilisateur affecté", "xpack.cases.allCasesView.filterAssigneesAriaLabel": "cliquer pour filtrer les utilisateurs affectés", "xpack.cases.allCasesView.showLessAvatars": "afficher moins", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f1ad2adee7dd7..a18a4202fc850 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11013,7 +11013,6 @@ "xpack.cases.allCases.comments": "コメント", "xpack.cases.allCases.noCategoriesAvailable": "カテゴリがありません", "xpack.cases.allCases.noTagsAvailable": "利用可能なタグがありません", - "xpack.cases.allCasesView.filterAssignees.clearFilters": "フィルターを消去", "xpack.cases.allCasesView.filterAssignees.noAssigneesLabel": "担当者なし", "xpack.cases.allCasesView.filterAssigneesAriaLabel": "クリックすると、担当者でフィルタリングします", "xpack.cases.allCasesView.showLessAvatars": "縮小表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 16c8656569620..601cef213948a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11107,7 +11107,6 @@ "xpack.cases.allCases.comments": "注释", "xpack.cases.allCases.noCategoriesAvailable": "没有可用类别", "xpack.cases.allCases.noTagsAvailable": "没有可用标签", - "xpack.cases.allCasesView.filterAssignees.clearFilters": "清除筛选", "xpack.cases.allCasesView.filterAssignees.noAssigneesLabel": "无被分配人", "xpack.cases.allCasesView.filterAssigneesAriaLabel": "单击以筛选被分配人", "xpack.cases.allCasesView.showLessAvatars": "显示更少", diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts index 03d1078ccec2c..f6ec13a492318 100644 --- a/x-pack/test/functional/services/cases/list.ts +++ b/x-pack/test/functional/services/cases/list.ts @@ -5,6 +5,9 @@ * 2.0. */ +import deepEqual from 'react-fast-compare'; +import expect from '@kbn/expect'; +import rison from '@kbn/rison'; import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -100,6 +103,15 @@ export function CasesTableServiceProvider( await header.waitUntilLoadingHasFinished(); }, + async waitForNthToBeListed(numberOfCases: number) { + await retry.try(async () => { + await this.refreshTable(); + await this.validateCasesTableHasNthRows(numberOfCases); + }); + + await header.waitUntilLoadingHasFinished(); + }, + async waitForCasesToBeDeleted() { await retry.waitFor('the cases table to be empty', async () => { await this.refreshTable(); @@ -133,6 +145,13 @@ export function CasesTableServiceProvider( return rows[index] ?? null; }, + async verifyCase(caseId: string, index: number) { + const theCaseById = await this.getCaseById(caseId); + const theCaseByIndex = await this.getCaseByIndex(index); + + return (await theCaseById._webElement.getId()) === (await theCaseByIndex._webElement.getId()); + }, + async filterByTag(tag: string) { await common.clickAndValidate( 'options-filter-popover-button-tags', @@ -437,5 +456,54 @@ export function CasesTableServiceProvider( // closes the popover await browser.pressKeys(browser.keys.ESCAPE); }, + + async clearFilters() { + if (await testSubjects.exists('all-cases-clear-filters-link-icon')) { + await testSubjects.click('all-cases-clear-filters-link-icon'); + await header.waitUntilLoadingHasFinished(); + } + }, + + async setAllCasesStateInLocalStorage(state: Record) { + await browser.setLocalStorageItem('management.cases.list.state', JSON.stringify(state)); + + const currentState = JSON.parse( + (await browser.getLocalStorageItem('management.cases.list.state')) ?? '{}' + ); + + expect(deepEqual(currentState, state)).to.be(true); + }, + + async getAllCasesStateInLocalStorage() { + const currentState = JSON.parse( + (await browser.getLocalStorageItem('management.cases.list.state')) ?? '{}' + ); + + return currentState; + }, + + async setFiltersConfigurationInLocalStorage(state: Array<{ key: string; isActive: boolean }>) { + await browser.setLocalStorageItem( + 'management.cases.list.tableFiltersConfig', + JSON.stringify(state) + ); + + const currentState = JSON.parse( + (await browser.getLocalStorageItem('management.cases.list.tableFiltersConfig')) ?? '{}' + ); + + expect(deepEqual(currentState, state)).to.be(true); + }, + + async expectFiltersToBeActive(filters: string[]) { + for (const filter of filters) { + await testSubjects.existOrFail(`options-filter-popover-button-${filter}`); + } + }, + + async setStateToUrlAndNavigate(state: Record) { + const encodedUrlParams = rison.encode(state); + await common.navigateToApp('cases', { search: `cases=${encodedUrlParams}` }); + }, }; } diff --git a/x-pack/test/functional/services/cases/navigation.ts b/x-pack/test/functional/services/cases/navigation.ts index f0d4fb52ba5e4..5b827c0287a0f 100644 --- a/x-pack/test/functional/services/cases/navigation.ts +++ b/x-pack/test/functional/services/cases/navigation.ts @@ -12,13 +12,13 @@ export function CasesNavigationProvider({ getPageObject, getService }: FtrProvid const testSubjects = getService('testSubjects'); return { - async navigateToApp(app: string = 'cases', appSelector: string = 'cases-app') { - await common.navigateToApp(app); + async navigateToApp(app: string = 'cases', appSelector: string = 'cases-app', search?: string) { + await common.navigateToApp(app, { search }); await testSubjects.existOrFail(appSelector); }, - async navigateToConfigurationPage(app: string = 'cases', appSelector: string = 'cases-app') { - await this.navigateToApp(app, appSelector); + async navigateToConfigurationPage(app: string = 'cases') { + await this.navigateToApp(app, 'cases-app'); await common.clickAndValidate('configure-case-button', 'case-configure-title'); }, diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts index 32a1ef6125b7b..906e571941ff2 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts @@ -6,7 +6,12 @@ */ import expect from '@kbn/expect'; -import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; +import rison from '@kbn/rison'; +import { + CaseSeverity, + CaseStatuses, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import { UserProfile } from '@kbn/user-profile-components'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { @@ -24,6 +29,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('cases list', () => { before(async () => { + await cases.api.deleteAllCases(); await cases.navigation.navigateToApp(); }); @@ -280,13 +286,19 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('filtering', () => { const caseTitle = 'matchme'; - const caseIds: string[] = []; - - before(async () => { - await createUsersAndRoles(getService, users, roles); - await cases.api.activateUserProfiles([casesAllUser, casesAllUser2]); - - const profiles = await cases.api.suggestUserProfiles({ name: 'all', owners: ['cases'] }); + let caseIds: string[] = []; + const profiles: UserProfile[] = []; + const customFields = [ + { + key: 'my_field_01', + label: 'My field', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const createCases = async () => { + caseIds = []; const case1 = await cases.api.createCase({ title: caseTitle, @@ -309,18 +321,30 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { caseIds.push(case2.id); caseIds.push(case3.id); caseIds.push(case4.id); + }; + + before(async () => { + await cases.api.deleteAllCases(); + await createUsersAndRoles(getService, users, roles); + await cases.api.activateUserProfiles([casesAllUser, casesAllUser2]); + + profiles.push(...(await cases.api.suggestUserProfiles({ name: 'all', owners: ['cases'] }))); await header.waitUntilLoadingHasFinished(); - await cases.casesTable.waitForCasesToBeListed(); }); beforeEach(async () => { - /** - * There is no easy way to clear the filtering. - * Refreshing the page seems to be easier. - */ await browser.clearLocalStorage(); - await cases.navigation.navigateToApp(); + await cases.api.createConfigWithCustomFields({ customFields, owner: 'cases' }); + await createCases(); + await header.waitUntilLoadingHasFinished(); + await cases.casesTable.waitForCasesToBeListed(); + }); + + afterEach(async () => { + await cases.casesTable.clearFilters(); + await cases.api.deleteAllCases(); + await cases.casesTable.waitForCasesToBeDeleted(); }); after(async () => { @@ -500,6 +524,234 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.casesTable.validateCasesTableHasNthRows(2); }); + it('clears the filters correctly', async () => { + // filter by status first + await cases.casesTable.filterByTag('one'); + await cases.casesTable.validateCasesTableHasNthRows(1); + + await cases.casesTable.clearFilters(); + await cases.casesTable.validateCasesTableHasNthRows(caseIds.length); + }); + + it('loads the state from the local storage when the URL is empty', async () => { + await cases.casesTable.validateCasesTableHasNthRows(caseIds.length); + const lsState = { + filterOptions: { + search: '', + searchFields: ['title', 'description'], + severity: [], + assignees: [], + reporters: [], + status: [], + // filter by tags + tags: ['one'], + owner: [], + category: [], + customFields: {}, + }, + queryParams: { page: 1, perPage: 10, sortField: 'createdAt', sortOrder: 'desc' }, + }; + + await cases.casesTable.setAllCasesStateInLocalStorage(lsState); + + /** + * Clicking to the navigation bar (sidebar) clears out any query params + * added by the cases app. + */ + await testSubjects.click('cases'); + await cases.casesTable.validateCasesTableHasNthRows(1); + await cases.casesTable.verifyCase(caseIds[0], 0); + }); + + it('loads the state from the URL with empty filter configuration in local storage', async () => { + const theCase = await cases.api.createCase({ + title: 'url-testing', + assignees: [{ uid: profiles[0].uid }], + description: 'url testing', + category: 'url-testing', + tags: ['url'], + severity: CaseSeverity.CRITICAL, + customFields: [{ key: customFields[0].key, type: CustomFieldTypes.TOGGLE, value: true }], + }); + + const lsState = [ + { key: 'status', isActive: false }, + { key: 'severity', isActive: false }, + { key: 'tags', isActive: false }, + { key: 'assignees', isActive: false }, + { key: 'category', isActive: false }, + { key: `cf_${customFields[0].key}`, isActive: false }, + ]; + + await cases.casesTable.setFiltersConfigurationInLocalStorage(lsState); + await cases.casesTable.waitForNthToBeListed(caseIds.length + 1); + + const casesState = { + search: theCase.title, + severity: [theCase.severity], + status: [theCase.status], + tags: theCase.tags, + assignees: [profiles[0].uid], + category: [theCase.category], + customFields: { [customFields[0].key]: ['on'] }, + }; + + await cases.casesTable.setStateToUrlAndNavigate(casesState); + await cases.casesTable.validateCasesTableHasNthRows(1); + await cases.casesTable.verifyCase(theCase.id, 0); + + const currentUrl = decodeURIComponent(await browser.getCurrentUrl()); + expect(new URL(currentUrl).search).to.be(`?cases=${rison.encode(casesState)}`); + + await cases.casesTable.expectFiltersToBeActive([ + 'status', + 'severity', + 'tags', + 'category', + 'assignees', + customFields[0].key, + ]); + + const searchBar = await testSubjects.find('search-cases'); + expect(await searchBar.getAttribute('value')).to.be(casesState.search); + }); + + it('loads the state from the URL with filter configuration in local storage', async () => { + const theCase = await cases.api.createCase({ + title: 'url-testing', + assignees: [{ uid: profiles[0].uid }], + description: 'url testing', + category: 'url-testing', + tags: ['url'], + severity: CaseSeverity.CRITICAL, + customFields: [{ key: customFields[0].key, type: CustomFieldTypes.TOGGLE, value: true }], + }); + + await cases.casesTable.waitForNthToBeListed(caseIds.length + 1); + + const casesState = { + search: theCase.title, + severity: [theCase.severity], + status: [theCase.status], + tags: theCase.tags, + assignees: [profiles[0].uid], + category: [theCase.category], + customFields: { [customFields[0].key]: ['on'] }, + }; + + await cases.casesTable.setStateToUrlAndNavigate(casesState); + await cases.casesTable.validateCasesTableHasNthRows(1); + await cases.casesTable.verifyCase(theCase.id, 0); + + const currentUrl = decodeURIComponent(await browser.getCurrentUrl()); + expect(new URL(currentUrl).search).to.be(`?cases=${rison.encode(casesState)}`); + + await cases.casesTable.expectFiltersToBeActive([ + 'status', + 'severity', + 'tags', + 'category', + 'assignees', + customFields[0].key, + ]); + + const searchBar = await testSubjects.find('search-cases'); + expect(await searchBar.getAttribute('value')).to.be(casesState.search); + }); + + it('updates the local storage correctly when navigating to a URL', async () => { + const theCase = await cases.api.createCase({ + title: 'url-testing', + assignees: [{ uid: profiles[0].uid }], + description: 'url testing', + category: 'url-testing', + tags: ['url'], + severity: CaseSeverity.CRITICAL, + customFields: [{ key: customFields[0].key, type: CustomFieldTypes.TOGGLE, value: true }], + }); + + await cases.casesTable.waitForNthToBeListed(caseIds.length + 1); + + const casesState = { + search: theCase.title, + severity: [theCase.severity], + status: [theCase.status], + tags: theCase.tags, + assignees: [profiles[0].uid], + category: [theCase.category], + customFields: { [customFields[0].key]: ['on'] }, + }; + + await cases.casesTable.setStateToUrlAndNavigate(casesState); + await cases.casesTable.validateCasesTableHasNthRows(1); + await cases.casesTable.verifyCase(theCase.id, 0); + + const currentState = await cases.casesTable.getAllCasesStateInLocalStorage(); + + expect(currentState).to.eql({ + queryParams: { page: 1, perPage: 10, sortField: 'createdAt', sortOrder: 'desc' }, + filterOptions: { + search: theCase.title, + searchFields: ['title', 'description'], + severity: [theCase.severity], + assignees: [profiles[0].uid], + reporters: [], + status: [theCase.status], + tags: theCase.tags, + owner: [], + category: [theCase.category], + customFields: { my_field_01: { type: CustomFieldTypes.TOGGLE, options: ['on'] } }, + }, + }); + }); + + it('loads the state from a legacy URL', async () => { + const theCase = await cases.api.createCase({ + title: 'url-testing', + description: 'url testing', + severity: CaseSeverity.CRITICAL, + }); + + await cases.casesTable.waitForNthToBeListed(caseIds.length + 1); + + const search = `severity=${theCase.severity}&status=${theCase.status}&page=1&perPage=1sortField=createdAt&sortOrder=desc`; + + await cases.navigation.navigateToApp('cases', 'cases-app', search); + await cases.casesTable.validateCasesTableHasNthRows(1); + await cases.casesTable.verifyCase(theCase.id, 0); + + const currentUrl = decodeURIComponent(await browser.getCurrentUrl()); + expect(new URL(currentUrl).search).to.be(`?${search}`); + + await cases.casesTable.expectFiltersToBeActive([ + 'status', + 'severity', + 'tags', + 'category', + 'assignees', + ]); + }); + + it('navigating between pages keeps the state', async () => { + await cases.casesTable.filterByTag('one'); + await cases.casesTable.validateCasesTableHasNthRows(1); + await cases.casesTable.verifyCase(caseIds[0], 0); + await cases.casesTable.goToFirstListedCase(); + + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('backToCases'); + + await header.waitUntilLoadingHasFinished(); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.validateCasesTableHasNthRows(1); + await cases.casesTable.verifyCase(caseIds[0], 0); + }); + + it('loads the initial state correctly', async () => { + await cases.casesTable.validateCasesTableHasNthRows(caseIds.length); + expect(await testSubjects.exists('all-cases-clear-filters-link-icon')).to.be(false); + }); + describe('assignees filtering', () => { it('filters cases by the first cases all user assignee', async () => { await cases.casesTable.filterByAssignee('all'); @@ -554,12 +806,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.casesTable.waitForCasesToBeListed(); }); - beforeEach(async () => { - /** - * There is no easy way to clear the filtering. - * Refreshing the page seems to be easier. - */ - await cases.navigation.navigateToApp(); + afterEach(async () => { + await cases.casesTable.clearFilters(); }); after(async () => {