Add sorting for exceptions cards (#145070)

## Add sorting for shared exceptions

Currently is possible to sort by Name, Created At, Created By


https://user-images.githubusercontent.com/7609147/201640150-dc9d53e4-0d34-4da1-8522-9899d35e7359.mov

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Devin W. Hurley <snowmiser111@gmail.com>
This commit is contained in:
Khristinin Nikita 2022-11-15 21:55:38 +01:00 committed by GitHub
parent 463007f9cc
commit 35e02bcf64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 145 additions and 15 deletions

View file

@ -44,6 +44,7 @@ export interface UseExceptionListsProps {
notifications: NotificationsStart;
initialPagination?: Pagination;
hideLists?: readonly string[];
initialSort?: Sort;
}
export interface UseExceptionListProps {
@ -56,6 +57,7 @@ export interface UseExceptionListProps {
showEndpointListsOnly: boolean;
matchFilters: boolean;
onSuccess?: (arg: UseExceptionListItemsSuccess) => void;
sort?: Sort;
}
export interface FilterExceptionsOptions {
@ -81,6 +83,10 @@ export interface ApiListExportProps {
onSuccess: (blob: Blob) => void;
}
export interface Sort {
field: string;
order: string;
}
export interface Pagination {
page: Page;
perPage: PerPage;
@ -168,6 +174,7 @@ export interface ApiCallFetchExceptionListsProps {
http: HttpStart;
namespaceTypes: string;
pagination: Partial<Pagination>;
sort?: Sort;
filters: string;
signal: AbortSignal;
}

View file

@ -231,14 +231,15 @@ const fetchExceptionLists = async ({
namespaceTypes,
pagination,
signal,
sort,
}: ApiCallFetchExceptionListsProps): Promise<FoundExceptionListSchema> => {
const query = {
filter: filters || undefined,
namespace_type: namespaceTypes,
page: pagination.page ? `${pagination.page}` : '1',
per_page: pagination.perPage ? `${pagination.perPage}` : '20',
sort_field: 'exception-list.created_at',
sort_order: 'desc',
sort_field: sort?.field ? sort?.field : 'exception-list.created_at',
sort_order: sort?.order ? sort?.order : 'desc',
};
return http.fetch<FoundExceptionListSchema>(`${EXCEPTION_LIST_URL}/_find`, {
@ -254,6 +255,7 @@ const fetchExceptionListsWithValidation = async ({
namespaceTypes,
pagination,
signal,
sort,
}: ApiCallFetchExceptionListsProps): Promise<FoundExceptionListSchema> =>
flow(
() =>
@ -265,6 +267,7 @@ const fetchExceptionListsWithValidation = async ({
namespaceTypes,
pagination,
signal,
sort,
}),
toError
),

View file

@ -11,6 +11,7 @@ import type {
ExceptionListSchema,
UseExceptionListsProps,
Pagination,
Sort,
} from '@kbn/securitysolution-io-ts-list-types';
import { fetchExceptionLists } from '@kbn/securitysolution-list-api';
@ -22,7 +23,9 @@ export type ReturnExceptionLists = [
exceptionLists: ExceptionListSchema[],
pagination: Pagination,
setPagination: React.Dispatch<React.SetStateAction<Pagination>>,
fetchLists: Func | null
fetchLists: Func | null,
sort: Sort,
setSort: React.Dispatch<React.SetStateAction<Sort>>
];
const DEFAULT_PAGINATION = {
@ -31,6 +34,11 @@ const DEFAULT_PAGINATION = {
total: 0,
};
const DEFAULT_SORT = {
field: 'created_at',
order: 'desc',
};
/**
* Hook for fetching ExceptionLists
*
@ -51,9 +59,11 @@ export const useExceptionLists = ({
namespaceTypes,
notifications,
hideLists = [],
initialSort = DEFAULT_SORT,
}: UseExceptionListsProps): ReturnExceptionLists => {
const [exceptionLists, setExceptionLists] = useState<ExceptionListSchema[]>([]);
const [pagination, setPagination] = useState<Pagination>(initialPagination);
const [sort, setSort] = useState<Sort>(initialSort);
const [loading, setLoading] = useState(true);
const abortCtrlRef = useRef<AbortController>();
@ -87,6 +97,7 @@ export const useExceptionLists = ({
page: pagination.page,
perPage: pagination.perPage,
},
sort,
signal: abortCtrlRef.current.signal,
});
@ -115,6 +126,7 @@ export const useExceptionLists = ({
notifications.toasts,
pagination.page,
pagination.perPage,
sort,
]);
useEffect(() => {
@ -125,5 +137,5 @@ export const useExceptionLists = ({
};
}, [fetchData]);
return [loading, exceptionLists, pagination, setPagination, fetchData];
return [loading, exceptionLists, pagination, setPagination, fetchData, sort, setSort];
};

View file

@ -62,6 +62,8 @@ describe('useExceptionLists', () => {
},
expect.any(Function),
expect.any(Function),
{ field: 'created_at', order: 'desc' },
expect.any(Function),
]);
});
});
@ -102,6 +104,8 @@ describe('useExceptionLists', () => {
},
expect.any(Function),
expect.any(Function),
{ field: 'created_at', order: 'desc' },
expect.any(Function),
]);
});
});
@ -137,6 +141,7 @@ describe('useExceptionLists', () => {
namespaceTypes: 'single,agnostic',
pagination: { page: 1, perPage: 20 },
signal: new AbortController().signal,
sort: { field: 'created_at', order: 'desc' },
});
});
});
@ -175,6 +180,10 @@ describe('useExceptionLists', () => {
namespaceTypes: 'single,agnostic',
pagination: { page: 1, perPage: 20 },
signal: new AbortController().signal,
sort: {
field: 'created_at',
order: 'desc',
},
});
});
});

View file

@ -38,8 +38,8 @@ export const deleteExceptionListWithoutRuleReference = () => {
};
export const deleteExceptionListWithRuleReference = () => {
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click();
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).last().click();
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).last().click();
cy.get(EXCEPTIONS_TABLE_MODAL).should('exist');
cy.get(EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist');

View file

@ -7,6 +7,10 @@
import React from 'react';
import type { Sort } from '@kbn/securitysolution-io-ts-list-types';
import styled from 'styled-components';
import { EuiContextMenuPanel, EuiContextMenuItem, EuiIcon } from '@elastic/eui';
import {
UtilityBar,
UtilityBarAction,
@ -19,12 +23,19 @@ import * as i18n from '../../translations';
interface ExceptionsTableUtilityBarProps {
onRefresh?: () => void;
totalExceptionLists: number;
sort?: Sort;
setSort?: (s: Sort) => void;
sortFields?: Array<{ field: string; label: string; defaultOrder: 'asc' | 'desc' }>;
}
export const ExceptionsTableUtilityBar: React.FC<ExceptionsTableUtilityBarProps> = ({
onRefresh,
totalExceptionLists,
setSort,
sort,
sortFields,
}) => {
const selectedSortField = sortFields?.find((sortField) => sortField.field === sort?.field);
return (
<UtilityBar border>
<UtilityBarSection>
@ -44,8 +55,66 @@ export const ExceptionsTableUtilityBar: React.FC<ExceptionsTableUtilityBarProps>
</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
<UtilityBarSection>
<>
<UtilityBarGroup>
{sort && (
<UtilityBarAction
dataTestSubj="sortExceptions"
iconSide="right"
iconType={sort.order === 'asc' ? 'sortUp' : 'sortDown'}
popoverPanelPaddingSize={'s'}
popoverContent={() => (
<EuiContextMenuPanel
size="s"
items={sortFields?.map((item) => {
const isSelectedSortItem = selectedSortField?.field === item.field;
let nextSortOrder = item.defaultOrder;
if (isSelectedSortItem) {
nextSortOrder = sort.order === 'asc' ? 'desc' : 'asc';
}
return (
<EuiContextMenuItem
key={item.field}
onClick={() =>
setSort?.({
field: item.field,
order: nextSortOrder,
})
}
>
<SortMenuItem>
{item.label}{' '}
{selectedSortField?.field === item.field && (
<SortIcon type={sort.order === 'asc' ? 'sortUp' : 'sortDown'} />
)}
</SortMenuItem>
</EuiContextMenuItem>
);
})}
/>
)}
>
<SortMenuItem>
{i18n.SORT_BY}{' '}
{sortFields?.find((sortField) => sortField.field === sort.field)?.label}
</SortMenuItem>
</UtilityBarAction>
)}
</UtilityBarGroup>
</>
</UtilityBarSection>
</UtilityBar>
);
};
const SortMenuItem = styled('div')`
display: flex;
align-items: center;
`;
const SortIcon = styled(EuiIcon)`
margin-left: 8px;
`;
ExceptionsTableUtilityBar.displayName = 'ExceptionsTableUtilityBar';

View file

@ -71,6 +71,14 @@ const exceptionReferenceModalInitialState: ReferenceModalState = {
listNamespaceType: 'single',
};
const SORT_FIELDS: Array<{ field: string; label: string; defaultOrder: 'asc' | 'desc' }> = [
{
field: 'created_at',
label: i18n.SORT_BY_CREATE_AT,
defaultOrder: 'desc',
},
];
export const SharedLists = React.memo(() => {
const [{ loading: userInfoLoading, canUserCRUD, canUserREAD }] = useUserData();
@ -89,15 +97,23 @@ export const SharedLists = React.memo(() => {
const [filters, setFilters] = useState<ExceptionListFilter | undefined>({
types: [ExceptionListTypeEnum.DETECTION, ExceptionListTypeEnum.ENDPOINT],
});
const [loadingExceptions, exceptions, pagination, setPagination, refreshExceptions] =
useExceptionLists({
errorMessage: i18n.ERROR_EXCEPTION_LISTS,
filterOptions: filters,
http,
namespaceTypes: ['single', 'agnostic'],
notifications,
hideLists: ALL_ENDPOINT_ARTIFACT_LIST_IDS,
});
const [
loadingExceptions,
exceptions,
pagination,
setPagination,
refreshExceptions,
sort,
setSort,
] = useExceptionLists({
errorMessage: i18n.ERROR_EXCEPTION_LISTS,
filterOptions: filters,
http,
namespaceTypes: ['single', 'agnostic'],
notifications,
hideLists: ALL_ENDPOINT_ARTIFACT_LIST_IDS,
});
const [loadingTableInfo, exceptionListsWithRuleRefs, exceptionsListsRef] = useAllExceptionLists({
exceptionLists: exceptions ?? [],
});
@ -470,6 +486,9 @@ export const SharedLists = React.memo(() => {
<ExceptionsTableUtilityBar
totalExceptionLists={exceptionListsWithRuleRefs.length}
onRefresh={handleRefresh}
setSort={setSort}
sort={sort}
sortFields={SORT_FIELDS}
/>
{exceptionListsWithRuleRefs.length > 0 && canUserCRUD !== null && canUserREAD !== null && (
<div data-test-subj="exceptionsTable">

View file

@ -347,3 +347,14 @@ export const SUCCESS_TITLE = i18n.translate(
defaultMessage: 'created list',
}
);
export const SORT_BY = i18n.translate('xpack.securitySolution.exceptions.sortBy', {
defaultMessage: 'Sort by:',
});
export const SORT_BY_CREATE_AT = i18n.translate(
'xpack.securitySolution.exceptions.sortByCreateAt',
{
defaultMessage: 'Created At',
}
);