[SECURITY_SOLUTION][ENDPOINT] Grid view for trusted apps. (#79485)

* Refactored store code to group properties related to location so that would be easy to introduce a new view type parameter.

* Added view type to the location and routing.

* Added a simple hook to make navigation easier.

* Improved the navigation hook to get params.

* Some fix for double notification after creating trusted app.

* Added a hook to perform trusted app store actions.

* Fixed trusted app card delete callback.

* Added grid view.

* Fixed the stories structuring.

* Shared more logic between grid and list.

* Finalized the grid view.

* Flattened the props.

* Improved memoization.

* Moved the flex item elements inside conditions.

* Fixed broken stories.

* Updated the snapshot.

* Updated the snapshot.
This commit is contained in:
Bohdan Tsymbala 2020-10-06 17:18:30 +02:00 committed by GitHub
parent 11886bf51c
commit f340d71377
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 9631 additions and 177 deletions

View file

@ -15,7 +15,7 @@ addDecorator((storyFn) => (
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>{storyFn()}</ThemeProvider>
));
storiesOf('Components|ConditionsTable', module)
storiesOf('Components/ConditionsTable', module)
.add('single item', () => {
return <ConditionsTable items={createItems(1)} columns={TEST_COLUMNS} badge="and" />;
})

View file

@ -14,7 +14,7 @@ addDecorator((storyFn) => (
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>{storyFn()}</ThemeProvider>
));
storiesOf('Components|ItemDetailsCard', module).add('default', () => {
storiesOf('Components/ItemDetailsCard', module).add('default', () => {
return (
<ItemDetailsCard>
<ItemDetailsPropertySummary name={'property 1'} value={'value 1'} />

View file

@ -8,16 +8,19 @@ import { ServerApiError } from '../../../../common/types';
import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types/trusted_apps';
import { AsyncResourceState } from '.';
export interface PaginationInfo {
index: number;
size: number;
export interface Pagination {
pageIndex: number;
pageSize: number;
totalItemCount: number;
pageSizeOptions: number[];
}
export interface TrustedAppsListData {
items: TrustedApp[];
totalItemsCount: number;
paginationInfo: PaginationInfo;
pageIndex: number;
pageSize: number;
timestamp: number;
totalItemsCount: number;
}
/** Store State when an API request has been sent to create a new trusted app entry */

View file

@ -9,6 +9,7 @@ import { applyMiddleware, createStore } from 'redux';
import { createSpyMiddleware } from '../../../../common/store/test_utils';
import {
createDefaultPagination,
createListLoadedResourceState,
createLoadedListViewWithPagination,
createSampleTrustedApp,
@ -19,7 +20,7 @@ import {
} from '../test_utils';
import { TrustedAppsService } from '../service';
import { PaginationInfo, TrustedAppsListPageState } from '../state';
import { Pagination, TrustedAppsListPageState } from '../state';
import { initialTrustedAppsPageState, trustedAppsPageReducer } from './reducer';
import { createTrustedAppsPageMiddleware } from './middleware';
@ -31,12 +32,16 @@ Date.now = dateNowMock;
const initialState = initialTrustedAppsPageState();
const createGetTrustedListAppsResponse = (pagination: PaginationInfo, totalItemsCount: number) => ({
data: createSampleTrustedApps(pagination),
page: pagination.index,
per_page: pagination.size,
total: totalItemsCount,
});
const createGetTrustedListAppsResponse = (pagination: Partial<Pagination>) => {
const fullPagination = { ...createDefaultPagination(), ...pagination };
return {
data: createSampleTrustedApps(pagination),
page: fullPagination.pageIndex,
per_page: fullPagination.pageSize,
total: fullPagination.totalItemCount,
};
};
const createTrustedAppsServiceMock = (): jest.Mocked<TrustedAppsService> => ({
getTrustedAppsList: jest.fn(),
@ -74,14 +79,12 @@ describe('middleware', () => {
describe('refreshing list resource state', () => {
it('refreshes the list when location changes and data gets outdated', async () => {
const pagination = { index: 2, size: 50 };
const pagination = { pageIndex: 2, pageSize: 50 };
const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' };
const service = createTrustedAppsServiceMock();
const { store, spyMiddleware } = createStoreSetup(service);
service.getTrustedAppsList.mockResolvedValue(
createGetTrustedListAppsResponse(pagination, 500)
);
service.getTrustedAppsList.mockResolvedValue(createGetTrustedListAppsResponse(pagination));
store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50'));
@ -102,21 +105,19 @@ describe('middleware', () => {
expect(store.getState()).toStrictEqual({
...initialState,
listView: createLoadedListViewWithPagination(initialNow, pagination, 500),
listView: createLoadedListViewWithPagination(initialNow, pagination),
active: true,
location,
});
});
it('does not refresh the list when location changes and data does not get outdated', async () => {
const pagination = { index: 2, size: 50 };
const pagination = { pageIndex: 2, pageSize: 50 };
const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' };
const service = createTrustedAppsServiceMock();
const { store, spyMiddleware } = createStoreSetup(service);
service.getTrustedAppsList.mockResolvedValue(
createGetTrustedListAppsResponse(pagination, 500)
);
service.getTrustedAppsList.mockResolvedValue(createGetTrustedListAppsResponse(pagination));
store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50'));
@ -127,7 +128,7 @@ describe('middleware', () => {
expect(service.getTrustedAppsList).toBeCalledTimes(1);
expect(store.getState()).toStrictEqual({
...initialState,
listView: createLoadedListViewWithPagination(initialNow, pagination, 500),
listView: createLoadedListViewWithPagination(initialNow, pagination),
active: true,
location,
});
@ -135,14 +136,12 @@ describe('middleware', () => {
it('refreshes the list when data gets outdated with and outdate action', async () => {
const newNow = 222222;
const pagination = { index: 0, size: 10 };
const pagination = { pageIndex: 0, pageSize: 10 };
const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' };
const service = createTrustedAppsServiceMock();
const { store, spyMiddleware } = createStoreSetup(service);
service.getTrustedAppsList.mockResolvedValue(
createGetTrustedListAppsResponse(pagination, 500)
);
service.getTrustedAppsList.mockResolvedValue(createGetTrustedListAppsResponse(pagination));
store.dispatch(createUserChangedUrlAction('/trusted_apps'));
@ -157,7 +156,7 @@ describe('middleware', () => {
listView: {
listResourceState: {
type: 'LoadingResourceState',
previousState: createListLoadedResourceState(pagination, 500, initialNow),
previousState: createListLoadedResourceState(pagination, initialNow),
},
freshDataTimestamp: newNow,
},
@ -169,7 +168,7 @@ describe('middleware', () => {
expect(store.getState()).toStrictEqual({
...initialState,
listView: createLoadedListViewWithPagination(newNow, pagination, 500),
listView: createLoadedListViewWithPagination(newNow, pagination),
active: true,
location,
});
@ -211,11 +210,11 @@ describe('middleware', () => {
const newNow = 222222;
const entry = createSampleTrustedApp(3);
const notFoundError = createServerApiError('Not Found');
const pagination = { index: 0, size: 10 };
const pagination = { pageIndex: 0, pageSize: 10 };
const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' };
const getTrustedAppsListResponse = createGetTrustedListAppsResponse(pagination, 500);
const listView = createLoadedListViewWithPagination(initialNow, pagination, 500);
const listViewNew = createLoadedListViewWithPagination(newNow, pagination, 500);
const getTrustedAppsListResponse = createGetTrustedListAppsResponse(pagination);
const listView = createLoadedListViewWithPagination(initialNow, pagination);
const listViewNew = createLoadedListViewWithPagination(newNow, pagination);
const testStartState = { ...initialState, listView, active: true, location };
it('does not submit when entry is undefined', async () => {

View file

@ -75,8 +75,9 @@ const refreshListIfNeeded = async (
type: 'LoadedResourceState',
data: {
items: response.data,
pageIndex,
pageSize,
totalItemsCount: response.total,
paginationInfo: { index: pageIndex, size: pageSize },
timestamp: Date.now(),
},
})

View file

@ -71,8 +71,7 @@ describe('reducer', () => {
describe('TrustedAppsListResourceStateChanged', () => {
it('sets the current list resource state', () => {
const listResourceState = createListLoadedResourceState(
{ index: 3, size: 50 },
200,
{ pageIndex: 3, pageSize: 50 },
initialNow
);
const result = trustedAppsPageReducer(

View file

@ -29,7 +29,7 @@ import {
} from './selectors';
import {
createDefaultPaginationInfo,
createDefaultPagination,
createListComplexLoadingResourceState,
createListFailedResourceState,
createListLoadedResourceState,
@ -66,13 +66,13 @@ describe('selectors', () => {
});
it('returns true when current loaded page index is outdated', () => {
const listView = createLoadedListViewWithPagination(initialNow, { index: 1, size: 20 });
const listView = createLoadedListViewWithPagination(initialNow, { pageIndex: 1 });
expect(needsRefreshOfListData({ ...initialState, listView, active: true })).toBe(true);
});
it('returns true when current loaded page size is outdated', () => {
const listView = createLoadedListViewWithPagination(initialNow, { index: 0, size: 50 });
const listView = createLoadedListViewWithPagination(initialNow, { pageSize: 50 });
expect(needsRefreshOfListData({ ...initialState, listView, active: true })).toBe(true);
});
@ -112,8 +112,7 @@ describe('selectors', () => {
...initialState,
listView: {
listResourceState: createListComplexLoadingResourceState(
createDefaultPaginationInfo(),
200,
createDefaultPagination(),
initialNow
),
freshDataTimestamp: initialNow,
@ -121,7 +120,7 @@ describe('selectors', () => {
};
expect(getLastLoadedListResourceState(state)).toStrictEqual(
createListLoadedResourceState(createDefaultPaginationInfo(), 200, initialNow)
createListLoadedResourceState(createDefaultPagination(), initialNow)
);
});
});
@ -136,17 +135,14 @@ describe('selectors', () => {
...initialState,
listView: {
listResourceState: createListComplexLoadingResourceState(
createDefaultPaginationInfo(),
200,
createDefaultPagination(),
initialNow
),
freshDataTimestamp: initialNow,
},
};
expect(getListItems(state)).toStrictEqual(
createSampleTrustedApps(createDefaultPaginationInfo())
);
expect(getListItems(state)).toStrictEqual(createSampleTrustedApps(createDefaultPagination()));
});
});
@ -160,8 +156,7 @@ describe('selectors', () => {
...initialState,
listView: {
listResourceState: createListComplexLoadingResourceState(
createDefaultPaginationInfo(),
200,
createDefaultPagination(),
initialNow
),
freshDataTimestamp: initialNow,
@ -202,8 +197,7 @@ describe('selectors', () => {
...initialState,
listView: {
listResourceState: createListComplexLoadingResourceState(
createDefaultPaginationInfo(),
200,
createDefaultPagination(),
initialNow
),
freshDataTimestamp: initialNow,
@ -236,8 +230,7 @@ describe('selectors', () => {
...initialState,
listView: {
listResourceState: createListComplexLoadingResourceState(
createDefaultPaginationInfo(),
200,
createDefaultPagination(),
initialNow
),
freshDataTimestamp: initialNow,

View file

@ -6,6 +6,7 @@
import { ServerApiError } from '../../../../common/types';
import { Immutable, NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types';
import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants';
import {
AsyncResourceState,
@ -16,6 +17,7 @@ import {
isLoadingResourceState,
isOutdatedResourceState,
LoadedResourceState,
Pagination,
TrustedAppCreateFailure,
TrustedAppsListData,
TrustedAppsListPageLocation,
@ -36,8 +38,8 @@ export const needsRefreshOfListData = (state: Immutable<TrustedAppsListPageState
state.active &&
isOutdatedResourceState(currentPage, (data) => {
return (
data.paginationInfo.index === location.page_index &&
data.paginationInfo.size === location.page_size &&
data.pageIndex === location.page_index &&
data.pageSize === location.page_size &&
data.timestamp >= freshDataTimestamp
);
})
@ -74,6 +76,17 @@ export const getListTotalItemsCount = (state: Immutable<TrustedAppsListPageState
return getLastLoadedResourceState(state.listView.listResourceState)?.data.totalItemsCount || 0;
};
export const getListPagination = (state: Immutable<TrustedAppsListPageState>): Pagination => {
const lastLoadedResourceState = getLastLoadedResourceState(state.listView.listResourceState);
return {
pageIndex: state.location.page_index,
pageSize: state.location.page_size,
totalItemCount: lastLoadedResourceState?.data.totalItemsCount || 0,
pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS],
};
};
export const getCurrentLocation = (
state: Immutable<TrustedAppsListPageState>
): TrustedAppsListPageLocation => state.location;

View file

@ -11,6 +11,7 @@ import { RoutingAction } from '../../../../common/store/routing';
import {
MANAGEMENT_DEFAULT_PAGE,
MANAGEMENT_DEFAULT_PAGE_SIZE,
MANAGEMENT_PAGE_SIZE_OPTIONS,
MANAGEMENT_STORE_GLOBAL_NAMESPACE,
MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE,
} from '../../../common/constants';
@ -20,7 +21,7 @@ import {
FailedResourceState,
LoadedResourceState,
LoadingResourceState,
PaginationInfo,
Pagination,
StaleResourceState,
TrustedAppsListData,
TrustedAppsListPageState,
@ -44,20 +45,23 @@ export const createSampleTrustedApp = (i: number): TrustedApp => {
};
};
export const createSampleTrustedApps = (paginationInfo: PaginationInfo): TrustedApp[] => {
return [...new Array(paginationInfo.size).keys()].map(createSampleTrustedApp);
export const createSampleTrustedApps = (pagination: Partial<Pagination>): TrustedApp[] => {
const fullPagination = { ...createDefaultPagination(), ...pagination };
return [...new Array(fullPagination.pageSize).keys()].map(createSampleTrustedApp);
};
export const createTrustedAppsListData = (
paginationInfo: PaginationInfo,
totalItemsCount: number,
timestamp: number
) => ({
items: createSampleTrustedApps(paginationInfo),
totalItemsCount,
paginationInfo,
timestamp,
});
export const createTrustedAppsListData = (pagination: Partial<Pagination>, timestamp: number) => {
const fullPagination = { ...createDefaultPagination(), ...pagination };
return {
items: createSampleTrustedApps(fullPagination),
pageSize: fullPagination.pageSize,
pageIndex: fullPagination.pageIndex,
totalItemsCount: fullPagination.totalItemCount,
timestamp,
};
};
export const createServerApiError = (message: string) => ({
statusCode: 500,
@ -70,12 +74,11 @@ export const createUninitialisedResourceState = (): UninitialisedResourceState =
});
export const createListLoadedResourceState = (
paginationInfo: PaginationInfo,
totalItemsCount: number,
pagination: Partial<Pagination>,
timestamp: number
): LoadedResourceState<TrustedAppsListData> => ({
type: 'LoadedResourceState',
data: createTrustedAppsListData(paginationInfo, totalItemsCount, timestamp),
data: createTrustedAppsListData(pagination, timestamp),
});
export const createListFailedResourceState = (
@ -95,32 +98,28 @@ export const createListLoadingResourceState = (
});
export const createListComplexLoadingResourceState = (
paginationInfo: PaginationInfo,
totalItemsCount: number,
pagination: Partial<Pagination>,
timestamp: number
): LoadingResourceState<TrustedAppsListData> =>
createListLoadingResourceState(
createListFailedResourceState(
'Internal Server Error',
createListLoadedResourceState(paginationInfo, totalItemsCount, timestamp)
createListLoadedResourceState(pagination, timestamp)
)
);
export const createDefaultPaginationInfo = () => ({
index: MANAGEMENT_DEFAULT_PAGE,
size: MANAGEMENT_DEFAULT_PAGE_SIZE,
export const createDefaultPagination = (): Pagination => ({
pageIndex: MANAGEMENT_DEFAULT_PAGE,
pageSize: MANAGEMENT_DEFAULT_PAGE_SIZE,
totalItemCount: 200,
pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS],
});
export const createLoadedListViewWithPagination = (
freshDataTimestamp: number,
paginationInfo: PaginationInfo = createDefaultPaginationInfo(),
totalItemsCount: number = 200
pagination: Partial<Pagination> = createDefaultPagination()
): TrustedAppsListPageState['listView'] => ({
listResourceState: createListLoadedResourceState(
paginationInfo,
totalItemsCount,
freshDataTimestamp
),
listResourceState: createListLoadedResourceState(pagination, freshDataTimestamp),
freshDataTimestamp,
});

View file

@ -0,0 +1,105 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`control_panel ControlPanel should render grid selection correctly 1`] = `
<EuiFlexGroup
alignItems="center"
direction="row"
>
<EuiFlexItem
grow={1}
>
<EuiText
color="subdued"
size="xs"
>
0 trusted applications
</EuiText>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<ViewTypeToggle
onToggle={[Function]}
selectedOption="grid"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`control_panel ControlPanel should render list selection correctly 1`] = `
<EuiFlexGroup
alignItems="center"
direction="row"
>
<EuiFlexItem
grow={1}
>
<EuiText
color="subdued"
size="xs"
>
0 trusted applications
</EuiText>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<ViewTypeToggle
onToggle={[Function]}
selectedOption="list"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`control_panel ControlPanel should render plural count correctly 1`] = `
<EuiFlexGroup
alignItems="center"
direction="row"
>
<EuiFlexItem
grow={1}
>
<EuiText
color="subdued"
size="xs"
>
100 trusted applications
</EuiText>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<ViewTypeToggle
onToggle={[Function]}
selectedOption="grid"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`control_panel ControlPanel should render singular count correctly 1`] = `
<EuiFlexGroup
alignItems="center"
direction="row"
>
<EuiFlexItem
grow={1}
>
<EuiText
color="subdued"
size="xs"
>
1 trusted application
</EuiText>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<ViewTypeToggle
onToggle={[Function]}
selectedOption="grid"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -0,0 +1,38 @@
/*
* 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, { useState } from 'react';
import { ThemeProvider } from 'styled-components';
import { storiesOf, addDecorator } from '@storybook/react';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { ControlPanel, ControlPanelProps } from '.';
import { ViewType } from '../../../state';
addDecorator((storyFn) => (
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>{storyFn()}</ThemeProvider>
));
const useRenderStory = (props: Omit<ControlPanelProps, 'onViewTypeChange'>) => {
const [selectedOption, setSelectedOption] = useState<ViewType>(props.currentViewType);
return (
<ControlPanel
{...{ ...props, currentViewType: selectedOption }}
onViewTypeChange={setSelectedOption}
/>
);
};
storiesOf('TrustedApps/ControlPanel', module)
.add('list view selected', () => {
return useRenderStory({ totalItemCount: 0, currentViewType: 'list' });
})
.add('plural totals', () => {
return useRenderStory({ totalItemCount: 200, currentViewType: 'grid' });
})
.add('singular totals', () => {
return useRenderStory({ totalItemCount: 1, currentViewType: 'grid' });
});

View file

@ -0,0 +1,57 @@
/*
* 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 { render } from '@testing-library/react';
import { shallow } from 'enzyme';
import React from 'react';
import { ControlPanel } from '.';
describe('control_panel', () => {
describe('ControlPanel', () => {
it('should render grid selection correctly', () => {
const element = shallow(
<ControlPanel currentViewType={'grid'} totalItemCount={0} onViewTypeChange={() => {}} />
);
expect(element).toMatchSnapshot();
});
it('should render list selection correctly', () => {
const element = shallow(
<ControlPanel currentViewType={'list'} totalItemCount={0} onViewTypeChange={() => {}} />
);
expect(element).toMatchSnapshot();
});
it('should render singular count correctly', () => {
const element = shallow(
<ControlPanel currentViewType={'grid'} totalItemCount={1} onViewTypeChange={() => {}} />
);
expect(element).toMatchSnapshot();
});
it('should render plural count correctly', () => {
const element = shallow(
<ControlPanel currentViewType={'grid'} totalItemCount={100} onViewTypeChange={() => {}} />
);
expect(element).toMatchSnapshot();
});
it('should trigger onViewTypeChange', async () => {
const onToggle = jest.fn();
const element = render(
<ControlPanel currentViewType={'list'} totalItemCount={100} onViewTypeChange={onToggle} />
);
(await element.findAllByTestId('viewTypeToggleButton'))[0].click();
expect(onToggle).toBeCalledWith('grid');
});
});
});

View file

@ -0,0 +1,41 @@
/*
* 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 } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ViewType } from '../../../state';
import { ViewTypeToggle } from '../view_type_toggle';
export interface ControlPanelProps {
totalItemCount: number;
currentViewType: ViewType;
onViewTypeChange: (value: ViewType) => void;
}
export const ControlPanel = memo<ControlPanelProps>(
({ totalItemCount, currentViewType, onViewTypeChange }) => {
return (
<EuiFlexGroup direction="row" alignItems="center">
<EuiFlexItem grow={1}>
<EuiText color="subdued" size="xs">
{i18n.translate('xpack.securitySolution.trustedapps.list.totalCount', {
defaultMessage:
'{totalItemCount, plural, one {# trusted application} other {# trusted applications}}',
values: { totalItemCount },
})}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ViewTypeToggle selectedOption={currentViewType} onToggle={onViewTypeChange} />
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
ControlPanel.displayName = 'ControlPanel';

View file

@ -62,6 +62,7 @@ exports[`trusted_app_card TrustedAppCard should render correctly 1`] = `
/>
<ItemDetailsAction
color="danger"
data-test-subj="trustedAppDeleteButton"
onClick={[Function]}
size="s"
>
@ -132,6 +133,7 @@ exports[`trusted_app_card TrustedAppCard should trim long descriptions 1`] = `
/>
<ItemDetailsAction
color="danger"
data-test-subj="trustedAppDeleteButton"
onClick={[Function]}
size="s"
>

View file

@ -38,7 +38,7 @@ const SIGNER_CONDITION: WindowsConditionEntry = {
value: 'Elastic',
};
storiesOf('TrustedApps|TrustedAppCard', module)
storiesOf('TrustedApps/TrustedAppCard', module)
.add('default', () => {
const trustedApp: TrustedApp = createSampleTrustedApp(5);
trustedApp.created_at = '2020-09-17T14:52:33.899Z';

View file

@ -5,7 +5,6 @@
*/
import React, { memo, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiTableFieldDataColumnType } from '@elastic/eui';
import {
@ -23,7 +22,12 @@ import {
ItemDetailsPropertySummary,
} from '../../../../../../common/components/item_details_card';
import { OS_TITLES, PROPERTY_TITLES, ENTRY_PROPERTY_TITLES } from '../../translations';
import {
OS_TITLES,
PROPERTY_TITLES,
ENTRY_PROPERTY_TITLES,
CARD_DELETE_BUTTON_LABEL,
} from '../../translations';
type Entry = MacosLinuxConditionEntry | WindowsConditionEntry;
@ -62,11 +66,11 @@ const getEntriesColumnDefinitions = (): Array<EuiTableFieldDataColumnType<Entry>
interface TrustedAppCardProps {
trustedApp: Immutable<TrustedApp>;
onDelete: (id: string) => void;
onDelete: (trustedApp: Immutable<TrustedApp>) => void;
}
export const TrustedAppCard = memo(({ trustedApp, onDelete }: TrustedAppCardProps) => {
const handleDelete = useCallback(() => onDelete(trustedApp.id), [onDelete, trustedApp.id]);
const handleDelete = useCallback(() => onDelete(trustedApp), [onDelete, trustedApp]);
return (
<ItemDetailsCard>
@ -98,10 +102,13 @@ export const TrustedAppCard = memo(({ trustedApp, onDelete }: TrustedAppCardProp
responsive
/>
<ItemDetailsAction size="s" color="danger" onClick={handleDelete}>
{i18n.translate('xpack.securitySolution.trustedapps.card.removeButtonLabel', {
defaultMessage: 'Remove',
})}
<ItemDetailsAction
size="s"
color="danger"
onClick={handleDelete}
data-test-subj="trustedAppDeleteButton"
>
{CARD_DELETE_BUTTON_LABEL}
</ItemDetailsAction>
</ItemDetailsCard>
);

View file

@ -0,0 +1,81 @@
/*
* 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 from 'react';
import { Provider } from 'react-redux';
import { ThemeProvider } from 'styled-components';
import { storiesOf } from '@storybook/react';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { KibanaContextProvider } from '../../../../../../../../../../src/plugins/kibana_react/public';
import {
createGlobalNoMiddlewareStore,
createListFailedResourceState,
createListLoadedResourceState,
createListLoadingResourceState,
createTrustedAppsListResourceStateChangedAction,
} from '../../../test_utils';
import { TrustedAppsGrid } from '.';
const now = 111111;
const renderGrid = (store: ReturnType<typeof createGlobalNoMiddlewareStore>) => (
<Provider store={store}>
<KibanaContextProvider services={{ uiSettings: { get: () => 'MMM D, YYYY @ HH:mm:ss.SSS' } }}>
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TrustedAppsGrid />
</ThemeProvider>
</KibanaContextProvider>
</Provider>
);
storiesOf('TrustedApps/TrustedAppsGrid', module)
.add('default', () => {
return renderGrid(createGlobalNoMiddlewareStore());
})
.add('loading', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(createListLoadingResourceState())
);
return renderGrid(store);
})
.add('error', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListFailedResourceState('Intenal Server Error')
)
);
return renderGrid(store);
})
.add('loaded', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadedResourceState({ pageSize: 10 }, now)
)
);
return renderGrid(store);
})
.add('loading second time', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadingResourceState(createListLoadedResourceState({ pageSize: 10 }, now))
)
);
return renderGrid(store);
});

View file

@ -0,0 +1,140 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { ThemeProvider } from 'styled-components';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import {
createSampleTrustedApp,
createListFailedResourceState,
createListLoadedResourceState,
createListLoadingResourceState,
createTrustedAppsListResourceStateChangedAction,
createUserChangedUrlAction,
createGlobalNoMiddlewareStore,
} from '../../../test_utils';
import { TrustedAppsGrid } from '.';
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
htmlIdGenerator: () => () => 'mockId',
}));
const now = 111111;
const renderList = (store: ReturnType<typeof createGlobalNoMiddlewareStore>) => {
const Wrapper: React.FC = ({ children }) => (
<Provider store={store}>
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
{children}
</ThemeProvider>
</Provider>
);
return render(<TrustedAppsGrid />, { wrapper: Wrapper });
};
describe('TrustedAppsGrid', () => {
it('renders correctly initially', () => {
expect(renderList(createGlobalNoMiddlewareStore()).container).toMatchSnapshot();
});
it('renders correctly when loading data for the first time', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(createListLoadingResourceState())
);
expect(renderList(store).container).toMatchSnapshot();
});
it('renders correctly when failed loading data for the first time', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListFailedResourceState('Intenal Server Error')
)
);
expect(renderList(store).container).toMatchSnapshot();
});
it('renders correctly when loaded data', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadedResourceState({ pageSize: 10 }, now)
)
);
expect(renderList(store).container).toMatchSnapshot();
});
it('renders correctly when new page and page size set (not loading yet)', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadedResourceState({ pageSize: 10 }, now)
)
);
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 = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadingResourceState(createListLoadedResourceState({ pageSize: 10 }, now))
)
);
expect(renderList(store).container).toMatchSnapshot();
});
it('renders correctly when failed loading data for the second time', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListFailedResourceState(
'Intenal Server Error',
createListLoadedResourceState({ pageSize: 10 }, now)
)
)
);
expect(renderList(store).container).toMatchSnapshot();
});
it('triggers deletion dialog when delete action clicked', async () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadedResourceState({ pageSize: 10 }, now)
)
);
store.dispatch = jest.fn();
(await renderList(store).findAllByTestId('trustedAppDeleteButton'))[0].click();
expect(store.dispatch).toBeCalledWith({
type: 'trustedAppDeletionDialogStarted',
payload: {
entry: createSampleTrustedApp(0),
},
});
});
});

View file

@ -0,0 +1,118 @@
/*
* 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, useEffect } from 'react';
import {
EuiTablePagination,
EuiFlexGroup,
EuiFlexItem,
EuiProgress,
EuiIcon,
EuiText,
} from '@elastic/eui';
import { Pagination } from '../../../state';
import {
getListErrorMessage,
getListItems,
getListPagination,
isListLoading,
} from '../../../store/selectors';
import {
useTrustedAppsNavigateCallback,
useTrustedAppsSelector,
useTrustedAppsStoreActionCallback,
} from '../../hooks';
import { NO_RESULTS_MESSAGE } from '../../translations';
import { TrustedAppCard } from '../trusted_app_card';
export interface PaginationBarProps {
pagination: Pagination;
onChange: (pagination: { size: number; index: number }) => void;
}
const PaginationBar = ({ pagination, onChange }: PaginationBarProps) => {
const pageCount = Math.ceil(pagination.totalItemCount / pagination.pageSize);
useEffect(() => {
if (pageCount > 0 && pageCount < pagination.pageIndex + 1) {
onChange({ index: pageCount - 1, size: pagination.pageSize });
}
}, [pageCount, onChange, pagination]);
return (
<div>
<EuiTablePagination
activePage={pagination.pageIndex}
itemsPerPage={pagination.pageSize}
itemsPerPageOptions={pagination.pageSizeOptions}
pageCount={pageCount}
onChangeItemsPerPage={useCallback((size) => ({ index: 0, size }), [])}
onChangePage={useCallback((index) => ({ index, size: pagination.pageSize }), [
pagination.pageSize,
])}
/>
</div>
);
};
export const TrustedAppsGrid = memo(() => {
const pagination = useTrustedAppsSelector(getListPagination);
const listItems = useTrustedAppsSelector(getListItems);
const isLoading = useTrustedAppsSelector(isListLoading);
const error = useTrustedAppsSelector(getListErrorMessage);
const handleTrustedAppDelete = useTrustedAppsStoreActionCallback((trustedApp) => ({
type: 'trustedAppDeletionDialogStarted',
payload: { entry: trustedApp },
}));
const handlePaginationChange = useTrustedAppsNavigateCallback(({ index, size }) => ({
page_index: index,
page_size: size,
}));
return (
<EuiFlexGroup direction="column">
{isLoading && (
<EuiFlexItem grow={false}>
<EuiProgress size="xs" color="primary" />
</EuiFlexItem>
)}
<EuiFlexItem>
{error && (
<div className="euiTextAlign--center">
<EuiIcon type="minusInCircle" color="danger" /> {error}
</div>
)}
{!error && (
<EuiFlexGroup direction="column">
{listItems.map((item) => (
<EuiFlexItem grow={false} key={item.id}>
<TrustedAppCard trustedApp={item} onDelete={handleTrustedAppDelete} />
</EuiFlexItem>
))}
{listItems.length === 0 && (
<EuiText size="s" className="euiTextAlign--center">
{NO_RESULTS_MESSAGE}
</EuiText>
)}
</EuiFlexGroup>
)}
</EuiFlexItem>
{!error && pagination.totalItemCount > 0 && (
<EuiFlexItem grow={false}>
<PaginationBar pagination={pagination} onChange={handlePaginationChange} />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
});
TrustedAppsGrid.displayName = 'TrustedAppsGrid';

View file

@ -994,6 +994,7 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = `
>
<button
class="euiButton euiButton--danger euiButton--small"
data-test-subj="trustedAppDeleteButton"
type="button"
>
<span

View file

@ -0,0 +1,81 @@
/*
* 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 from 'react';
import { Provider } from 'react-redux';
import { ThemeProvider } from 'styled-components';
import { storiesOf } from '@storybook/react';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { KibanaContextProvider } from '../../../../../../../../../../src/plugins/kibana_react/public';
import {
createGlobalNoMiddlewareStore,
createListFailedResourceState,
createListLoadedResourceState,
createListLoadingResourceState,
createTrustedAppsListResourceStateChangedAction,
} from '../../../test_utils';
import { TrustedAppsList } from '.';
const now = 111111;
const renderList = (store: ReturnType<typeof createGlobalNoMiddlewareStore>) => (
<Provider store={store}>
<KibanaContextProvider services={{ uiSettings: { get: () => 'MMM D, YYYY @ HH:mm:ss.SSS' } }}>
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TrustedAppsList />
</ThemeProvider>
</KibanaContextProvider>
</Provider>
);
storiesOf('TrustedApps/TrustedAppsList', module)
.add('default', () => {
return renderList(createGlobalNoMiddlewareStore());
})
.add('loading', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(createListLoadingResourceState())
);
return renderList(store);
})
.add('error', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListFailedResourceState('Intenal Server Error')
)
);
return renderList(store);
})
.add('loaded', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadedResourceState({ pageSize: 10 }, now)
)
);
return renderList(store);
})
.add('loading second time', () => {
const store = createGlobalNoMiddlewareStore();
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadingResourceState(createListLoadedResourceState({ pageSize: 10 }, now))
)
);
return renderList(store);
});

View file

@ -9,7 +9,6 @@ import { Provider } from 'react-redux';
import { ThemeProvider } from 'styled-components';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { TrustedAppsList } from './trusted_apps_list';
import {
createSampleTrustedApp,
createListFailedResourceState,
@ -18,7 +17,9 @@ import {
createTrustedAppsListResourceStateChangedAction,
createUserChangedUrlAction,
createGlobalNoMiddlewareStore,
} from '../test_utils';
} from '../../../test_utils';
import { TrustedAppsList } from '.';
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
htmlIdGenerator: () => () => 'mockId',
@ -70,7 +71,7 @@ describe('TrustedAppsList', () => {
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadedResourceState({ index: 0, size: 20 }, 200, now)
createListLoadedResourceState({ pageSize: 20 }, now)
)
);
@ -82,7 +83,7 @@ describe('TrustedAppsList', () => {
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadedResourceState({ index: 0, size: 20 }, 200, now)
createListLoadedResourceState({ pageSize: 20 }, now)
)
);
store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50'));
@ -95,9 +96,7 @@ describe('TrustedAppsList', () => {
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadingResourceState(
createListLoadedResourceState({ index: 0, size: 20 }, 200, now)
)
createListLoadingResourceState(createListLoadedResourceState({ pageSize: 20 }, now))
)
);
@ -111,7 +110,7 @@ describe('TrustedAppsList', () => {
createTrustedAppsListResourceStateChangedAction(
createListFailedResourceState(
'Intenal Server Error',
createListLoadedResourceState({ index: 0, size: 20 }, 200, now)
createListLoadedResourceState({ pageSize: 20 }, now)
)
)
);
@ -124,7 +123,7 @@ describe('TrustedAppsList', () => {
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadedResourceState({ index: 0, size: 20 }, 200, now)
createListLoadedResourceState({ pageSize: 20 }, now)
)
);
@ -140,7 +139,7 @@ describe('TrustedAppsList', () => {
store.dispatch(
createTrustedAppsListResourceStateChangedAction(
createListLoadedResourceState({ index: 0, size: 20 }, 200, now)
createListLoadedResourceState({ pageSize: 20 }, now)
)
);
store.dispatch = jest.fn();

View file

@ -5,9 +5,8 @@
*/
import { Dispatch } from 'redux';
import React, { memo, ReactNode, useCallback, useMemo, useState } from 'react';
import React, { memo, ReactNode, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import {
EuiBasicTable,
EuiBasicTableColumn,
@ -16,26 +15,23 @@ import {
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import { Immutable } from '../../../../../common/endpoint/types';
import { AppAction } from '../../../../common/store/actions';
import { TrustedApp } from '../../../../../common/endpoint/types/trusted_apps';
import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants';
import { getTrustedAppsListPath } from '../../../common/routing';
import { Immutable } from '../../../../../../../common/endpoint/types';
import { AppAction } from '../../../../../../common/store/actions';
import { TrustedApp } from '../../../../../../../common/endpoint/types/trusted_apps';
import {
getCurrentLocationPageIndex,
getCurrentLocationPageSize,
getListErrorMessage,
getListItems,
getListTotalItemsCount,
getListPagination,
isListLoading,
} from '../store/selectors';
} from '../../../store/selectors';
import { useTrustedAppsSelector } from './hooks';
import { FormattedDate } from '../../../../../../common/components/formatted_date';
import { FormattedDate } from '../../../../common/components/formatted_date';
import { ACTIONS_COLUMN_TITLE, LIST_ACTIONS, OS_TITLES, PROPERTY_TITLES } from './translations';
import { TrustedAppCard } from './components/trusted_app_card';
import { useTrustedAppsNavigateCallback, useTrustedAppsSelector } from '../../hooks';
import { ACTIONS_COLUMN_TITLE, LIST_ACTIONS, OS_TITLES, PROPERTY_TITLES } from '../../translations';
import { TrustedAppCard } from '../trusted_app_card';
interface DetailsMap {
[K: string]: ReactNode;
@ -149,12 +145,9 @@ const getColumnDefinitions = (context: TrustedAppsListContext): ColumnsList => {
export const TrustedAppsList = memo(() => {
const [detailsMap, setDetailsMap] = useState<DetailsMap>({});
const pageIndex = useTrustedAppsSelector(getCurrentLocationPageIndex);
const pageSize = useTrustedAppsSelector(getCurrentLocationPageSize);
const totalItemCount = useTrustedAppsSelector(getListTotalItemsCount);
const pagination = useTrustedAppsSelector(getListPagination);
const listItems = useTrustedAppsSelector(getListItems);
const dispatch = useDispatch();
const history = useHistory();
return (
<EuiBasicTable
@ -168,27 +161,11 @@ export const TrustedAppsList = memo(() => {
itemId="id"
itemIdToExpandedRowMap={detailsMap}
isExpandable={true}
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]
)}
pagination={pagination}
onChange={useTrustedAppsNavigateCallback(({ page }) => ({
page_index: page.index,
page_size: page.size,
}))}
data-test-subj="trustedAppsList"
/>
);

View file

@ -0,0 +1,47 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`view_type_toggle ViewTypeToggle should render grid selection correctly 1`] = `
<EuiButtonGroup
color="primary"
data-test-subj="viewTypeToggleButton"
idSelected="grid"
onChange={[Function]}
options={
Array [
Object {
"iconType": "grid",
"id": "grid",
"label": "Grid view",
},
Object {
"iconType": "list",
"id": "list",
"label": "List view",
},
]
}
/>
`;
exports[`view_type_toggle ViewTypeToggle should render list selection correctly 1`] = `
<EuiButtonGroup
color="primary"
data-test-subj="viewTypeToggleButton"
idSelected="list"
onChange={[Function]}
options={
Array [
Object {
"iconType": "grid",
"id": "grid",
"label": "Grid view",
},
Object {
"iconType": "list",
"id": "list",
"label": "List view",
},
]
}
/>
`;

View file

@ -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 React, { useState } from 'react';
import { ThemeProvider } from 'styled-components';
import { storiesOf, addDecorator } from '@storybook/react';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { ViewType } from '../../../state';
import { ViewTypeToggle } from '.';
addDecorator((storyFn) => (
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>{storyFn()}</ThemeProvider>
));
const useRenderStory = (viewType: ViewType) => {
const [selectedOption, setSelectedOption] = useState<ViewType>(viewType);
return <ViewTypeToggle selectedOption={selectedOption} onToggle={setSelectedOption} />;
};
storiesOf('TrustedApps/ViewTypeToggle', module)
.add('grid selected', () => useRenderStory('grid'))
.add('list selected', () => useRenderStory('list'));

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { render } from '@testing-library/react';
import { shallow } from 'enzyme';
import React from 'react';
import { ViewTypeToggle } from '.';
describe('view_type_toggle', () => {
describe('ViewTypeToggle', () => {
it('should render grid selection correctly', () => {
const element = shallow(<ViewTypeToggle selectedOption="grid" onToggle={() => {}} />);
expect(element).toMatchSnapshot();
});
it('should render list selection correctly', () => {
const element = shallow(<ViewTypeToggle selectedOption="list" onToggle={() => {}} />);
expect(element).toMatchSnapshot();
});
it('should trigger onToggle', async () => {
const onToggle = jest.fn();
const element = render(<ViewTypeToggle selectedOption="list" onToggle={onToggle} />);
(await element.findAllByTestId('viewTypeToggleButton'))[0].click();
expect(onToggle).toBeCalledWith('grid');
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 } from 'react';
import { EuiButtonGroup } from '@elastic/eui';
import { ViewType } from '../../../state';
import { GRID_VIEW_TOGGLE_LABEL, LIST_VIEW_TOGGLE_LABEL } from '../../translations';
export interface ViewTypeToggleProps {
selectedOption: ViewType;
onToggle: (type: ViewType) => void;
}
export const ViewTypeToggle = memo(({ selectedOption, onToggle }: ViewTypeToggleProps) => {
const handleChange = useCallback(
(id) => {
if (id === 'list' || id === 'grid') {
onToggle(id);
}
},
[onToggle]
);
return (
<EuiButtonGroup
color="primary"
idSelected={selectedOption}
data-test-subj="viewTypeToggleButton"
options={[
{ id: 'grid', iconType: 'grid', label: GRID_VIEW_TOGGLE_LABEL },
{ id: 'list', iconType: 'list', label: LIST_VIEW_TOGGLE_LABEL },
]}
onChange={handleChange}
/>
);
});
ViewTypeToggle.displayName = 'ViewTypeToggle';

View file

@ -4,7 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { useCallback } from 'react';
import { State } from '../../../../common/store';
@ -13,10 +15,38 @@ import {
MANAGEMENT_STORE_GLOBAL_NAMESPACE as GLOBAL_NS,
} from '../../../common/constants';
import { TrustedAppsListPageState } from '../state';
import { AppAction } from '../../../../common/store/actions';
import { getTrustedAppsListPath } from '../../../common/routing';
import { TrustedAppsListPageLocation, TrustedAppsListPageState } from '../state';
import { getCurrentLocation } from '../store/selectors';
export function useTrustedAppsSelector<R>(selector: (state: TrustedAppsListPageState) => R): R {
return useSelector((state: State) =>
selector(state[GLOBAL_NS][TRUSTED_APPS_NS] as TrustedAppsListPageState)
);
}
export type NavigationCallback = (
...args: Parameters<Parameters<typeof useCallback>[0]>
) => Partial<TrustedAppsListPageLocation>;
export function useTrustedAppsNavigateCallback(callback: NavigationCallback) {
const location = useTrustedAppsSelector(getCurrentLocation);
const history = useHistory();
return useCallback(
(...args) => history.push(getTrustedAppsListPath({ ...location, ...callback(...args) })),
// TODO: needs more investigation, but if callback is in dependencies list memoization will never happen
// eslint-disable-next-line react-hooks/exhaustive-deps
[history, location]
);
}
export function useTrustedAppsStoreActionCallback(
callback: (...args: Parameters<Parameters<typeof useCallback>[0]>) => AppAction
) {
const dispatch = useDispatch();
// eslint-disable-next-line react-hooks/exhaustive-deps
return useCallback((...args) => dispatch(callback(...args)), [dispatch]);
}

View file

@ -82,3 +82,28 @@ export const LIST_ACTIONS = {
),
},
};
export const CARD_DELETE_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.trustedapps.card.removeButtonLabel',
{
defaultMessage: 'Remove',
}
);
export const GRID_VIEW_TOGGLE_LABEL = i18n.translate(
'xpack.securitySolution.trustedapps.view.toggle.grid',
{
defaultMessage: 'Grid view',
}
);
export const LIST_VIEW_TOGGLE_LABEL = i18n.translate(
'xpack.securitySolution.trustedapps.view.toggle.list',
{
defaultMessage: 'List view',
}
);
export const NO_RESULTS_MESSAGE = i18n.translate('xpack.securitySolution.trustedapps.noResults', {
defaultMessage: 'No items found',
});

View file

@ -47,14 +47,6 @@ describe('When on the Trusted Apps Page', () => {
window.scrollTo = jest.fn();
});
it('should render expected set of list columns', () => {
const { getByTestId } = render();
const tableColumns = Array.from(
getByTestId('trustedAppsList').querySelectorAll('table th')
).map((th) => (th.textContent || '').trim());
expect(tableColumns).toEqual(['Name', 'OS', 'Date Created', 'Created By', 'Actions']);
});
it('should display subtitle info about trusted apps', async () => {
const { getByTestId } = render();
expect(getByTestId('header-panel-subtitle').textContent).toEqual(expectedAboutInfo);

View file

@ -3,39 +3,42 @@
* 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 { FormattedMessage } from '@kbn/i18n/react';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { useHistory, useLocation } from 'react-router-dom';
import React, { memo, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiSpacer,
} from '@elastic/eui';
import { ViewType } from '../state';
import { getCurrentLocation, getListTotalItemsCount } from '../store/selectors';
import { useTrustedAppsNavigateCallback, useTrustedAppsSelector } from './hooks';
import { AdministrationListPage } from '../../../components/administration_list_page';
import { TrustedAppsList } from './trusted_apps_list';
import { CreateTrustedAppFlyout } from './components/create_trusted_app_flyout';
import { ControlPanel } from './components/control_panel';
import { TrustedAppsGrid } from './components/trusted_apps_grid';
import { TrustedAppsList } from './components/trusted_apps_list';
import { TrustedAppDeletionDialog } from './trusted_app_deletion_dialog';
import { TrustedAppsNotifications } from './trusted_apps_notifications';
import { CreateTrustedAppFlyout } from './components/create_trusted_app_flyout';
import { getTrustedAppsListPath } from '../../../common/routing';
import { useTrustedAppsSelector } from './hooks';
import { getCurrentLocation } from '../store/selectors';
import { TrustedAppsListPageRouteState } from '../../../../../common/endpoint/types';
import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler';
import { ABOUT_TRUSTED_APPS } from './translations';
export const TrustedAppsPage = memo(() => {
const history = useHistory();
const { state: routeState } = useLocation<TrustedAppsListPageRouteState | undefined>();
const location = useTrustedAppsSelector(getCurrentLocation);
const handleAddButtonClick = useCallback(() => {
history.push(
getTrustedAppsListPath({
...location,
show: 'create',
})
);
}, [history, location]);
const handleAddFlyoutClose = useCallback(() => {
const { show, ...paginationParamsOnly } = location;
history.push(getTrustedAppsListPath(paginationParamsOnly));
}, [history, location]);
const totalItemsCount = useTrustedAppsSelector(getListTotalItemsCount);
const handleAddButtonClick = useTrustedAppsNavigateCallback(() => ({ show: 'create' }));
const handleAddFlyoutClose = useTrustedAppsNavigateCallback(() => ({ show: undefined }));
const handleViewTypeChange = useTrustedAppsNavigateCallback((viewType: ViewType) => ({
view_type: viewType,
}));
const backButton = useMemo(() => {
if (routeState && routeState.onBackButtonNavigateTo) {
@ -84,7 +87,23 @@ export const TrustedAppsPage = memo(() => {
data-test-subj="addTrustedAppFlyout"
/>
)}
<TrustedAppsList />
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<ControlPanel
totalItemCount={totalItemsCount}
currentViewType={location.view_type}
onViewTypeChange={handleViewTypeChange}
/>
<EuiSpacer size="m" />
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule margin="none" />
{location.view_type === 'grid' && <TrustedAppsGrid />}
{location.view_type === 'list' && <TrustedAppsList />}
</EuiFlexItem>
</EuiFlexGroup>
</AdministrationListPage>
);
});