[Security Solution][Exceptions] - Fix bug displaying empty view when no exception list search results found (#151530)

## Summary

Addresses https://github.com/elastic/kibana/issues/145717

Updated the all exceptions lists view to account for different possible
states (empty search, loading, no lists) using an existing component.
This commit is contained in:
Yara Tercero 2023-02-23 00:01:32 -08:00 committed by GitHub
parent c86f4ea6a7
commit 56777859bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 160 additions and 38 deletions

View file

@ -19,7 +19,7 @@ describe('EmptyViewerState', () => {
<EmptyViewerState
isReadOnly={false}
viewerStatus={ViewerStatus.ERROR}
onCreateExceptionListItem={jest.fn()}
onEmptyButtonStateClick={jest.fn()}
/>
);
@ -34,7 +34,7 @@ describe('EmptyViewerState', () => {
<EmptyViewerState
isReadOnly={false}
viewerStatus={ViewerStatus.ERROR}
onCreateExceptionListItem={jest.fn()}
onEmptyButtonStateClick={jest.fn()}
title="Error title"
body="Error body"
/>
@ -49,7 +49,7 @@ describe('EmptyViewerState', () => {
<EmptyViewerState
isReadOnly={false}
viewerStatus={ViewerStatus.LOADING}
onCreateExceptionListItem={jest.fn()}
onEmptyButtonStateClick={jest.fn()}
/>
);
@ -60,7 +60,7 @@ describe('EmptyViewerState', () => {
<EmptyViewerState
isReadOnly={false}
viewerStatus={ViewerStatus.EMPTY_SEARCH}
onCreateExceptionListItem={jest.fn()}
onEmptyButtonStateClick={jest.fn()}
/>
);
@ -77,7 +77,7 @@ describe('EmptyViewerState', () => {
<EmptyViewerState
isReadOnly={false}
viewerStatus={ViewerStatus.EMPTY_SEARCH}
onCreateExceptionListItem={jest.fn()}
onEmptyButtonStateClick={jest.fn()}
title="Empty search title"
body="Empty search body"
/>
@ -92,7 +92,7 @@ describe('EmptyViewerState', () => {
<EmptyViewerState
isReadOnly={false}
viewerStatus={ViewerStatus.EMPTY}
onCreateExceptionListItem={jest.fn()}
onEmptyButtonStateClick={jest.fn()}
body="There are no endpoint exceptions."
buttonText="Add endpoint exception"
/>
@ -108,7 +108,7 @@ describe('EmptyViewerState', () => {
<EmptyViewerState
isReadOnly={false}
viewerStatus={ViewerStatus.EMPTY}
onCreateExceptionListItem={jest.fn()}
onEmptyButtonStateClick={jest.fn()}
/>
);
@ -125,7 +125,7 @@ describe('EmptyViewerState', () => {
<EmptyViewerState
isReadOnly={false}
viewerStatus={ViewerStatus.EMPTY}
onCreateExceptionListItem={jest.fn()}
onEmptyButtonStateClick={jest.fn()}
listType={ListTypeText.ENDPOINT}
/>
);
@ -143,7 +143,7 @@ describe('EmptyViewerState', () => {
<EmptyViewerState
isReadOnly={true}
viewerStatus={ViewerStatus.EMPTY}
onCreateExceptionListItem={jest.fn()}
onEmptyButtonStateClick={jest.fn()}
listType={ListTypeText.ENDPOINT}
/>
);

View file

@ -31,7 +31,7 @@ interface EmptyViewerStateProps {
listType?: ListTypeText;
isReadOnly: boolean;
viewerStatus: ViewerStatus;
onCreateExceptionListItem?: () => void | null;
onEmptyButtonStateClick?: () => void | null;
}
const panelCss = css`
@ -45,7 +45,7 @@ const EmptyViewerStateComponent: FC<EmptyViewerStateProps> = ({
listType,
isReadOnly,
viewerStatus,
onCreateExceptionListItem,
onEmptyButtonStateClick,
}) => {
const { euiTheme } = useEuiTheme();
@ -75,7 +75,7 @@ const EmptyViewerStateComponent: FC<EmptyViewerStateProps> = ({
actions: [
<EuiButton
data-test-subj="emptyStateButton"
onClick={onCreateExceptionListItem}
onClick={onEmptyButtonStateClick}
iconType="plusInCircle"
color="primary"
isDisabled={isReadOnly}
@ -110,7 +110,7 @@ const EmptyViewerStateComponent: FC<EmptyViewerStateProps> = ({
euiTheme.colors.darkestShade,
title,
body,
onCreateExceptionListItem,
onEmptyButtonStateClick,
isReadOnly,
buttonText,
listType,

View file

@ -92,7 +92,7 @@ const ExceptionItemsComponent: FC<ExceptionItemsProps> = ({
viewerStatus={viewerStatus}
buttonText={emptyViewerButtonText}
body={emptyViewerBody}
onCreateExceptionListItem={onCreateExceptionListItem}
onEmptyButtonStateClick={onCreateExceptionListItem}
/>
);
return (

View file

@ -76,7 +76,7 @@ const ListWithSearchComponent: FC<ListWithSearchComponentProps> = ({
<EmptyViewerState
isReadOnly={isReadOnly}
viewerStatus={viewerStatus as ViewerStatus}
onCreateExceptionListItem={onAddExceptionClick}
onEmptyButtonStateClick={onAddExceptionClick}
title={i18n.EXCEPTION_LIST_EMPTY_VIEWER_TITLE}
body={i18n.EXCEPTION_LIST_EMPTY_VIEWER_BODY(listName)}
buttonText={i18n.EXCEPTION_LIST_EMPTY_VIEWER_BUTTON}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useCallback, useState } from 'react';
import React, { useMemo, useEffect, useCallback, useState } from 'react';
import type { EuiSearchBarProps } from '@elastic/eui';
import {
@ -18,8 +18,6 @@ import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
EuiProgress,
EuiSpacer,
EuiPageHeader,
EuiHorizontalRule,
@ -29,9 +27,9 @@ import {
import type { NamespaceType, ExceptionListFilter } from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks';
import { ViewerStatus, EmptyViewerState } from '@kbn/securitysolution-exception-list-components';
import { AutoDownload } from '../../../common/components/auto_download/auto_download';
import { Loader } from '../../../common/components/loader';
import { useKibana } from '../../../common/lib/kibana';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
@ -103,6 +101,8 @@ export const SharedLists = React.memo(() => {
);
const [filters, setFilters] = useState<ExceptionListFilter | undefined>();
const [viewerStatus, setViewStatus] = useState<ViewerStatus | null>(ViewerStatus.LOADING);
const [
loadingExceptions,
exceptions,
@ -133,6 +133,12 @@ export const SharedLists = React.memo(() => {
const [displayImportListFlyout, setDisplayImportListFlyout] = useState(false);
const { addError, addSuccess } = useAppToasts();
// Loading states
const exceptionsLoaded = !loadingTableInfo && !initLoading;
const hasNoExceptions = !loadingExceptions && !exceptionListsWithRuleRefs.length;
const isSearchingExceptions = viewerStatus === ViewerStatus.SEARCHING;
const isLoadingExceptions = viewerStatus === ViewerStatus.LOADING;
const handleDeleteSuccess = useCallback(
(listId?: string) => () => {
notifications.toasts.addSuccess({
@ -235,6 +241,7 @@ export const SharedLists = React.memo(() => {
query,
queryText,
}: Parameters<NonNullable<EuiSearchBarProps['onChange']>>[0]): Promise<void> => {
setViewStatus(ViewerStatus.SEARCHING);
const filterOptions = {
name: null,
list_id: null,
@ -378,6 +385,23 @@ export const SharedLists = React.memo(() => {
setDisplayAddExceptionItemFlyout(false);
setIsCreatePopoverOpen(false);
};
const onCreateExceptionListOpenClick = () => setDisplayCreateSharedListFlyout(true);
const isReadOnly = useMemo(() => {
return (canUserREAD && !canUserCRUD) ?? true;
}, [canUserREAD, canUserCRUD]);
useEffect(() => {
if (isSearchingExceptions && hasNoExceptions) {
setViewStatus(ViewerStatus.EMPTY_SEARCH);
} else if (!exceptionsLoaded) {
setViewStatus(ViewerStatus.LOADING);
} else if (isLoadingExceptions && hasNoExceptions) {
setViewStatus(ViewerStatus.EMPTY);
} else if (isLoadingExceptions && exceptionsLoaded) {
setViewStatus(null);
}
}, [isSearchingExceptions, hasNoExceptions, exceptionsLoaded, isLoadingExceptions]);
return (
<>
@ -428,7 +452,7 @@ export const SharedLists = React.memo(() => {
key={'createList'}
onClick={() => {
onCloseCreatePopover();
setDisplayCreateSharedListFlyout(true);
onCreateExceptionListOpenClick();
}}
>
{i18n.CREATE_SHARED_LIST_BUTTON}
@ -468,7 +492,7 @@ export const SharedLists = React.memo(() => {
isEndpointItem={false}
isBulkAction={false}
showAlertCloseOptions
onCancel={(didRuleChange: boolean) => setDisplayAddExceptionItemFlyout(false)}
onCancel={() => setDisplayAddExceptionItemFlyout(false)}
onConfirm={(didRuleChange: boolean) => {
setDisplayAddExceptionItemFlyout(false);
if (didRuleChange) handleRefresh();
@ -489,23 +513,17 @@ export const SharedLists = React.memo(() => {
<EuiHorizontalRule />
<div data-test-subj="allExceptionListsPanel">
{loadingTableInfo && (
<EuiProgress
data-test-subj="loadingRulesInfoProgress"
size="xs"
position="absolute"
color="accent"
/>
)}
{!initLoading && <ListsSearchBar onSearch={handleSearch} />}
<EuiSpacer size="m" />
{loadingTableInfo && !initLoading && !showReferenceErrorModal && (
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
{initLoading || loadingTableInfo ? (
<EuiLoadingContent data-test-subj="initialLoadingPanelAllRulesTable" lines={10} />
{viewerStatus != null ? (
<EmptyViewerState
isReadOnly={isReadOnly}
title={i18n.NO_EXCEPTION_LISTS}
viewerStatus={viewerStatus}
buttonText={i18n.CREATE_SHARED_LIST_BUTTON}
body={i18n.NO_LISTS_BODY}
onEmptyButtonStateClick={onCreateExceptionListOpenClick}
/>
) : (
<>
<ExceptionsTableUtilityBar
@ -515,13 +533,13 @@ export const SharedLists = React.memo(() => {
sort={sort}
sortFields={SORT_FIELDS}
/>
{exceptionListsWithRuleRefs.length > 0 && canUserCRUD !== null && canUserREAD !== null && (
{exceptionListsWithRuleRefs.length > 0 && (
<div data-test-subj="exceptionsTable">
{exceptionListsWithRuleRefs.map((excList) => (
<ExceptionsListCard
key={excList.list_id}
data-test-subj="exceptionsListCard"
readOnly={canUserREAD && !canUserCRUD}
readOnly={isReadOnly}
exceptionsList={excList}
handleDelete={handleDelete}
handleExport={handleExport}

View file

@ -90,6 +90,110 @@ describe('SharedLists', () => {
]);
});
it('renders empty view if no lists exist', async () => {
(useExceptionLists as jest.Mock).mockReturnValue([
false,
[],
{
page: 1,
perPage: 20,
total: 0,
},
jest.fn(),
]);
(useAllExceptionLists as jest.Mock).mockReturnValue([false, [], {}]);
const wrapper = render(
<TestProviders>
<SharedLists />
</TestProviders>
);
await waitFor(() => {
const emptyViewerState = wrapper.getByTestId('emptyViewerState');
expect(emptyViewerState).toBeInTheDocument();
});
});
it('renders loading state when fetching lists', async () => {
(useExceptionLists as jest.Mock).mockReturnValue([
true,
[],
{
page: 1,
perPage: 20,
total: 0,
},
jest.fn(),
]);
(useAllExceptionLists as jest.Mock).mockReturnValue([false, [], {}]);
const wrapper = render(
<TestProviders>
<SharedLists />
</TestProviders>
);
await waitFor(() => {
const loadingViewerState = wrapper.getByTestId('loadingViewerState');
expect(loadingViewerState).toBeInTheDocument();
});
});
it('renders loading state when fetching refs', async () => {
(useExceptionLists as jest.Mock).mockReturnValue([
false,
[exceptionList1, exceptionList2],
{
page: 1,
perPage: 20,
total: 2,
},
jest.fn(),
]);
(useAllExceptionLists as jest.Mock).mockReturnValue([true, [], {}]);
const wrapper = render(
<TestProviders>
<SharedLists />
</TestProviders>
);
await waitFor(() => {
const loadingViewerState = wrapper.getByTestId('loadingViewerState');
expect(loadingViewerState).toBeInTheDocument();
});
});
it('renders empty search state when no search results are found', async () => {
const wrapper = render(
<TestProviders>
<SharedLists />
</TestProviders>
);
(useExceptionLists as jest.Mock).mockReturnValue([
false,
[],
{
page: 1,
perPage: 20,
total: 0,
},
jest.fn(),
]);
(useAllExceptionLists as jest.Mock).mockReturnValue([false, [], {}]);
const searchBar = wrapper.getByTestId('exceptionsHeaderSearchInput');
fireEvent.change(searchBar, { target: { value: 'foo' } });
await waitFor(() => {
const emptySearchViewerState = wrapper.getByTestId('emptySearchViewerState');
expect(emptySearchViewerState).toBeInTheDocument();
});
});
it('renders delete option as disabled if list is "endpoint_list"', async () => {
const wrapper = render(
<TestProviders>