[Cases] Increase default page size cases table and save preferences in url/localStorage (#144228)

## Summary

Issues:
https://github.com/elastic/kibana/issues/131806
https://github.com/elastic/kibana/issues/140008

* Increase the default table size of the cases table to 10
* Changed the available page sizes to 10, 25, 50 and 100
* Save the visualization preferences of the cases table in localStorage
* Display the current visualization preferences of the cases table in
the URL
* This logic is not applied if the cases table is opened in a modal

### Screenshots

<img width="1441" alt="Screenshot 2022-10-31 at 12 19 10"
src="https://user-images.githubusercontent.com/1533137/198996468-f33ef67b-4f18-467e-841c-dfcff1574c06.png">

### Checklist

Delete any items that are not applicable to this PR.

- [ ] [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
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---

Fixes #140008

## Release notes
* Increase the default table size of the cases table to 10
* Save the visualization preferences of the cases table in localStorage
* Display the current visualization preferences of the cases table in
the URL

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Antonio 2022-11-14 14:37:51 +01:00 committed by GitHub
parent 10fcf61d56
commit 46a71979c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 489 additions and 113 deletions

View file

@ -185,3 +185,8 @@ export const NO_ASSIGNEES_FILTERING_KEYWORD = 'none';
* Delays
*/
export const SEARCH_DEBOUNCE_MS = 500;
/**
* Local storage keys
*/
export const LOCAL_STORAGE_KEYS = { casesFiltering: 'cases.list.filtering' };

View file

@ -97,7 +97,15 @@ export interface QueryParams {
sortField: SortFieldCase;
sortOrder: 'asc' | 'desc';
}
export type UrlQueryParams = Partial<QueryParams>;
export type ParsedUrlQueryParams = Partial<Omit<QueryParams, 'page' | 'perPage'>> & {
page?: string;
perPage?: string;
[index: string]: string | string[] | undefined | null;
};
export type LocalStorageQueryParams = Partial<Omit<QueryParams, 'page'>>;
export interface FilterOptions {
search: string;
searchFields: string[];

View file

@ -19,10 +19,13 @@ const useApplicationMock = useApplication as jest.Mock;
describe('hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
useApplicationMock.mockReturnValue({ appId: 'management', appTitle: 'Management' });
});
describe('useIsMainApplication', () => {
beforeEach(() => {
useApplicationMock.mockReturnValue({ appId: 'management', appTitle: 'Management' });
});
it('returns true if it is the main application', () => {
const { result } = renderHook(() => useIsMainApplication(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
@ -33,6 +36,7 @@ describe('hooks', () => {
it('returns false if it is not the main application', () => {
useApplicationMock.mockReturnValue({ appId: 'testAppId', appTitle: 'Test app' });
const { result } = renderHook(() => useIsMainApplication(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});

View file

@ -8,6 +8,7 @@
/* eslint-disable no-console */
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { euiDarkVars } from '@kbn/ui-theme';
import { I18nProvider } from '@kbn/i18n-react';
import { ThemeProvider } from 'styled-components';
@ -73,17 +74,19 @@ const TestProvidersComponent: React.FC<TestProviderProps> = ({
<KibanaContextProvider services={services}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<QueryClientProvider client={queryClient}>
<CasesProvider
value={{
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
features,
owner,
permissions,
}}
>
{children}
</CasesProvider>
<MemoryRouter>
<CasesProvider
value={{
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
features,
owner,
permissions,
}}
>
{children}
</CasesProvider>
</MemoryRouter>
</QueryClientProvider>
</ThemeProvider>
</KibanaContextProvider>
@ -149,18 +152,20 @@ export const createAppMockRenderer = ({
<KibanaContextProvider services={services}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<QueryClientProvider client={queryClient}>
<CasesProvider
value={{
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
features,
owner,
permissions,
releasePhase,
}}
>
{children}
</CasesProvider>
<MemoryRouter>
<CasesProvider
value={{
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
features,
owner,
permissions,
releasePhase,
}}
>
{children}
</CasesProvider>
</MemoryRouter>
</QueryClientProvider>
</ThemeProvider>
</KibanaContextProvider>

View file

@ -23,7 +23,7 @@ import {
} from '../../common/mock';
import { useGetCasesMockState, connectorsMock } from '../../containers/mock';
import { StatusAll } from '../../../common/ui/types';
import { SortFieldCase, StatusAll } from '../../../common/ui/types';
import { CaseSeverity, CaseStatuses } from '../../../common/api';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { getEmptyTagValue } from '../empty_value';
@ -39,7 +39,7 @@ import { useCreateAttachments } from '../../containers/use_create_attachments';
import { useGetConnectors } from '../../containers/configure/use_connectors';
import { useGetTags } from '../../containers/use_get_tags';
import { useUpdateCase } from '../../containers/use_update_case';
import { useGetCases } from '../../containers/use_get_cases';
import { useGetCases, DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases';
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';
@ -127,6 +127,7 @@ describe('AllCasesListGeneric', () => {
useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false });
mockKibana();
moment.tz.setDefault('UTC');
window.localStorage.clear();
});
it('should render AllCasesList', async () => {
@ -255,9 +256,7 @@ describe('AllCasesListGeneric', () => {
expect(useGetCasesMock).toBeCalledWith(
expect.objectContaining({
queryParams: {
page: 1,
perPage: 5,
sortField: 'createdAt',
...DEFAULT_QUERY_PARAMS,
sortOrder: 'asc',
},
})
@ -402,12 +401,7 @@ describe('AllCasesListGeneric', () => {
await waitFor(() => {
expect(useGetCasesMock).toHaveBeenLastCalledWith(
expect.objectContaining({
queryParams: {
page: 1,
perPage: 5,
sortField: 'closedAt',
sortOrder: 'desc',
},
queryParams: { ...DEFAULT_QUERY_PARAMS, sortField: SortFieldCase.closedAt },
})
);
});
@ -421,12 +415,7 @@ describe('AllCasesListGeneric', () => {
await waitFor(() => {
expect(useGetCasesMock).toHaveBeenLastCalledWith(
expect.objectContaining({
queryParams: {
page: 1,
perPage: 5,
sortField: 'createdAt',
sortOrder: 'desc',
},
queryParams: DEFAULT_QUERY_PARAMS,
})
);
});
@ -440,12 +429,7 @@ describe('AllCasesListGeneric', () => {
await waitFor(() => {
expect(useGetCasesMock).toHaveBeenLastCalledWith(
expect.objectContaining({
queryParams: {
page: 1,
perPage: 5,
sortField: 'createdAt',
sortOrder: 'desc',
},
queryParams: DEFAULT_QUERY_PARAMS,
})
);
});
@ -618,7 +602,7 @@ describe('AllCasesListGeneric', () => {
assignees: [],
owner: ['securitySolution', 'observability'],
},
queryParams: { page: 1, perPage: 5, sortField: 'createdAt', sortOrder: 'desc' },
queryParams: DEFAULT_QUERY_PARAMS,
});
userEvent.click(getByTestId('options-filter-popover-button-Solution'));
@ -644,7 +628,7 @@ describe('AllCasesListGeneric', () => {
assignees: [],
owner: ['securitySolution'],
},
queryParams: { page: 1, perPage: 5, sortField: 'createdAt', sortOrder: 'desc' },
queryParams: DEFAULT_QUERY_PARAMS,
});
userEvent.click(
@ -666,7 +650,7 @@ describe('AllCasesListGeneric', () => {
assignees: [],
owner: ['securitySolution', 'observability'],
},
queryParams: { page: 1, perPage: 5, sortField: 'createdAt', sortOrder: 'desc' },
queryParams: DEFAULT_QUERY_PARAMS,
});
});
@ -698,7 +682,7 @@ describe('AllCasesListGeneric', () => {
assignees: [],
owner: ['securitySolution'],
},
queryParams: { page: 1, perPage: 5, sortField: 'createdAt', sortOrder: 'desc' },
queryParams: DEFAULT_QUERY_PARAMS,
});
});
});

View file

@ -11,12 +11,7 @@ import { EuiProgress } from '@elastic/eui';
import { difference, head, isEmpty } from 'lodash/fp';
import styled, { css } from 'styled-components';
import type {
Case,
CaseStatusWithAllStatus,
FilterOptions,
QueryParams,
} from '../../../common/ui/types';
import type { Case, CaseStatusWithAllStatus, FilterOptions } from '../../../common/ui/types';
import { SortFieldCase, StatusAll } from '../../../common/ui/types';
import { CaseStatuses, caseStatuses } from '../../../common/api';
@ -29,16 +24,12 @@ import { CasesTable } from './table';
import { useCasesContext } from '../cases_context/use_cases_context';
import { CasesMetrics } from './cases_metrics';
import { useGetConnectors } from '../../containers/configure/use_connectors';
import {
DEFAULT_FILTER_OPTIONS,
DEFAULT_QUERY_PARAMS,
initialData,
useGetCases,
} from '../../containers/use_get_cases';
import { DEFAULT_FILTER_OPTIONS, initialData, useGetCases } from '../../containers/use_get_cases';
import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles';
import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile';
import { getAllPermissionsExceptFrom, isReadOnlyPermissions } from '../../utils/permissions';
import { useIsLoadingCases } from './use_is_loading_cases';
import { useAllCasesQueryParams } from './use_all_cases_query_params';
const ProgressLoader = styled(EuiProgress)`
${({ $isShow }: { $isShow: boolean }) =>
@ -69,7 +60,6 @@ export const AllCasesList = React.memo<AllCasesListProps>(
const isLoading = useIsLoadingCases();
const hasOwner = !!owner.length;
const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses));
const initialFilterOptions = {
...(!isEmpty(hiddenStatuses) && firstAvailableStatus && { status: firstAvailableStatus }),
@ -80,7 +70,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
...DEFAULT_FILTER_OPTIONS,
...initialFilterOptions,
});
const [queryParams, setQueryParams] = useState<QueryParams>(DEFAULT_QUERY_PARAMS);
const { queryParams, setQueryParams } = useAllCasesQueryParams(isSelectorView);
const [selectedCases, setSelectedCases] = useState<Case[]>([]);
const { data = initialData, isFetching: isLoadingCases } = useGetCases({
@ -112,7 +102,10 @@ export const AllCasesList = React.memo<AllCasesListProps>(
const sorting = useMemo(
() => ({
sort: { field: queryParams.sortField, direction: queryParams.sortOrder },
sort: {
field: queryParams.sortField,
direction: queryParams.sortOrder,
},
}),
[queryParams.sortField, queryParams.sortOrder]
);
@ -150,23 +143,14 @@ export const AllCasesList = React.memo<AllCasesListProps>(
const onFilterChangedCallback = useCallback(
(newFilterOptions: Partial<FilterOptions>) => {
if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.closed) {
setQueryParams((prevQueryParams) => ({
...prevQueryParams,
sortField: SortFieldCase.closedAt,
}));
} else if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.open) {
setQueryParams((prevQueryParams) => ({
...prevQueryParams,
sortField: SortFieldCase.createdAt,
}));
setQueryParams({ sortField: SortFieldCase.closedAt });
} else if (
newFilterOptions.status &&
newFilterOptions.status === CaseStatuses['in-progress']
[CaseStatuses.open, CaseStatuses['in-progress'], StatusAll].includes(
newFilterOptions.status
)
) {
setQueryParams((prevQueryParams) => ({
...prevQueryParams,
sortField: SortFieldCase.createdAt,
}));
setQueryParams({ sortField: SortFieldCase.createdAt });
}
deselectCases();
@ -193,7 +177,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
: {}),
}));
},
[deselectCases, hasOwner, availableSolutions, owner]
[deselectCases, hasOwner, availableSolutions, owner, setQueryParams]
);
const { columns } = useCasesColumns({
@ -208,10 +192,10 @@ export const AllCasesList = React.memo<AllCasesListProps>(
const pagination = useMemo(
() => ({
pageIndex: (queryParams?.page ?? DEFAULT_QUERY_PARAMS.page) - 1,
pageSize: queryParams?.perPage ?? DEFAULT_QUERY_PARAMS.perPage,
pageIndex: queryParams.page - 1,
pageSize: queryParams.perPage,
totalItemCount: data.total ?? 0,
pageSizeOptions: [5, 10, 15, 20, 25],
pageSizeOptions: [10, 25, 50, 100],
}),
[data, queryParams]
);

View file

@ -0,0 +1,155 @@
/*
* 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 { useHistory } from 'react-router-dom';
import { renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../common/mock';
import {
useAllCasesQueryParams,
getQueryParamsLocalStorageKey,
} from './use_all_cases_query_params';
import { DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases';
import { stringify } from 'query-string';
import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../containers/constants';
const LOCAL_STORAGE_DEFAULTS = {
perPage: DEFAULT_QUERY_PARAMS.perPage,
sortOrder: DEFAULT_QUERY_PARAMS.sortOrder,
};
const URL_DEFAULTS = {
page: DEFAULT_QUERY_PARAMS.page,
perPage: DEFAULT_QUERY_PARAMS.perPage,
sortOrder: DEFAULT_QUERY_PARAMS.sortOrder,
};
const mockLocation = { search: '' };
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn().mockImplementation(() => {
return mockLocation;
}),
useHistory: jest.fn().mockReturnValue({
push: jest.fn(),
location: {
search: '',
},
}),
}));
const APP_ID = 'testAppId';
const LOCALSTORAGE_KEY = getQueryParamsLocalStorageKey(APP_ID);
describe('useAllCasesQueryParams', () => {
beforeEach(() => {
global.localStorage.clear();
});
it('calls setState with default values on first run', () => {
const { result } = renderHook(() => useAllCasesQueryParams(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(result.current.queryParams).toStrictEqual(DEFAULT_QUERY_PARAMS);
});
it('updates localstorage with default values on first run', () => {
expect(global.localStorage.getItem(LOCALSTORAGE_KEY)).toStrictEqual(null);
renderHook(() => useAllCasesQueryParams(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(JSON.parse(global.localStorage.getItem(LOCALSTORAGE_KEY) ?? '{}')).toMatchObject({
...LOCAL_STORAGE_DEFAULTS,
});
});
it('calls history.push with default values on first run', () => {
renderHook(() => useAllCasesQueryParams(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(useHistory().push).toHaveBeenCalledWith({
search: stringify(URL_DEFAULTS),
});
});
it('takes into account existing localStorage values on first run', () => {
const existingLocalStorageValues = { perPage: DEFAULT_TABLE_LIMIT + 10, sortOrder: 'asc' };
global.localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(existingLocalStorageValues));
const { result } = renderHook(() => useAllCasesQueryParams(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(result.current.queryParams).toMatchObject({
...LOCAL_STORAGE_DEFAULTS,
...existingLocalStorageValues,
});
});
it('takes into account existing urlParams on first run', () => {
const nonDefaultUrlParams = {
page: DEFAULT_TABLE_ACTIVE_PAGE + 1,
perPage: DEFAULT_TABLE_LIMIT + 5,
};
const expectedUrl = { ...URL_DEFAULTS, ...nonDefaultUrlParams };
mockLocation.search = stringify(nonDefaultUrlParams);
renderHook(() => useAllCasesQueryParams(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(useHistory().push).toHaveBeenCalledWith({
search: stringify(expectedUrl),
});
});
it('preserves other url parameters', () => {
const nonDefaultUrlParams = {
foo: 'bar',
};
const expectedUrl = { ...URL_DEFAULTS, ...nonDefaultUrlParams };
mockLocation.search = stringify(nonDefaultUrlParams);
renderHook(() => useAllCasesQueryParams(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(useHistory().push).toHaveBeenCalledWith({
search: stringify(expectedUrl),
});
});
it('urlParams take precedence over localStorage values', () => {
const nonDefaultUrlParams = {
perPage: DEFAULT_TABLE_LIMIT + 5,
};
mockLocation.search = stringify(nonDefaultUrlParams);
global.localStorage.setItem(
LOCALSTORAGE_KEY,
JSON.stringify({ perPage: DEFAULT_TABLE_LIMIT + 10 }) // existingLocalStorageValues
);
const { result } = renderHook(() => useAllCasesQueryParams(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(result.current.queryParams).toMatchObject({
...DEFAULT_QUERY_PARAMS,
...nonDefaultUrlParams,
});
});
});

View file

@ -0,0 +1,127 @@
/*
* 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 { useCallback, useRef, useState } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { isEqual } from 'lodash';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { parse, stringify } from 'query-string';
import { DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases';
import { parseUrlQueryParams } from './utils';
import { LOCAL_STORAGE_KEYS } from '../../../common/constants';
import type {
LocalStorageQueryParams,
ParsedUrlQueryParams,
QueryParams,
UrlQueryParams,
} from '../../../common/ui/types';
import { useCasesContext } from '../cases_context/use_cases_context';
export const getQueryParamsLocalStorageKey = (appId: string) => {
const filteringKey = LOCAL_STORAGE_KEYS.casesFiltering;
return `${appId}.${filteringKey}`;
};
const getQueryParams = (
params: UrlQueryParams,
queryParams: UrlQueryParams,
urlParams: UrlQueryParams,
localStorageQueryParams?: LocalStorageQueryParams
): QueryParams => {
const result = { ...DEFAULT_QUERY_PARAMS };
result.perPage =
params.perPage ??
urlParams.perPage ??
localStorageQueryParams?.perPage ??
DEFAULT_QUERY_PARAMS.perPage;
result.sortField = params.sortField ?? queryParams.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;
};
export function useAllCasesQueryParams(isModalView: boolean = false) {
const { appId } = useCasesContext();
const location = useLocation();
const history = useHistory();
const isFirstRenderRef = useRef(true);
const [queryParams, setQueryParams] = useState<QueryParams>({ ...DEFAULT_QUERY_PARAMS });
const [localStorageQueryParams, setLocalStorageQueryParams] =
useLocalStorage<LocalStorageQueryParams>(getQueryParamsLocalStorageKey(appId));
const persistAndUpdateQueryParams = useCallback(
(params) => {
if (isModalView) {
setQueryParams((prevParams) => ({ ...prevParams, ...params }));
return;
}
const parsedUrlParams: ParsedUrlQueryParams = parse(location.search);
const urlParams: UrlQueryParams = parseUrlQueryParams(parsedUrlParams);
const newQueryParams: QueryParams = getQueryParams(
params,
queryParams,
urlParams,
localStorageQueryParams
);
const newLocalStorageQueryParams = {
perPage: newQueryParams.perPage,
sortOrder: newQueryParams.sortOrder,
};
const newUrlParams = {
page: newQueryParams.page,
...newLocalStorageQueryParams,
};
if (!isEqual(newUrlParams, urlParams)) {
try {
history.push({
...location,
search: stringify({ ...parsedUrlParams, ...newUrlParams }),
});
} catch {
// silently fail
}
}
setLocalStorageQueryParams(newLocalStorageQueryParams);
setQueryParams(newQueryParams);
},
[
isModalView,
location,
localStorageQueryParams,
queryParams,
setLocalStorageQueryParams,
history,
]
);
if (isFirstRenderRef.current) {
persistAndUpdateQueryParams(isModalView ? DEFAULT_QUERY_PARAMS : {});
isFirstRenderRef.current = false;
}
return {
queryParams,
setQueryParams: persistAndUpdateQueryParams,
};
}

View file

@ -0,0 +1,71 @@
/*
* 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 './utils';
import { DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases';
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);
});
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { ParsedUrlQueryParams, UrlQueryParams } from '../../../common/ui/types';
export const parseUrlQueryParams = (parsedUrlParams: ParsedUrlQueryParams): UrlQueryParams => {
const urlParams: UrlQueryParams = {
...(parsedUrlParams.sortField && { sortField: parsedUrlParams.sortField }),
...(parsedUrlParams.sortOrder && { sortOrder: parsedUrlParams.sortOrder }),
};
const intPage = parsedUrlParams.page && parseInt(parsedUrlParams.page, 10);
const intPerPage = parsedUrlParams.perPage && parseInt(parsedUrlParams.perPage, 10);
// page=0 is deliberately ignored
if (intPage) {
urlParams.page = intPage;
}
// perPage=0 is deliberately ignored
if (intPerPage) {
urlParams.perPage = intPerPage;
}
return urlParams;
};

View file

@ -8,7 +8,7 @@
import type { SingleCaseMetricsFeature } from './types';
export const DEFAULT_TABLE_ACTIVE_PAGE = 1;
export const DEFAULT_TABLE_LIMIT = 5;
export const DEFAULT_TABLE_LIMIT = 10;
export const casesQueriesKeys = {
all: ['cases'] as const,

View file

@ -17,16 +17,11 @@ import type { AppLeaveHandler, AppMountParameters } from '@kbn/core/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { ManageUserInfo } from '../detections/components/user_info';
import { DEFAULT_DARK_MODE, APP_NAME, APP_ID } from '../../common/constants';
import { DEFAULT_DARK_MODE, APP_NAME } from '../../common/constants';
import { ErrorToastDispatcher } from '../common/components/error_toast_dispatcher';
import { MlCapabilitiesProvider } from '../common/components/ml/permissions/ml_capabilities_provider';
import { GlobalToaster, ManageGlobalToaster } from '../common/components/toasters';
import {
KibanaContextProvider,
useGetUserCasesPermissions,
useKibana,
useUiSetting$,
} from '../common/lib/kibana';
import { KibanaContextProvider, useKibana, useUiSetting$ } from '../common/lib/kibana';
import type { State } from '../common/store';
import type { StartServices } from '../types';
@ -54,11 +49,8 @@ const StartAppComponent: FC<StartAppComponent> = ({
const {
i18n,
application: { capabilities },
cases,
} = useKibana().services;
const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE);
const userCasesPermissions = useGetUserCasesPermissions();
const CasesContext = cases.ui.getCasesContext();
return (
<EuiErrorBoundary>
<i18n.Context>
@ -70,15 +62,13 @@ const StartAppComponent: FC<StartAppComponent> = ({
<UserPrivilegesProvider kibanaCapabilities={capabilities}>
<ManageUserInfo>
<ReactQueryClientProvider>
<CasesContext owner={[APP_ID]} permissions={userCasesPermissions}>
<PageRouter
history={history}
onAppLeave={onAppLeave}
setHeaderActionMenu={setHeaderActionMenu}
>
{children}
</PageRouter>
</CasesContext>
<PageRouter
history={history}
onAppLeave={onAppLeave}
setHeaderActionMenu={setHeaderActionMenu}
>
{children}
</PageRouter>
</ReactQueryClientProvider>
</ManageUserInfo>
</UserPrivilegesProvider>

View file

@ -11,11 +11,13 @@ import React, { memo, useEffect } from 'react';
import { Router, Switch } from 'react-router-dom';
import { Route } from '@kbn/kibana-react-plugin/public';
import { useDispatch } from 'react-redux';
import type { AppLeaveHandler, AppMountParameters } from '@kbn/core/public';
import { ManageRoutesSpy } from '../common/utils/route/manage_spy_routes';
import { APP_ID } from '../../common/constants';
import { RouteCapture } from '../common/components/endpoint/route_capture';
import { useGetUserCasesPermissions, useKibana } from '../common/lib/kibana';
import type { AppAction } from '../common/store/actions';
import { ManageRoutesSpy } from '../common/utils/route/manage_spy_routes';
import { NotFoundPage } from './404';
import { HomePage } from './home';
@ -32,6 +34,9 @@ const PageRouterComponent: FC<RouterProps> = ({
onAppLeave,
setHeaderActionMenu,
}) => {
const { cases } = useKibana().services;
const CasesContext = cases.ui.getCasesContext();
const userCasesPermissions = useGetUserCasesPermissions();
const dispatch = useDispatch<(action: AppAction) => void>();
useEffect(() => {
return () => {
@ -50,7 +55,9 @@ const PageRouterComponent: FC<RouterProps> = ({
<RouteCapture>
<Switch>
<Route path="/">
<HomePage setHeaderActionMenu={setHeaderActionMenu}>{children}</HomePage>
<CasesContext owner={[APP_ID]} permissions={userCasesPermissions}>
<HomePage setHeaderActionMenu={setHeaderActionMenu}>{children}</HomePage>
</CasesContext>
</Route>
<Route>
<NotFoundPage />

View file

@ -376,7 +376,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
describe('pagination', () => {
before(async () => {
await cases.api.createNthRandomCases(8);
await cases.api.createNthRandomCases(12);
await header.waitUntilLoadingHasFinished();
await cases.casesTable.waitForCasesToBeListed();
});
@ -388,7 +388,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
it('paginates cases correctly', async () => {
await testSubjects.click('tablePaginationPopoverButton');
await testSubjects.click('tablePagination-5-rows');
await testSubjects.click('tablePagination-25-rows');
await testSubjects.missingOrFail('pagination-button-1');
await testSubjects.click('tablePaginationPopoverButton');
await testSubjects.click('tablePagination-10-rows');
await testSubjects.isEnabled('pagination-button-1');
await testSubjects.click('pagination-button-1');
await testSubjects.isEnabled('pagination-button-0');

View file

@ -16,6 +16,7 @@ import {
EuiButton,
EuiFlexGroup,
} from '@elastic/eui';
import { Router } from 'react-router-dom';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
import { CasesUiStart } from '@kbn/cases-plugin/public';
import { CommentType } from '@kbn/cases-plugin/common';
@ -92,7 +93,7 @@ const CasesFixtureAppWithContext: React.FC<CasesFixtureAppDeps> = (props) => {
const CasesFixtureApp: React.FC<{ deps: RenderAppProps }> = ({ deps }) => {
const { mountParams, coreStart, pluginsStart } = deps;
const { theme$ } = mountParams;
const { history, theme$ } = mountParams;
const { cases } = pluginsStart;
const CasesContext = cases.ui.getCasesContext();
@ -108,9 +109,11 @@ const CasesFixtureApp: React.FC<{ deps: RenderAppProps }> = ({ deps }) => {
}}
>
<StyledComponentsThemeProvider>
<CasesContext owner={[]} permissions={permissions}>
<CasesFixtureAppWithContext cases={cases} />
</CasesContext>
<Router history={history}>
<CasesContext owner={[]} permissions={permissions}>
<CasesFixtureAppWithContext cases={cases} />
</CasesContext>
</Router>
</StyledComponentsThemeProvider>
</KibanaContextProvider>
</KibanaThemeProvider>