Skip to content

Commit

Permalink
[Cases] Persist the cases table state on the URL (elastic#175237)
Browse files Browse the repository at this point in the history
## 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: elastic#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 <[email protected]>
  • Loading branch information
cnasikas and kibanamachine authored Feb 9, 2024
1 parent 3503a11 commit d696e91
Show file tree
Hide file tree
Showing 58 changed files with 3,228 additions and 1,103 deletions.
3 changes: 1 addition & 2 deletions x-pack/plugins/cases/common/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

/**
Expand Down
18 changes: 4 additions & 14 deletions x-pack/plugins/cases/common/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,18 +137,6 @@ export interface QueryParams extends SortingParams {
page: number;
perPage: number;
}
export type PartialQueryParams = Partial<QueryParams>;

export interface UrlQueryParams extends SortingParams {
page: string;
perPage: string;
}

export interface ParsedUrlQueryParams extends Partial<UrlQueryParams> {
[index: string]: string | string[] | undefined | null;
}

export type LocalStorageQueryParams = Partial<Omit<QueryParams, 'page'>>;

export interface SystemFilterOptions {
search: string;
Expand All @@ -171,11 +159,13 @@ export interface FilterOptions extends SystemFilterOptions {
};
}

export type PartialFilterOptions = Partial<FilterOptions>;

export type SingleCaseMetrics = SingleCaseMetricsResponse;
export type SingleCaseMetricsFeature = Exclude<CaseMetricsFeature, CaseMetricsFeature.MTTR>;

/**
* 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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});

Expand Down Expand Up @@ -643,6 +649,22 @@ describe('AllCasesListGeneric', () => {
expect(alertCounts.length).toBeGreaterThan(0);
});

it('should clear the filters correctly', async () => {
useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true });

appMockRenderer.render(<AllCasesList />);

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(
Expand Down
43 changes: 27 additions & 16 deletions x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 }) =>
Expand Down Expand Up @@ -64,15 +65,9 @@ export const AllCasesList = React.memo<AllCasesListProps>(

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<CasesUI>([]);

const { data = initialData, isFetching: isLoadingCases } = useGetCases({
Expand Down Expand Up @@ -164,7 +159,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
pageIndex: queryParams.page - 1,
pageSize: queryParams.perPage,
totalItemCount: data.total ?? 0,
pageSizeOptions: CASES_TABLE_PERPAGE_VALUES,
pageSizeOptions: CASES_TABLE_PER_PAGE_VALUES,
}),
[data, queryParams]
);
Expand All @@ -190,6 +185,15 @@ export const AllCasesList = React.memo<AllCasesListProps>(
onRowClick?.(undefined, true);
}, [onRowClick]);

const onClearFilters = useCallback(() => {
setFilterOptions(DEFAULT_CASES_TABLE_STATE.filterOptions);
}, [setFilterOptions]);

const showClearFiltersButton = !deepEqual(
DEFAULT_CASES_TABLE_STATE.filterOptions,
filterOptions
);

return (
<>
<ProgressLoader
Expand All @@ -212,6 +216,17 @@ export const AllCasesList = React.memo<AllCasesListProps>(
currentUserProfile={currentUserProfile}
filterOptions={filterOptions}
/>
<CasesTableUtilityBar
pagination={pagination}
isSelectorView={isSelectorView}
totalCases={data.total ?? 0}
selectedCases={selectedCases}
deselectCases={deselectCases}
selectedColumns={selectedColumns}
onSelectedColumnsChange={setSelectedColumns}
onClearFilters={onClearFilters}
showClearFiltersButton={showClearFiltersButton}
/>
<CasesTable
columns={columns}
data={data}
Expand All @@ -223,13 +238,9 @@ export const AllCasesList = React.memo<AllCasesListProps>(
isSelectorView={isSelectorView}
onChange={tableOnChangeCallback}
pagination={pagination}
selectedCases={selectedCases}
selection={euiBasicTableSelectionProps}
sorting={sorting}
tableRowProps={tableRowProps}
deselectCases={deselectCases}
selectedColumns={selectedColumns}
onSelectedColumnsChange={setSelectedColumns}
/>
</>
);
Expand Down
18 changes: 18 additions & 0 deletions x-pack/plugins/cases/public/components/all_cases/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('multi select filter', () => {
{ label: 'tag d', key: 'tag d' },
],
onChange,
isLoading: false,
};

render(<MultiSelectFilter {...props} />);
Expand All @@ -46,6 +47,7 @@ describe('multi select filter', () => {
selectedOptionKeys: ['tag a'],
limit: 1,
limitReachedMessage: 'Limit reached',
isLoading: false,
};

const { rerender } = render(<MultiSelectFilter {...props} />);
Expand Down Expand Up @@ -76,6 +78,7 @@ describe('multi select filter', () => {
selectedOptionKeys: ['tag a'],
limit: 2,
limitReachedMessage: 'Limit reached',
isLoading: false,
};

const { rerender } = render(<MultiSelectFilter {...props} />);
Expand Down Expand Up @@ -109,6 +112,7 @@ describe('multi select filter', () => {
selectedOptionKeys: ['tag a'],
limit: 1,
limitReachedMessage: 'Limit reached',
isLoading: false,
};

render(<MultiSelectFilter {...props} />);
Expand All @@ -134,6 +138,7 @@ describe('multi select filter', () => {
],
onChange,
selectedOptionKeys: ['tag b'],
isLoading: false,
};

const { rerender } = render(<MultiSelectFilter {...props} />);
Expand All @@ -154,6 +159,7 @@ describe('multi select filter', () => {
],
onChange,
renderOption,
isLoading: false,
};

render(<MultiSelectFilter {...props} />);
Expand All @@ -173,6 +179,7 @@ describe('multi select filter', () => {
],
onChange,
selectedOptionKeys: ['tag b'],
isLoading: false,
};

const { rerender } = render(<MultiSelectFilter {...props} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ interface UseFilterParams<T extends string, K extends string = string> {
renderOption?: (option: FilterOption<T, K>) => React.ReactNode;
selectedOptionKeys?: string[];
transparentBackground?: boolean;
isLoading: boolean;
}
export const MultiSelectFilter = <T extends string, K extends string = string>({
buttonLabel,
Expand All @@ -89,6 +90,7 @@ export const MultiSelectFilter = <T extends string, K extends string = string>({
selectedOptionKeys = [],
renderOption,
transparentBackground,
isLoading,
}: UseFilterParams<T, K>) => {
const { euiTheme } = useEuiTheme();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
Expand All @@ -101,13 +103,14 @@ export const MultiSelectFilter = <T extends string, K extends string = string>({
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<FilterOption<T, K>>) => {
const newSelectedOptions = getEuiSelectableCheckedOptions(newOptions);
Expand Down
Loading

0 comments on commit d696e91

Please sign in to comment.