diff --git a/x-pack/plugins/security_solution/public/common/store/actions.ts b/x-pack/plugins/security_solution/public/common/store/actions.ts index cd8836e38bfef..6b446ab6692d9 100644 --- a/x-pack/plugins/security_solution/public/common/store/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/actions.ts @@ -7,10 +7,16 @@ import { EndpointAction } from '../../management/pages/endpoint_hosts/store/action'; import { PolicyListAction } from '../../management/pages/policy/store/policy_list'; import { PolicyDetailsAction } from '../../management/pages/policy/store/policy_details'; +import { TrustedAppsPageAction } from '../../management/pages/trusted_apps/store/action'; export { appActions } from './app'; export { dragAndDropActions } from './drag_and_drop'; export { inputsActions } from './inputs'; import { RoutingAction } from './routing'; -export type AppAction = EndpointAction | RoutingAction | PolicyListAction | PolicyDetailsAction; +export type AppAction = + | EndpointAction + | RoutingAction + | PolicyListAction + | PolicyDetailsAction + | TrustedAppsPageAction; diff --git a/x-pack/plugins/security_solution/public/common/store/routing/action.ts b/x-pack/plugins/security_solution/public/common/store/routing/action.ts index ae5e4eb32d476..d0cc38970ca21 100644 --- a/x-pack/plugins/security_solution/public/common/store/routing/action.ts +++ b/x-pack/plugins/security_solution/public/common/store/routing/action.ts @@ -6,7 +6,7 @@ import { AppLocation, Immutable } from '../../../../common/endpoint/types'; -interface UserChangedUrl { +export interface UserChangedUrl { readonly type: 'userChangedUrl'; readonly payload: Immutable; } diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index 06f0f09bcf54d..cd4ce743bb701 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -24,6 +24,12 @@ export const MANAGEMENT_STORE_POLICY_LIST_NAMESPACE = 'policyList'; export const MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE = 'policyDetails'; /** Namespace within the Management state where endpoint-host state is maintained */ export const MANAGEMENT_STORE_ENDPOINTS_NAMESPACE = 'endpoints'; +/** Namespace within the Management state where trusted apps page state is maintained */ +export const MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE = 'trustedApps'; + +export const MANAGEMENT_PAGE_SIZE_OPTIONS: readonly number[] = [10, 20, 50]; +export const MANAGEMENT_DEFAULT_PAGE = 0; +export const MANAGEMENT_DEFAULT_PAGE_SIZE = 10; // --[ DEFAULTS ]--------------------------------------------------------------------------- /** The default polling interval to start all polling pages */ diff --git a/x-pack/plugins/security_solution/public/management/common/routing.test.ts b/x-pack/plugins/security_solution/public/management/common/routing.test.ts new file mode 100644 index 0000000000000..7a36654dcffc3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/common/routing.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extractListPaginationParams, getTrustedAppsListPath } from './routing'; +import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from './constants'; + +describe('routing', () => { + describe('extractListPaginationParams()', () => { + it('extracts default page index when not provided', () => { + expect(extractListPaginationParams({}).page_index).toBe(MANAGEMENT_DEFAULT_PAGE); + }); + + it('extracts default page index when too small value provided', () => { + expect(extractListPaginationParams({ page_index: '-1' }).page_index).toBe( + MANAGEMENT_DEFAULT_PAGE + ); + }); + + it('extracts default page index when not a number provided', () => { + expect(extractListPaginationParams({ page_index: 'a' }).page_index).toBe( + MANAGEMENT_DEFAULT_PAGE + ); + }); + + it('extracts only last page index when multiple values provided', () => { + expect(extractListPaginationParams({ page_index: ['1', '2'] }).page_index).toBe(2); + }); + + it('extracts proper page index when single valid value provided', () => { + expect(extractListPaginationParams({ page_index: '2' }).page_index).toBe(2); + }); + + it('extracts default page size when not provided', () => { + expect(extractListPaginationParams({}).page_size).toBe(MANAGEMENT_DEFAULT_PAGE_SIZE); + }); + + it('extracts default page size when invalid option provided', () => { + expect(extractListPaginationParams({ page_size: '25' }).page_size).toBe( + MANAGEMENT_DEFAULT_PAGE_SIZE + ); + }); + + it('extracts default page size when not a number provided', () => { + expect(extractListPaginationParams({ page_size: 'a' }).page_size).toBe( + MANAGEMENT_DEFAULT_PAGE_SIZE + ); + }); + + it('extracts only last page size when multiple values provided', () => { + expect(extractListPaginationParams({ page_size: ['10', '20'] }).page_size).toBe(20); + }); + + it('extracts proper page size when single valid value provided', () => { + expect(extractListPaginationParams({ page_size: '20' }).page_size).toBe(20); + }); + }); + + describe('getTrustedAppsListPath()', () => { + it('builds proper path when no parameters provided', () => { + expect(getTrustedAppsListPath()).toEqual('/trusted_apps'); + }); + + it('builds proper path when empty parameters provided', () => { + expect(getTrustedAppsListPath({})).toEqual('/trusted_apps'); + }); + + it('builds proper path when no page index provided', () => { + expect(getTrustedAppsListPath({ page_size: 20 })).toEqual('/trusted_apps?page_size=20'); + }); + + it('builds proper path when no page size provided', () => { + expect(getTrustedAppsListPath({ page_index: 2 })).toEqual('/trusted_apps?page_index=2'); + }); + + it('builds proper path when both page index and size provided', () => { + expect(getTrustedAppsListPath({ page_index: 2, page_size: 20 })).toEqual( + '/trusted_apps?page_index=2&page_size=20' + ); + }); + + it('builds proper path when page index is equal to default', () => { + const path = getTrustedAppsListPath({ + page_index: MANAGEMENT_DEFAULT_PAGE, + page_size: 20, + }); + + expect(path).toEqual('/trusted_apps?page_size=20'); + }); + + it('builds proper path when page size is equal to default', () => { + const path = getTrustedAppsListPath({ + page_index: 2, + page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, + }); + + expect(path).toEqual('/trusted_apps?page_index=2'); + }); + + it('builds proper path when both page index and size are equal to default', () => { + const path = getTrustedAppsListPath({ + page_index: MANAGEMENT_DEFAULT_PAGE, + page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, + }); + + expect(path).toEqual('/trusted_apps'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index c5ced6f3bcf55..62f360df90192 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -10,6 +10,9 @@ import { generatePath } from 'react-router-dom'; import querystring from 'querystring'; import { + MANAGEMENT_DEFAULT_PAGE, + MANAGEMENT_DEFAULT_PAGE_SIZE, + MANAGEMENT_PAGE_SIZE_OPTIONS, MANAGEMENT_ROUTING_ENDPOINTS_PATH, MANAGEMENT_ROUTING_POLICIES_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, @@ -86,8 +89,61 @@ export const getPolicyDetailPath = (policyId: string, search?: string) => { })}${appendSearch(search)}`; }; -export const getTrustedAppsListPath = (search?: string) => { - return `${generatePath(MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, { +interface ListPaginationParams { + page_index: number; + page_size: number; +} + +const isDefaultOrMissing = (value: number | undefined, defaultValue: number) => { + return value === undefined || value === defaultValue; +}; + +const normalizeListPaginationParams = ( + params?: Partial +): Partial => { + if (params) { + return { + ...(!isDefaultOrMissing(params.page_index, MANAGEMENT_DEFAULT_PAGE) + ? { page_index: params.page_index } + : {}), + ...(!isDefaultOrMissing(params.page_size, MANAGEMENT_DEFAULT_PAGE_SIZE) + ? { page_size: params.page_size } + : {}), + }; + } else { + return {}; + } +}; + +const extractFirstParamValue = (query: querystring.ParsedUrlQuery, key: string): string => { + const value = query[key]; + + return Array.isArray(value) ? value[value.length - 1] : value; +}; + +const extractPageIndex = (query: querystring.ParsedUrlQuery): number => { + const pageIndex = Number(extractFirstParamValue(query, 'page_index')); + + return !Number.isFinite(pageIndex) || pageIndex < 0 ? MANAGEMENT_DEFAULT_PAGE : pageIndex; +}; + +const extractPageSize = (query: querystring.ParsedUrlQuery): number => { + const pageSize = Number(extractFirstParamValue(query, 'page_size')); + + return MANAGEMENT_PAGE_SIZE_OPTIONS.includes(pageSize) ? pageSize : MANAGEMENT_DEFAULT_PAGE_SIZE; +}; + +export const extractListPaginationParams = ( + query: querystring.ParsedUrlQuery +): ListPaginationParams => ({ + page_index: extractPageIndex(query), + page_size: extractPageSize(query), +}); + +export const getTrustedAppsListPath = (params?: Partial): string => { + const path = generatePath(MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, { tabName: AdministrationSubTab.trustedApps, - })}${appendSearch(search)}`; + }); + + return `${path}${appendSearch(querystring.stringify(normalizeListPaginationParams(params)))}`; }; diff --git a/x-pack/plugins/security_solution/public/management/index.ts b/x-pack/plugins/security_solution/public/management/index.ts index 902ed085bd369..4bd9ac495ada9 100644 --- a/x-pack/plugins/security_solution/public/management/index.ts +++ b/x-pack/plugins/security_solution/public/management/index.ts @@ -47,9 +47,7 @@ export class Management { * Cast the ImmutableReducer to a regular reducer for compatibility with * the subplugin architecture (which expects plain redux reducers.) */ - reducer: { - management: managementReducer, - } as ManagementPluginReducer, + reducer: { management: managementReducer } as ManagementPluginReducer, middleware: managementMiddlewareFactory(core, plugins), }, }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 68ba71b7bbc94..e8abe37cf0a88 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -15,9 +15,12 @@ import { HostPolicyResponseActionStatus, } from '../../../../../common/endpoint/types'; import { EndpointState, EndpointIndexUIQueryParams } from '../types'; -import { MANAGEMENT_ROUTING_ENDPOINTS_PATH } from '../../../common/constants'; - -const PAGE_SIZES = Object.freeze([10, 20, 50]); +import { extractListPaginationParams } from '../../../common/routing'; +import { + MANAGEMENT_DEFAULT_PAGE, + MANAGEMENT_DEFAULT_PAGE_SIZE, + MANAGEMENT_ROUTING_ENDPOINTS_PATH, +} from '../../../common/constants'; export const listData = (state: Immutable) => state.hosts; @@ -129,17 +132,17 @@ export const uiQueryParams: ( ) => Immutable = createSelector( (state: Immutable) => state.location, (location: Immutable['location']) => { - const data: EndpointIndexUIQueryParams = { page_index: '0', page_size: '10' }; + const data: EndpointIndexUIQueryParams = { + page_index: String(MANAGEMENT_DEFAULT_PAGE), + page_size: String(MANAGEMENT_DEFAULT_PAGE_SIZE), + }; + if (location) { // Removes the `?` from the beginning of query string if it exists const query = querystring.parse(location.search.slice(1)); + const paginationParams = extractListPaginationParams(query); - const keys: Array = [ - 'selected_endpoint', - 'page_size', - 'page_index', - 'show', - ]; + const keys: Array = ['selected_endpoint', 'show']; for (const key of keys) { const value: string | undefined = @@ -160,17 +163,10 @@ export const uiQueryParams: ( } } - // Check if page size is an expected size, otherwise default to 10 - if (!PAGE_SIZES.includes(Number(data.page_size))) { - data.page_size = '10'; - } - - // Check if page index is a valid positive integer, otherwise default to 0 - const pageIndexAsNumber = Number(data.page_index); - if (!Number.isFinite(pageIndexAsNumber) || pageIndexAsNumber < 0) { - data.page_index = '0'; - } + data.page_size = String(paginationParams.page_size); + data.page_index = String(paginationParams.page_index); } + return data; } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 8d08ac4e59a87..a569c4f02604b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -33,7 +33,7 @@ import { import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { CreateStructuredSelector } from '../../../../common/store'; import { Immutable, HostInfo } from '../../../../../common/endpoint/types'; -import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; +import { DEFAULT_POLL_INTERVAL, MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; import { PolicyEmptyState, HostsEmptyState } from '../../../components/management_empty_state'; import { FormattedDate } from '../../../../common/components/formatted_date'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; @@ -99,7 +99,7 @@ export const EndpointList = () => { pageIndex, pageSize, totalItemCount, - pageSizeOptions: [10, 20, 50], + pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS], hidePerPageOptions: false, }; }, [pageIndex, pageSize, totalItemCount]); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts new file mode 100644 index 0000000000000..9308c137cfb9c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpStart } from 'kibana/public'; +import { TRUSTED_APPS_LIST_API } from '../../../../../common/endpoint/constants'; +import { + GetTrustedListAppsResponse, + GetTrustedAppsListRequest, +} from '../../../../../common/endpoint/types/trusted_apps'; + +export interface TrustedAppsService { + getTrustedAppsList(request: GetTrustedAppsListRequest): Promise; +} + +export class TrustedAppsHttpService implements TrustedAppsService { + constructor(private http: HttpStart) {} + + async getTrustedAppsList(request: GetTrustedAppsListRequest) { + return this.http.get(TRUSTED_APPS_LIST_API, { + query: request, + }); + } +} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.test.ts new file mode 100644 index 0000000000000..5e00d833981ed --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.test.ts @@ -0,0 +1,242 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + UninitialisedResourceState, + LoadingResourceState, + LoadedResourceState, + FailedResourceState, + isUninitialisedResourceState, + isLoadingResourceState, + isLoadedResourceState, + isFailedResourceState, + getLastLoadedResourceState, + getCurrentResourceError, + isOutdatedResourceState, +} from './async_resource_state'; + +interface TestData { + property: string; +} + +const data: TestData = { property: 'value' }; + +const uninitialisedResourceState: UninitialisedResourceState = { + type: 'UninitialisedResourceState', +}; + +const loadedResourceState: LoadedResourceState = { + type: 'LoadedResourceState', + data, +}; + +const failedResourceStateInitially: FailedResourceState = { + type: 'FailedResourceState', + error: {}, +}; + +const failedResourceStateSubsequently: FailedResourceState = { + type: 'FailedResourceState', + error: {}, + lastLoadedState: loadedResourceState, +}; + +const loadingResourceStateInitially: LoadingResourceState = { + type: 'LoadingResourceState', + previousState: uninitialisedResourceState, +}; + +const loadingResourceStateAfterSuccess: LoadingResourceState = { + type: 'LoadingResourceState', + previousState: loadedResourceState, +}; + +const loadingResourceStateAfterInitialFailure: LoadingResourceState = { + type: 'LoadingResourceState', + previousState: failedResourceStateInitially, +}; + +const loadingResourceStateAfterSubsequentFailure: LoadingResourceState = { + type: 'LoadingResourceState', + previousState: failedResourceStateSubsequently, +}; + +describe('AsyncResourceState', () => { + describe('guards', () => { + describe('isUninitialisedResourceState()', () => { + it('returns true for UninitialisedResourceState', () => { + expect(isUninitialisedResourceState(uninitialisedResourceState)).toBe(true); + }); + + it('returns false for LoadingResourceState', () => { + expect(isUninitialisedResourceState(loadingResourceStateInitially)).toBe(false); + }); + + it('returns false for LoadedResourceState', () => { + expect(isUninitialisedResourceState(loadedResourceState)).toBe(false); + }); + + it('returns false for FailedResourceState', () => { + expect(isUninitialisedResourceState(failedResourceStateInitially)).toBe(false); + }); + }); + + describe('isLoadingResourceState()', () => { + it('returns false for UninitialisedResourceState', () => { + expect(isLoadingResourceState(uninitialisedResourceState)).toBe(false); + }); + + it('returns true for LoadingResourceState', () => { + expect(isLoadingResourceState(loadingResourceStateInitially)).toBe(true); + }); + + it('returns false for LoadedResourceState', () => { + expect(isLoadingResourceState(loadedResourceState)).toBe(false); + }); + + it('returns false for FailedResourceState', () => { + expect(isLoadingResourceState(failedResourceStateInitially)).toBe(false); + }); + }); + + describe('isLoadedResourceState()', () => { + it('returns false for UninitialisedResourceState', () => { + expect(isLoadedResourceState(uninitialisedResourceState)).toBe(false); + }); + + it('returns false for LoadingResourceState', () => { + expect(isLoadedResourceState(loadingResourceStateInitially)).toBe(false); + }); + + it('returns true for LoadedResourceState', () => { + expect(isLoadedResourceState(loadedResourceState)).toBe(true); + }); + + it('returns false for FailedResourceState', () => { + expect(isLoadedResourceState(failedResourceStateInitially)).toBe(false); + }); + }); + + describe('isFailedResourceState()', () => { + it('returns false for UninitialisedResourceState', () => { + expect(isFailedResourceState(uninitialisedResourceState)).toBe(false); + }); + + it('returns false for LoadingResourceState', () => { + expect(isFailedResourceState(loadingResourceStateInitially)).toBe(false); + }); + + it('returns false for LoadedResourceState', () => { + expect(isFailedResourceState(loadedResourceState)).toBe(false); + }); + + it('returns true for FailedResourceState', () => { + expect(isFailedResourceState(failedResourceStateInitially)).toBe(true); + }); + }); + }); + + describe('functions', () => { + describe('getLastLoadedResourceState()', () => { + it('returns undefined for UninitialisedResourceState', () => { + expect(getLastLoadedResourceState(uninitialisedResourceState)).toBeUndefined(); + }); + + it('returns current state for LoadedResourceState', () => { + expect(getLastLoadedResourceState(loadedResourceState)).toBe(loadedResourceState); + }); + + it('returns undefined for initial FailedResourceState', () => { + expect(getLastLoadedResourceState(failedResourceStateInitially)).toBeUndefined(); + }); + + it('returns last loaded state for subsequent FailedResourceState', () => { + expect(getLastLoadedResourceState(failedResourceStateSubsequently)).toBe( + loadedResourceState + ); + }); + + it('returns undefined for initial LoadingResourceState', () => { + expect(getLastLoadedResourceState(loadingResourceStateInitially)).toBeUndefined(); + }); + + it('returns previous state for LoadingResourceState after success', () => { + expect(getLastLoadedResourceState(loadingResourceStateAfterSuccess)).toBe( + loadedResourceState + ); + }); + + it('returns undefined for LoadingResourceState after initial failure', () => { + expect(getLastLoadedResourceState(loadingResourceStateAfterInitialFailure)).toBeUndefined(); + }); + + it('returns previous state for LoadingResourceState after subsequent failure', () => { + expect(getLastLoadedResourceState(loadingResourceStateAfterSubsequentFailure)).toBe( + loadedResourceState + ); + }); + }); + + describe('getCurrentResourceError()', () => { + it('returns undefined for UninitialisedResourceState', () => { + expect(getCurrentResourceError(uninitialisedResourceState)).toBeUndefined(); + }); + + it('returns undefined for LoadedResourceState', () => { + expect(getCurrentResourceError(loadedResourceState)).toBeUndefined(); + }); + + it('returns error for FailedResourceState', () => { + expect(getCurrentResourceError(failedResourceStateSubsequently)).toStrictEqual({}); + }); + + it('returns undefined for LoadingResourceState', () => { + expect(getCurrentResourceError(loadingResourceStateAfterSubsequentFailure)).toBeUndefined(); + }); + }); + + describe('isOutdatedResourceState()', () => { + const trueFreshnessTest = (testData: TestData) => true; + const falseFreshnessTest = (testData: TestData) => false; + + it('returns true for UninitialisedResourceState', () => { + expect(isOutdatedResourceState(uninitialisedResourceState, falseFreshnessTest)).toBe(true); + }); + + it('returns false for LoadingResourceState', () => { + expect(isOutdatedResourceState(loadingResourceStateAfterSuccess, falseFreshnessTest)).toBe( + false + ); + }); + + it('returns false for LoadedResourceState and fresh data', () => { + expect(isOutdatedResourceState(loadedResourceState, trueFreshnessTest)).toBe(false); + }); + + it('returns true for LoadedResourceState and outdated data', () => { + expect(isOutdatedResourceState(loadedResourceState, falseFreshnessTest)).toBe(true); + }); + + it('returns true for initial FailedResourceState', () => { + expect(isOutdatedResourceState(failedResourceStateInitially, falseFreshnessTest)).toBe( + true + ); + }); + + it('returns false for subsequent FailedResourceState and fresh data', () => { + expect(isOutdatedResourceState(failedResourceStateSubsequently, trueFreshnessTest)).toBe( + false + ); + }); + + it('returns true for subsequent FailedResourceState and outdated data', () => { + expect(isOutdatedResourceState(failedResourceStateSubsequently, falseFreshnessTest)).toBe( + true + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts new file mode 100644 index 0000000000000..4639a50a61865 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * this file contains set of types to represent state of asynchronous resource. + * Resource is defined as a reference to potential data that is loaded/updated + * using asynchronous communication with data source (for example through REST API call). + * Asynchronous update implies that next to just having data: + * - there is moment in time when data is not loaded/initialised and not in process of loading/updating + * - process performing data update can take considerable time which needs to be communicated to user + * - update can fail due to multiple reasons and also needs to be communicated to the user + */ + +import { Immutable } from '../../../../../common/endpoint/types'; +import { ServerApiError } from '../../../../common/types'; + +/** + * Data type to represent uninitialised state of asynchronous resource. + * This state indicates that no actions to load the data has be taken. + */ +export interface UninitialisedResourceState { + type: 'UninitialisedResourceState'; +} + +/** + * Data type to represent loading state of asynchronous resource. Loading state + * should be used to indicate that data is in the process of loading/updating. + * It contains reference to previous stale state that can be used to present + * previous state of resource to the user (like show previous already loaded + * data or show previous failure). + * + * @param Data - type of the data that is referenced by resource state + * @param Error - type of the error that can happen during attempt to update data + */ +export interface LoadingResourceState { + type: 'LoadingResourceState'; + previousState: StaleResourceState; +} + +/** + * Data type to represent loaded state of asynchronous resource. Loaded state + * is characterised with reference to the loaded data. + * + * @param Data - type of the data that is referenced by resource state + */ +export interface LoadedResourceState { + type: 'LoadedResourceState'; + data: Data; +} + +/** + * Data type to represent failed state of asynchronous resource. Failed state + * is characterised with error and can reference last loaded state. Reference + * to last loaded state can be used to present previous successfully loaded data. + * + * @param Data - type of the data that is referenced by resource state + * @param Error - type of the error that can happen during attempt to update data + */ +export interface FailedResourceState { + type: 'FailedResourceState'; + error: Error; + lastLoadedState?: LoadedResourceState; +} + +/** + * Data type to represent stale (not loading) state of asynchronous resource. + * + * @param Data - type of the data that is referenced by resource state + * @param Error - type of the error that can happen during attempt to update data + */ +export type StaleResourceState = + | UninitialisedResourceState + | LoadedResourceState + | FailedResourceState; + +/** + * Data type to represent any state of asynchronous resource. + * + * @param Data - type of the data that is referenced by resource state + * @param Error - type of the error that can happen during attempt to update data + */ +export type AsyncResourceState = + | UninitialisedResourceState + | LoadingResourceState + | LoadedResourceState + | FailedResourceState; + +// Set of guards to narrow the type of AsyncResourceState that make further refactoring easier + +export const isUninitialisedResourceState = ( + state: Immutable> +): state is Immutable => state.type === 'UninitialisedResourceState'; + +export const isLoadingResourceState = ( + state: Immutable> +): state is Immutable> => state.type === 'LoadingResourceState'; + +export const isLoadedResourceState = ( + state: Immutable> +): state is Immutable> => state.type === 'LoadedResourceState'; + +export const isFailedResourceState = ( + state: Immutable> +): state is Immutable> => state.type === 'FailedResourceState'; + +// Set of functions to work with AsyncResourceState + +export const getLastLoadedResourceState = ( + state: Immutable> +): Immutable> | undefined => { + if (isLoadedResourceState(state)) { + return state; + } else if (isLoadingResourceState(state)) { + return getLastLoadedResourceState(state.previousState); + } else if (isFailedResourceState(state)) { + return state.lastLoadedState; + } else { + return undefined; + } +}; + +export const getCurrentResourceError = ( + state: Immutable> +): Immutable | undefined => { + return isFailedResourceState(state) ? state.error : undefined; +}; + +export const isOutdatedResourceState = ( + state: AsyncResourceState, + isFresh: (data: Data) => boolean +): boolean => + isUninitialisedResourceState(state) || + (isLoadedResourceState(state) && !isFresh(state.data)) || + (isFailedResourceState(state) && + (!state.lastLoadedState || !isFresh(state.lastLoadedState.data))); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/index.ts new file mode 100644 index 0000000000000..99bdac57da4be --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './async_resource_state'; +export * from './trusted_apps_list_page_state'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts new file mode 100644 index 0000000000000..23f4cfd576c56 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; +import { AsyncResourceState } from '.'; + +export interface PaginationInfo { + index: number; + size: number; +} + +export interface TrustedAppsListData { + items: TrustedApp[]; + totalItemsCount: number; + paginationInfo: PaginationInfo; +} + +export interface TrustedAppsListPageState { + listView: { + currentListResourceState: AsyncResourceState; + currentPaginationInfo: PaginationInfo; + }; + active: boolean; +} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts new file mode 100644 index 0000000000000..2154a0eca462e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AsyncResourceState, TrustedAppsListData } from '../state'; + +export interface TrustedAppsListResourceStateChanged { + type: 'trustedAppsListResourceStateChanged'; + payload: { + newState: AsyncResourceState; + }; +} + +export type TrustedAppsPageAction = TrustedAppsListResourceStateChanged; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts new file mode 100644 index 0000000000000..c5abaae473486 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { applyMiddleware, createStore } from 'redux'; + +import { createSpyMiddleware } from '../../../../common/store/test_utils'; + +import { + createFailedListViewWithPagination, + createLoadedListViewWithPagination, + createLoadingListViewWithPagination, + createSampleTrustedApps, + createServerApiError, + createUserChangedUrlAction, +} from '../test_utils'; + +import { TrustedAppsService } from '../service'; +import { PaginationInfo, TrustedAppsListPageState } from '../state'; +import { initialTrustedAppsPageState, trustedAppsPageReducer } from './reducer'; +import { createTrustedAppsPageMiddleware } from './middleware'; + +const createGetTrustedListAppsResponse = (pagination: PaginationInfo, totalItemsCount: number) => ({ + data: createSampleTrustedApps(pagination), + page: pagination.index, + per_page: pagination.size, + total: totalItemsCount, +}); + +const createTrustedAppsServiceMock = (): jest.Mocked => ({ + getTrustedAppsList: jest.fn(), +}); + +const createStoreSetup = (trustedAppsService: TrustedAppsService) => { + const spyMiddleware = createSpyMiddleware(); + + return { + spyMiddleware, + store: createStore( + trustedAppsPageReducer, + applyMiddleware( + createTrustedAppsPageMiddleware(trustedAppsService), + spyMiddleware.actionSpyMiddleware + ) + ), + }; +}; + +describe('middleware', () => { + describe('refreshing list resource state', () => { + it('sets initial state properly', async () => { + expect(createStoreSetup(createTrustedAppsServiceMock()).store.getState()).toStrictEqual( + initialTrustedAppsPageState + ); + }); + + it('refreshes the list when location changes and data gets outdated', async () => { + const pagination = { index: 2, size: 50 }; + const service = createTrustedAppsServiceMock(); + const { store, spyMiddleware } = createStoreSetup(service); + + service.getTrustedAppsList.mockResolvedValue( + createGetTrustedListAppsResponse(pagination, 500) + ); + + store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50')); + + expect(store.getState()).toStrictEqual({ + listView: createLoadingListViewWithPagination(pagination), + active: true, + }); + + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + expect(store.getState()).toStrictEqual({ + listView: createLoadedListViewWithPagination(pagination, pagination, 500), + active: true, + }); + }); + + it('does not refresh the list when location changes and data does not get outdated', async () => { + const pagination = { index: 2, size: 50 }; + const service = createTrustedAppsServiceMock(); + const { store, spyMiddleware } = createStoreSetup(service); + + service.getTrustedAppsList.mockResolvedValue( + createGetTrustedListAppsResponse(pagination, 500) + ); + + store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50')); + + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50')); + + expect(service.getTrustedAppsList).toBeCalledTimes(1); + expect(store.getState()).toStrictEqual({ + listView: createLoadedListViewWithPagination(pagination, pagination, 500), + active: true, + }); + }); + + it('set list resource state to faile when failing to load data', async () => { + const service = createTrustedAppsServiceMock(); + const { store, spyMiddleware } = createStoreSetup(service); + + service.getTrustedAppsList.mockRejectedValue(createServerApiError('Internal Server Error')); + + store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50')); + + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + expect(store.getState()).toStrictEqual({ + listView: createFailedListViewWithPagination( + { index: 2, size: 50 }, + createServerApiError('Internal Server Error') + ), + active: true, + }); + + const infiniteLoopTest = async () => { + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + }; + + await expect(infiniteLoopTest).rejects.not.toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts new file mode 100644 index 0000000000000..31c301b8dbd2b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Immutable } from '../../../../../common/endpoint/types'; +import { AppAction } from '../../../../common/store/actions'; +import { + ImmutableMiddleware, + ImmutableMiddlewareAPI, + ImmutableMiddlewareFactory, +} from '../../../../common/store'; + +import { TrustedAppsHttpService, TrustedAppsService } from '../service'; + +import { + AsyncResourceState, + StaleResourceState, + TrustedAppsListData, + TrustedAppsListPageState, +} from '../state'; + +import { TrustedAppsListResourceStateChanged } from './action'; + +import { + getCurrentListResourceState, + getLastLoadedListResourceState, + getListCurrentPageIndex, + getListCurrentPageSize, + needsRefreshOfListData, +} from './selectors'; + +const createTrustedAppsListResourceStateChangedAction = ( + newState: Immutable> +): Immutable => ({ + type: 'trustedAppsListResourceStateChanged', + payload: { newState }, +}); + +const refreshList = async ( + store: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + store.dispatch( + createTrustedAppsListResourceStateChangedAction({ + type: 'LoadingResourceState', + // need to think on how to avoid the casting + previousState: getCurrentListResourceState(store.getState()) as Immutable< + StaleResourceState + >, + }) + ); + + try { + const pageIndex = getListCurrentPageIndex(store.getState()); + const pageSize = getListCurrentPageSize(store.getState()); + const response = await trustedAppsService.getTrustedAppsList({ + page: pageIndex + 1, + per_page: pageSize, + }); + + store.dispatch( + createTrustedAppsListResourceStateChangedAction({ + type: 'LoadedResourceState', + data: { + items: response.data, + totalItemsCount: response.total, + paginationInfo: { index: pageIndex, size: pageSize }, + }, + }) + ); + } catch (error) { + store.dispatch( + createTrustedAppsListResourceStateChangedAction({ + type: 'FailedResourceState', + error, + lastLoadedState: getLastLoadedListResourceState(store.getState()), + }) + ); + } +}; + +export const createTrustedAppsPageMiddleware = ( + trustedAppsService: TrustedAppsService +): ImmutableMiddleware => { + return (store) => (next) => async (action) => { + next(action); + + // TODO: need to think if failed state is a good condition to consider need for refresh + if (action.type === 'userChangedUrl' && needsRefreshOfListData(store.getState())) { + await refreshList(store, trustedAppsService); + } + }; +}; + +export const trustedAppsPageMiddlewareFactory: ImmutableMiddlewareFactory = ( + coreStart +) => createTrustedAppsPageMiddleware(new TrustedAppsHttpService(coreStart.http)); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts new file mode 100644 index 0000000000000..34325e0cf1398 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { initialTrustedAppsPageState, trustedAppsPageReducer } from './reducer'; +import { + createListLoadedResourceState, + createLoadedListViewWithPagination, + createTrustedAppsListResourceStateChangedAction, + createUserChangedUrlAction, +} from '../test_utils'; + +describe('reducer', () => { + describe('UserChangedUrl', () => { + it('makes page state active and extracts pagination parameters', () => { + const result = trustedAppsPageReducer( + initialTrustedAppsPageState, + createUserChangedUrlAction('/trusted_apps', '?page_index=5&page_size=50') + ); + + expect(result).toStrictEqual({ + listView: { + ...initialTrustedAppsPageState.listView, + currentPaginationInfo: { index: 5, size: 50 }, + }, + active: true, + }); + }); + + it('extracts default pagination parameters when none provided', () => { + const result = trustedAppsPageReducer( + { + ...initialTrustedAppsPageState, + listView: { + ...initialTrustedAppsPageState.listView, + currentPaginationInfo: { index: 5, size: 50 }, + }, + }, + createUserChangedUrlAction('/trusted_apps', '?page_index=b&page_size=60') + ); + + expect(result).toStrictEqual({ + ...initialTrustedAppsPageState, + active: true, + }); + }); + + it('extracts default pagination parameters when invalid provided', () => { + const result = trustedAppsPageReducer( + { + ...initialTrustedAppsPageState, + listView: { + ...initialTrustedAppsPageState.listView, + currentPaginationInfo: { index: 5, size: 50 }, + }, + }, + createUserChangedUrlAction('/trusted_apps') + ); + + expect(result).toStrictEqual({ + ...initialTrustedAppsPageState, + active: true, + }); + }); + + it('makes page state inactive and resets list to uninitialised state when navigating away', () => { + const result = trustedAppsPageReducer( + { listView: createLoadedListViewWithPagination(), active: true }, + createUserChangedUrlAction('/endpoints') + ); + + expect(result).toStrictEqual(initialTrustedAppsPageState); + }); + }); + + describe('TrustedAppsListResourceStateChanged', () => { + it('sets the current list resource state', () => { + const listResourceState = createListLoadedResourceState({ index: 3, size: 50 }, 200); + const result = trustedAppsPageReducer( + initialTrustedAppsPageState, + createTrustedAppsListResourceStateChangedAction(listResourceState) + ); + + expect(result).toStrictEqual({ + ...initialTrustedAppsPageState, + listView: { + ...initialTrustedAppsPageState.listView, + currentListResourceState: listResourceState, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts new file mode 100644 index 0000000000000..4fdc6f90ef40c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.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; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line import/no-nodejs-modules +import { parse } from 'querystring'; +import { matchPath } from 'react-router-dom'; +import { ImmutableReducer } from '../../../../common/store'; +import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; +import { UserChangedUrl } from '../../../../common/store/routing/action'; +import { AppAction } from '../../../../common/store/actions'; +import { extractListPaginationParams } from '../../../common/routing'; +import { + MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, + MANAGEMENT_DEFAULT_PAGE, + MANAGEMENT_DEFAULT_PAGE_SIZE, +} from '../../../common/constants'; + +import { TrustedAppsListResourceStateChanged } from './action'; +import { TrustedAppsListPageState } from '../state'; + +type StateReducer = ImmutableReducer; +type CaseReducer = ( + state: Immutable, + action: Immutable +) => Immutable; + +const isTrustedAppsPageLocation = (location: Immutable) => { + return ( + matchPath(location.pathname ?? '', { + path: MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, + exact: true, + }) !== null + ); +}; + +const trustedAppsListResourceStateChanged: CaseReducer = ( + state, + action +) => { + return { + ...state, + listView: { + ...state.listView, + currentListResourceState: action.payload.newState, + }, + }; +}; + +const userChangedUrl: CaseReducer = (state, action) => { + if (isTrustedAppsPageLocation(action.payload)) { + const paginationParams = extractListPaginationParams(parse(action.payload.search.slice(1))); + + return { + ...state, + listView: { + ...state.listView, + currentPaginationInfo: { + index: paginationParams.page_index, + size: paginationParams.page_size, + }, + }, + active: true, + }; + } else { + return initialTrustedAppsPageState; + } +}; + +export const initialTrustedAppsPageState: TrustedAppsListPageState = { + listView: { + currentListResourceState: { type: 'UninitialisedResourceState' }, + currentPaginationInfo: { + index: MANAGEMENT_DEFAULT_PAGE, + size: MANAGEMENT_DEFAULT_PAGE_SIZE, + }, + }, + active: false, +}; + +export const trustedAppsPageReducer: StateReducer = ( + state = initialTrustedAppsPageState, + action +) => { + switch (action.type) { + case 'trustedAppsListResourceStateChanged': + return trustedAppsListResourceStateChanged(state, action); + + case 'userChangedUrl': + return userChangedUrl(state, action); + } + + return state; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts new file mode 100644 index 0000000000000..a969e2dee4773 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getCurrentListResourceState, + getLastLoadedListResourceState, + getListCurrentPageIndex, + getListCurrentPageSize, + getListErrorMessage, + getListItems, + getListTotalItemsCount, + isListLoading, + needsRefreshOfListData, +} from './selectors'; + +import { + createDefaultListView, + createDefaultPaginationInfo, + createListComplexLoadingResourceState, + createListFailedResourceState, + createListLoadedResourceState, + createLoadedListViewWithPagination, + createSampleTrustedApps, + createUninitialisedResourceState, +} from '../test_utils'; + +describe('selectors', () => { + describe('needsRefreshOfListData()', () => { + it('returns false for outdated resource state and inactive state', () => { + expect(needsRefreshOfListData({ listView: createDefaultListView(), active: false })).toBe( + false + ); + }); + + it('returns true for outdated resource state and active state', () => { + expect(needsRefreshOfListData({ listView: createDefaultListView(), active: true })).toBe( + true + ); + }); + + it('returns true when current loaded page index is outdated', () => { + const listView = createLoadedListViewWithPagination({ index: 1, size: 20 }); + + expect(needsRefreshOfListData({ listView, active: true })).toBe(true); + }); + + it('returns true when current loaded page size is outdated', () => { + const listView = createLoadedListViewWithPagination({ index: 0, size: 50 }); + + expect(needsRefreshOfListData({ listView, active: true })).toBe(true); + }); + + it('returns false when current loaded data is up to date', () => { + const listView = createLoadedListViewWithPagination(); + + expect(needsRefreshOfListData({ listView, active: true })).toBe(false); + }); + }); + + describe('getCurrentListResourceState()', () => { + it('returns current list resource state', () => { + const listView = createDefaultListView(); + + expect(getCurrentListResourceState({ listView, active: false })).toStrictEqual( + createUninitialisedResourceState() + ); + }); + }); + + describe('getLastLoadedListResourceState()', () => { + it('returns last loaded list resource state', () => { + const listView = { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200 + ), + currentPaginationInfo: createDefaultPaginationInfo(), + }; + + expect(getLastLoadedListResourceState({ listView, active: false })).toStrictEqual( + createListLoadedResourceState(createDefaultPaginationInfo(), 200) + ); + }); + }); + + describe('getListItems()', () => { + it('returns empty list when no valid data loaded', () => { + expect(getListItems({ listView: createDefaultListView(), active: false })).toStrictEqual([]); + }); + + it('returns last loaded list items', () => { + const listView = { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200 + ), + currentPaginationInfo: createDefaultPaginationInfo(), + }; + + expect(getListItems({ listView, active: false })).toStrictEqual( + createSampleTrustedApps(createDefaultPaginationInfo()) + ); + }); + }); + + describe('getListTotalItemsCount()', () => { + it('returns 0 when no valid data loaded', () => { + expect(getListTotalItemsCount({ listView: createDefaultListView(), active: false })).toBe(0); + }); + + it('returns last loaded total items count', () => { + const listView = { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200 + ), + currentPaginationInfo: createDefaultPaginationInfo(), + }; + + expect(getListTotalItemsCount({ listView, active: false })).toBe(200); + }); + }); + + describe('getListCurrentPageIndex()', () => { + it('returns page index', () => { + expect(getListCurrentPageIndex({ listView: createDefaultListView(), active: false })).toBe(0); + }); + }); + + describe('getListCurrentPageSize()', () => { + it('returns page index', () => { + expect(getListCurrentPageSize({ listView: createDefaultListView(), active: false })).toBe(20); + }); + }); + + describe('getListErrorMessage()', () => { + it('returns undefined when not in failed state', () => { + const listView = { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200 + ), + currentPaginationInfo: createDefaultPaginationInfo(), + }; + + expect(getListErrorMessage({ listView, active: false })).toBeUndefined(); + }); + + it('returns message when not in failed state', () => { + const listView = { + currentListResourceState: createListFailedResourceState('Internal Server Error'), + currentPaginationInfo: createDefaultPaginationInfo(), + }; + + expect(getListErrorMessage({ listView, active: false })).toBe('Internal Server Error'); + }); + }); + + describe('isListLoading()', () => { + it('returns false when no loading is happening', () => { + expect(isListLoading({ listView: createDefaultListView(), active: false })).toBe(false); + }); + + it('returns true when loading is in progress', () => { + const listView = { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200 + ), + currentPaginationInfo: createDefaultPaginationInfo(), + }; + + expect(isListLoading({ listView, active: false })).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts new file mode 100644 index 0000000000000..6fde779ac1cce --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Immutable, TrustedApp } from '../../../../../common/endpoint/types'; + +import { + AsyncResourceState, + getCurrentResourceError, + getLastLoadedResourceState, + isLoadingResourceState, + isOutdatedResourceState, + LoadedResourceState, + PaginationInfo, + TrustedAppsListData, + TrustedAppsListPageState, +} from '../state'; + +const pageInfosEqual = (pageInfo1: PaginationInfo, pageInfo2: PaginationInfo): boolean => + pageInfo1.index === pageInfo2.index && pageInfo1.size === pageInfo2.size; + +export const needsRefreshOfListData = (state: Immutable): boolean => { + const currentPageInfo = state.listView.currentPaginationInfo; + const currentPage = state.listView.currentListResourceState; + + return ( + state.active && + isOutdatedResourceState(currentPage, (data) => + pageInfosEqual(currentPageInfo, data.paginationInfo) + ) + ); +}; + +export const getCurrentListResourceState = ( + state: Immutable +): Immutable> | undefined => { + return state.listView.currentListResourceState; +}; + +export const getLastLoadedListResourceState = ( + state: Immutable +): Immutable> | undefined => { + return getLastLoadedResourceState(state.listView.currentListResourceState); +}; + +export const getListItems = ( + state: Immutable +): Immutable => { + return getLastLoadedResourceState(state.listView.currentListResourceState)?.data.items || []; +}; + +export const getListCurrentPageIndex = (state: Immutable): number => { + return state.listView.currentPaginationInfo.index; +}; + +export const getListCurrentPageSize = (state: Immutable): number => { + return state.listView.currentPaginationInfo.size; +}; + +export const getListTotalItemsCount = (state: Immutable): number => { + return ( + getLastLoadedResourceState(state.listView.currentListResourceState)?.data.totalItemsCount || 0 + ); +}; + +export const getListErrorMessage = ( + state: Immutable +): string | undefined => { + return getCurrentResourceError(state.listView.currentListResourceState)?.message; +}; + +export const isListLoading = (state: Immutable): boolean => { + return isLoadingResourceState(state.listView.currentListResourceState); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts new file mode 100644 index 0000000000000..fab059a422a2a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ServerApiError } from '../../../../common/types'; +import { TrustedApp } from '../../../../../common/endpoint/types'; +import { RoutingAction } from '../../../../common/store/routing'; + +import { + AsyncResourceState, + FailedResourceState, + LoadedResourceState, + LoadingResourceState, + PaginationInfo, + StaleResourceState, + TrustedAppsListData, + TrustedAppsListPageState, + UninitialisedResourceState, +} from '../state'; + +import { TrustedAppsListResourceStateChanged } from '../store/action'; + +const OS_LIST: Array = ['windows', 'macos', 'linux']; + +export const createSampleTrustedApps = (paginationInfo: PaginationInfo): TrustedApp[] => { + return [...new Array(paginationInfo.size).keys()].map((i) => ({ + id: String(paginationInfo.index + i), + name: `trusted app ${paginationInfo.index + i}`, + description: `Trusted App ${paginationInfo.index + i}`, + created_at: '1 minute ago', + created_by: 'someone', + os: OS_LIST[i % 3], + entries: [], + })); +}; + +export const createTrustedAppsListData = ( + paginationInfo: PaginationInfo, + totalItemsCount: number +) => ({ + items: createSampleTrustedApps(paginationInfo), + totalItemsCount, + paginationInfo, +}); + +export const createServerApiError = (message: string) => ({ + statusCode: 500, + error: 'Internal Server Error', + message, +}); + +export const createUninitialisedResourceState = (): UninitialisedResourceState => ({ + type: 'UninitialisedResourceState', +}); + +export const createListLoadedResourceState = ( + paginationInfo: PaginationInfo, + totalItemsCount: number +): LoadedResourceState => ({ + type: 'LoadedResourceState', + data: createTrustedAppsListData(paginationInfo, totalItemsCount), +}); + +export const createListFailedResourceState = ( + message: string, + lastLoadedState?: LoadedResourceState +): FailedResourceState => ({ + type: 'FailedResourceState', + error: createServerApiError(message), + lastLoadedState, +}); + +export const createListLoadingResourceState = ( + previousState: StaleResourceState = createUninitialisedResourceState() +): LoadingResourceState => ({ + type: 'LoadingResourceState', + previousState, +}); + +export const createListComplexLoadingResourceState = ( + paginationInfo: PaginationInfo, + totalItemsCount: number +): LoadingResourceState => + createListLoadingResourceState( + createListFailedResourceState( + 'Internal Server Error', + createListLoadedResourceState(paginationInfo, totalItemsCount) + ) + ); + +export const createDefaultPaginationInfo = () => ({ index: 0, size: 20 }); + +export const createDefaultListView = () => ({ + currentListResourceState: createUninitialisedResourceState(), + currentPaginationInfo: createDefaultPaginationInfo(), +}); + +export const createLoadingListViewWithPagination = ( + currentPaginationInfo: PaginationInfo, + previousState: StaleResourceState = createUninitialisedResourceState() +): TrustedAppsListPageState['listView'] => ({ + currentListResourceState: { type: 'LoadingResourceState', previousState }, + currentPaginationInfo, +}); + +export const createLoadedListViewWithPagination = ( + paginationInfo: PaginationInfo = createDefaultPaginationInfo(), + currentPaginationInfo: PaginationInfo = createDefaultPaginationInfo(), + totalItemsCount: number = 200 +): TrustedAppsListPageState['listView'] => ({ + currentListResourceState: createListLoadedResourceState(paginationInfo, totalItemsCount), + currentPaginationInfo, +}); + +export const createFailedListViewWithPagination = ( + currentPaginationInfo: PaginationInfo, + error: ServerApiError, + lastLoadedState?: LoadedResourceState +): TrustedAppsListPageState['listView'] => ({ + currentListResourceState: { type: 'FailedResourceState', error, lastLoadedState }, + currentPaginationInfo, +}); + +export const createUserChangedUrlAction = (path: string, search: string = ''): RoutingAction => { + return { type: 'userChangedUrl', payload: { pathname: path, search, hash: '' } }; +}; + +export const createTrustedAppsListResourceStateChangedAction = ( + newState: AsyncResourceState +): TrustedAppsListResourceStateChanged => ({ + type: 'trustedAppsListResourceStateChanged', + payload: { newState }, +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap new file mode 100644 index 0000000000000..e0f846f5950f7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap @@ -0,0 +1,5530 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TrustedAppsList renders correctly initially 1`] = ` +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ + No items found + +
+
+
+
+
+`; + +exports[`TrustedAppsList renders correctly when failed loading data for the first time 1`] = ` +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ +
+ + Intenal Server Error + +
+
+
+
+
+`; + +exports[`TrustedAppsList renders correctly when failed loading data for the second time 1`] = ` +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ +
+ + Intenal Server Error + +
+
+
+
+
+`; + +exports[`TrustedAppsList renders correctly when loaded data 1`] = ` +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ Name +
+
+ + trusted app 0 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 1 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 2 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 3 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 4 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 5 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 6 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 7 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 8 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 9 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 10 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 11 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 12 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 13 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 14 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 15 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 16 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 17 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 18 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 19 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; + +exports[`TrustedAppsList renders correctly when loading data for the first time 1`] = ` +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ + No items found + +
+
+
+
+
+`; + +exports[`TrustedAppsList renders correctly when loading data for the second time 1`] = ` +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ Name +
+
+ + trusted app 0 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 1 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 2 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 3 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 4 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 5 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 6 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 7 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 8 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 9 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 10 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 11 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 12 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 13 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 14 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 15 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 16 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 17 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 18 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 19 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; + +exports[`TrustedAppsList renders correctly when new page and page sie set (not loading yet) 1`] = ` +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ Name +
+
+ + trusted app 0 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 1 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 2 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 3 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 4 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 5 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 6 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 7 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 8 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 9 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 10 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 11 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 12 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 13 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 14 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 15 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 16 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 17 + +
+
+
+ OS +
+
+ Linux +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 18 + +
+
+
+ OS +
+
+ Windows +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+ Name +
+
+ + trusted app 19 + +
+
+
+ OS +
+
+ Mac OS +
+
+
+ Date Created +
+
+ 1 minute ago +
+
+
+ Created By +
+
+ + someone + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap index 6f074f3809036..d6e9aee108cf6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap @@ -17,5 +17,7 @@ exports[`TrustedAppsPage rendering 1`] = ` values={Object {}} /> } -/> +> + + `; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/hooks.ts new file mode 100644 index 0000000000000..62610610981e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/hooks.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useSelector } from 'react-redux'; + +import { State } from '../../../../common/store'; + +import { + MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE as TRUSTED_APPS_NS, + MANAGEMENT_STORE_GLOBAL_NAMESPACE as GLOBAL_NS, +} from '../../../common/constants'; + +import { TrustedAppsListPageState } from '../state'; + +export function useTrustedAppsSelector(selector: (state: TrustedAppsListPageState) => R): R { + return useSelector((state: State) => + selector(state[GLOBAL_NS][TRUSTED_APPS_NS] as TrustedAppsListPageState) + ); +} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx new file mode 100644 index 0000000000000..0362f5c7a9de6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { combineReducers, createStore } from 'redux'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { + MANAGEMENT_STORE_GLOBAL_NAMESPACE, + MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE, +} from '../../../common/constants'; +import { trustedAppsPageReducer } from '../store/reducer'; +import { TrustedAppsList } from './trusted_apps_list'; +import { + createListFailedResourceState, + createListLoadedResourceState, + createListLoadingResourceState, + createTrustedAppsListResourceStateChangedAction, + createUserChangedUrlAction, +} from '../test_utils'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => 'mockId', +})); + +const createStoreSetup = () => { + return createStore( + combineReducers({ + [MANAGEMENT_STORE_GLOBAL_NAMESPACE]: combineReducers({ + [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: trustedAppsPageReducer, + }), + }) + ); +}; + +const renderList = (store: ReturnType) => { + const Wrapper: React.FC = ({ children }) => {children}; + + return render(, { wrapper: Wrapper }); +}; + +describe('TrustedAppsList', () => { + it('renders correctly initially', () => { + expect(renderList(createStoreSetup()).container).toMatchSnapshot(); + }); + + it('renders correctly when loading data for the first time', () => { + const store = createStoreSetup(); + + store.dispatch( + createTrustedAppsListResourceStateChangedAction(createListLoadingResourceState()) + ); + + expect(renderList(store).container).toMatchSnapshot(); + }); + + it('renders correctly when failed loading data for the first time', () => { + const store = createStoreSetup(); + + store.dispatch( + createTrustedAppsListResourceStateChangedAction( + createListFailedResourceState('Intenal Server Error') + ) + ); + + expect(renderList(store).container).toMatchSnapshot(); + }); + + it('renders correctly when loaded data', () => { + const store = createStoreSetup(); + + store.dispatch( + createTrustedAppsListResourceStateChangedAction( + createListLoadedResourceState({ index: 0, size: 20 }, 200) + ) + ); + + expect(renderList(store).container).toMatchSnapshot(); + }); + + it('renders correctly when new page and page sie set (not loading yet)', () => { + const store = createStoreSetup(); + + store.dispatch( + createTrustedAppsListResourceStateChangedAction( + createListLoadedResourceState({ index: 0, size: 20 }, 200) + ) + ); + store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50')); + + expect(renderList(store).container).toMatchSnapshot(); + }); + + it('renders correctly when loading data for the second time', () => { + const store = createStoreSetup(); + + store.dispatch( + createTrustedAppsListResourceStateChangedAction( + createListLoadingResourceState(createListLoadedResourceState({ index: 0, size: 20 }, 200)) + ) + ); + + expect(renderList(store).container).toMatchSnapshot(); + }); + + it('renders correctly when failed loading data for the second time', () => { + const store = createStoreSetup(); + + store.dispatch( + createTrustedAppsListResourceStateChangedAction( + createListFailedResourceState( + 'Intenal Server Error', + createListLoadedResourceState({ index: 0, size: 20 }, 200) + ) + ) + ); + + expect(renderList(store).container).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx new file mode 100644 index 0000000000000..a9077dd84913e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback, useMemo } from 'react'; +import { useHistory } from 'react-router-dom'; +import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Immutable } from '../../../../../common/endpoint/types'; +import { TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; +import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; +import { getTrustedAppsListPath } from '../../../common/routing'; + +import { + getListCurrentPageIndex, + getListCurrentPageSize, + getListErrorMessage, + getListItems, + getListTotalItemsCount, + isListLoading, +} from '../store/selectors'; + +import { useTrustedAppsSelector } from './hooks'; + +import { FormattedDate } from '../../../../common/components/formatted_date'; + +const OS_TITLES: Readonly<{ [K in TrustedApp['os']]: string }> = { + windows: i18n.translate('xpack.securitySolution.trustedapps.os.windows', { + defaultMessage: 'Windows', + }), + macos: i18n.translate('xpack.securitySolution.trustedapps.os.macos', { + defaultMessage: 'Mac OS', + }), + linux: i18n.translate('xpack.securitySolution.trustedapps.os.linux', { + defaultMessage: 'Linux', + }), +}; + +const COLUMN_TITLES: Readonly<{ [K in keyof Omit]: string }> = { + name: i18n.translate('xpack.securitySolution.trustedapps.list.columns.name', { + defaultMessage: 'Name', + }), + os: i18n.translate('xpack.securitySolution.trustedapps.list.columns.os', { + defaultMessage: 'OS', + }), + created_at: i18n.translate('xpack.securitySolution.trustedapps.list.columns.createdAt', { + defaultMessage: 'Date Created', + }), + created_by: i18n.translate('xpack.securitySolution.trustedapps.list.columns.createdBy', { + defaultMessage: 'Created By', + }), +}; + +const getColumnDefinitions = (): Array>> => [ + { + field: 'name', + name: COLUMN_TITLES.name, + }, + { + field: 'os', + name: COLUMN_TITLES.os, + render(value: TrustedApp['os'], record: Immutable) { + return OS_TITLES[value]; + }, + }, + { + field: 'created_at', + name: COLUMN_TITLES.created_at, + render(value: TrustedApp['created_at'], record: Immutable) { + return ( + + ); + }, + }, + { + field: 'created_by', + name: COLUMN_TITLES.created_by, + }, +]; + +export const TrustedAppsList = memo(() => { + const pageIndex = useTrustedAppsSelector(getListCurrentPageIndex); + const pageSize = useTrustedAppsSelector(getListCurrentPageSize); + const totalItemCount = useTrustedAppsSelector(getListTotalItemsCount); + const listItems = useTrustedAppsSelector(getListItems); + const history = useHistory(); + + return ( + [...listItems], [listItems])} + error={useTrustedAppsSelector(getListErrorMessage)} + loading={useTrustedAppsSelector(isListLoading)} + pagination={useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount, + hidePerPageOptions: false, + pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS], + }), + [pageIndex, pageSize, totalItemCount] + )} + onChange={useCallback( + ({ page }: { page: { index: number; size: number } }) => { + history.push( + getTrustedAppsListPath({ + page_index: page.index, + page_size: page.size, + }) + ); + }, + [history] + )} + /> + ); +}); + +TrustedAppsList.displayName = 'TrustedAppsList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx index 7045fa49ffad3..c0d3b9cd699de 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx @@ -3,11 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { memo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { AdministrationListPage } from '../../../components/administration_list_page'; +import { TrustedAppsList } from './trusted_apps_list'; -export function TrustedAppsPage() { +export const TrustedAppsPage = memo(() => { return ( } - /> + > + + ); -} +}); + +TrustedAppsPage.displayName = 'TrustedAppsPage'; diff --git a/x-pack/plugins/security_solution/public/management/store/middleware.ts b/x-pack/plugins/security_solution/public/management/store/middleware.ts index c7a7d2cad0623..77d02262e93b7 100644 --- a/x-pack/plugins/security_solution/public/management/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/store/middleware.ts @@ -9,22 +9,22 @@ import { SecuritySubPluginMiddlewareFactory, State, } from '../../common/store'; -import { policyListMiddlewareFactory } from '../pages/policy/store/policy_list'; -import { policyDetailsMiddlewareFactory } from '../pages/policy/store/policy_details'; import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_GLOBAL_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, MANAGEMENT_STORE_POLICY_LIST_NAMESPACE, + MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE, } from '../common/constants'; +import { policyListMiddlewareFactory } from '../pages/policy/store/policy_list'; +import { policyDetailsMiddlewareFactory } from '../pages/policy/store/policy_details'; import { endpointMiddlewareFactory } from '../pages/endpoint_hosts/store/middleware'; +import { trustedAppsPageMiddlewareFactory } from '../pages/trusted_apps/store/middleware'; + +type ManagementSubStateKey = keyof State[typeof MANAGEMENT_STORE_GLOBAL_NAMESPACE]; -const policyListSelector = (state: State) => - state[MANAGEMENT_STORE_GLOBAL_NAMESPACE][MANAGEMENT_STORE_POLICY_LIST_NAMESPACE]; -const policyDetailsSelector = (state: State) => - state[MANAGEMENT_STORE_GLOBAL_NAMESPACE][MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]; -const endpointsSelector = (state: State) => - state[MANAGEMENT_STORE_GLOBAL_NAMESPACE][MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]; +const createSubStateSelector = (namespace: K) => (state: State) => + state[MANAGEMENT_STORE_GLOBAL_NAMESPACE][namespace]; export const managementMiddlewareFactory: SecuritySubPluginMiddlewareFactory = ( coreStart, @@ -32,13 +32,20 @@ export const managementMiddlewareFactory: SecuritySubPluginMiddlewareFactory = ( ) => { return [ substateMiddlewareFactory( - policyListSelector, + createSubStateSelector(MANAGEMENT_STORE_POLICY_LIST_NAMESPACE), policyListMiddlewareFactory(coreStart, depsStart) ), substateMiddlewareFactory( - policyDetailsSelector, + createSubStateSelector(MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE), policyDetailsMiddlewareFactory(coreStart, depsStart) ), - substateMiddlewareFactory(endpointsSelector, endpointMiddlewareFactory(coreStart, depsStart)), + substateMiddlewareFactory( + createSubStateSelector(MANAGEMENT_STORE_ENDPOINTS_NAMESPACE), + endpointMiddlewareFactory(coreStart, depsStart) + ), + substateMiddlewareFactory( + createSubStateSelector(MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE), + trustedAppsPageMiddlewareFactory(coreStart, depsStart) + ), ]; }; diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index eafd69c875ff1..29eb2d289ae1c 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -17,6 +17,7 @@ import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, MANAGEMENT_STORE_POLICY_LIST_NAMESPACE, + MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE, } from '../common/constants'; import { ImmutableCombineReducers } from '../../common/store'; import { Immutable } from '../../../common/endpoint/types'; @@ -25,6 +26,10 @@ import { endpointListReducer, initialEndpointListState, } from '../pages/endpoint_hosts/store/reducer'; +import { + initialTrustedAppsPageState, + trustedAppsPageReducer, +} from '../pages/trusted_apps/store/reducer'; const immutableCombineReducers: ImmutableCombineReducers = combineReducers; @@ -35,6 +40,7 @@ export const mockManagementState: Immutable = { [MANAGEMENT_STORE_POLICY_LIST_NAMESPACE]: initialPolicyListState(), [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointListState, + [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: initialTrustedAppsPageState, }; /** @@ -44,4 +50,5 @@ export const managementReducer = immutableCombineReducers({ [MANAGEMENT_STORE_POLICY_LIST_NAMESPACE]: policyListReducer, [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: policyDetailsReducer, [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: endpointListReducer, + [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: trustedAppsPageReducer, }); diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index 21214241d1981..8b53f4c1d8525 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -8,6 +8,7 @@ import { CombinedState } from 'redux'; import { SecurityPageName } from '../app/types'; import { PolicyListState, PolicyDetailsState } from './pages/policy/types'; import { EndpointState } from './pages/endpoint_hosts/types'; +import { TrustedAppsListPageState } from './pages/trusted_apps/state/trusted_apps_list_page_state'; /** * The type for the management store global namespace. Used mostly internally to reference @@ -19,6 +20,7 @@ export type ManagementState = CombinedState<{ policyList: PolicyListState; policyDetails: PolicyDetailsState; endpoints: EndpointState; + trustedApps: TrustedAppsListPageState; }>; /**