[SIEM][Detections] Value Lists Management Modal (#67068)
* Add Frontend components for Value Lists Management Modal Imports and uses the hooks provided by the lists plugin. Tests coming next. * Update value list components to use newest Lists API * uses useEffect on a task's state instead of promise chaining * handles the fact that API calls can be rejected with strings * uses exportList function instead of hook * Close modal on outside click * Add hook for using a cursor with paged API calls. For e.g. findLists, we can send along a cursor to optimize our query. On the backend, this cursor is used as part of a search_after query. * Better implementation of useCursor * Does not require args for setCursor as they're already passed to the hook * Finds nearest cursor for the same page size Eventually this logic will also include sortField as part of the hash/lookup, but we do not currently use that on the frontend. * Fixes useCursor hook functionality We were previously storing the cursor on the _current_ page, when it's only truly valid for the _next_ page (and beyond). This was causing a few issues, but now that it's fixed everything works great. * Add cursor to lists query This allows us to search_after a previous page's search, if available. * Do not validate response of export This is just a blob, so we have nothing to validate. * Fix double callback post-import After uploading a list, the modal was being shown twice. Declaring the constituent state dependencies separately fixed the issue. * Update ValueListsForm to manually abort import request These hooks no longer care about/expose an abort function. In this one case where we need that functionality, we can do it ourselves relatively simply. * Default modal table to five rows * Update translation keys following plugin rename * Try to fit table contents on a single row Dates were wrapping (and raw), and so were wrapped in a FormattedDate component. However, since this component didn't wrap, we needed to shrink/truncate the uploaded_by field as well as allow the fileName to truncate. * Add helper function to prevent tests from logging errors https://github.com/enzymejs/enzyme/issues/2073 seems to be an ongoing issue, and causes components with useEffect to update after the test is completed. waitForUpdates ensures that updates have completed within an act() before continuing on. * Add jest tests for our form, table, and modal components * Fix translation conflict * Add more waitForUpdates to new overview page tests Each of these logs a console.error without them. * Fix bad merge resolution That resulted in duplicate exports. * Make cursor an optional parameter to findLists This param is an optimization and not required for basic functionality. * Tweaking Table column sizes Makes actions column smaller, leaving more room for everything else. * Fix bug where onSuccess is called upon pagination change Because fetchLists changes when pagination does, and handleUploadSuccess changes with fetchLists, our useEffect in Form was being fired on every pagination change due to its onSuccess changing. The solution in this instance is to remove fetchLists from handleUploadSuccess's dependencies, as we merely want to invoke fetchLists from it, not change our reference. * Fix failing test It looks like this broke because EuiTable's pagination changed from a button to an anchor tag. * Hide page size options on ValueLists modal table These have style issues, and anything above 5 rows causes the modal to scroll, so we're going to disable it for now. * Update error callbacks now that we have Errors We don't display the nice errors in the case of an ApiError right now, but this is better than it was. * Synchronize delete with the subsequent fetch Our start() no longer resolves in a meaningful way, so we instead need to perform the refetch in an effect watching the result of our delete. * Cast our unknown error to an Error useAsync generally does not know how what its tasks are going to be rejected with, hence the unknown. For these API calls we know that it will be an Error, but I don't currently have a way to type that generally. For now, we'll cast it where we use it. * Import lists code from our new, standardized modules Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
683fb42df7
commit
835c13dd6a
|
@ -39,4 +39,5 @@ export {
|
|||
entriesList,
|
||||
namespaceType,
|
||||
ExceptionListType,
|
||||
Type,
|
||||
} from './schemas';
|
||||
|
|
118
x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts
Normal file
118
x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts
Normal 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 { act, renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { UseCursorProps, useCursor } from './use_cursor';
|
||||
|
||||
describe('useCursor', () => {
|
||||
it('returns undefined cursor if no values have been set', () => {
|
||||
const { result } = renderHook((props: UseCursorProps) => useCursor(props), {
|
||||
initialProps: { pageIndex: 0, pageSize: 0 },
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retrieves a cursor for the next page of a given page size', () => {
|
||||
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
|
||||
initialProps: { pageIndex: 0, pageSize: 0 },
|
||||
});
|
||||
rerender({ pageIndex: 1, pageSize: 1 });
|
||||
act(() => {
|
||||
result.current[1]('new_cursor');
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBeUndefined();
|
||||
|
||||
rerender({ pageIndex: 2, pageSize: 1 });
|
||||
expect(result.current[0]).toEqual('new_cursor');
|
||||
});
|
||||
|
||||
it('returns undefined cursor for an unknown search', () => {
|
||||
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
|
||||
initialProps: { pageIndex: 0, pageSize: 0 },
|
||||
});
|
||||
act(() => {
|
||||
result.current[1]('new_cursor');
|
||||
});
|
||||
|
||||
rerender({ pageIndex: 1, pageSize: 2 });
|
||||
expect(result.current[0]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('remembers cursor through rerenders', () => {
|
||||
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
|
||||
initialProps: { pageIndex: 0, pageSize: 0 },
|
||||
});
|
||||
|
||||
rerender({ pageIndex: 1, pageSize: 1 });
|
||||
act(() => {
|
||||
result.current[1]('new_cursor');
|
||||
});
|
||||
|
||||
rerender({ pageIndex: 2, pageSize: 1 });
|
||||
expect(result.current[0]).toEqual('new_cursor');
|
||||
|
||||
rerender({ pageIndex: 0, pageSize: 0 });
|
||||
expect(result.current[0]).toBeUndefined();
|
||||
|
||||
rerender({ pageIndex: 2, pageSize: 1 });
|
||||
expect(result.current[0]).toEqual('new_cursor');
|
||||
});
|
||||
|
||||
it('remembers multiple cursors', () => {
|
||||
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
|
||||
initialProps: { pageIndex: 0, pageSize: 0 },
|
||||
});
|
||||
|
||||
rerender({ pageIndex: 1, pageSize: 1 });
|
||||
act(() => {
|
||||
result.current[1]('new_cursor');
|
||||
});
|
||||
rerender({ pageIndex: 2, pageSize: 2 });
|
||||
act(() => {
|
||||
result.current[1]('another_cursor');
|
||||
});
|
||||
|
||||
rerender({ pageIndex: 2, pageSize: 1 });
|
||||
expect(result.current[0]).toEqual('new_cursor');
|
||||
|
||||
rerender({ pageIndex: 3, pageSize: 2 });
|
||||
expect(result.current[0]).toEqual('another_cursor');
|
||||
});
|
||||
|
||||
it('returns the "nearest" cursor for the given page size', () => {
|
||||
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
|
||||
initialProps: { pageIndex: 0, pageSize: 0 },
|
||||
});
|
||||
|
||||
rerender({ pageIndex: 1, pageSize: 2 });
|
||||
act(() => {
|
||||
result.current[1]('cursor1');
|
||||
});
|
||||
rerender({ pageIndex: 2, pageSize: 2 });
|
||||
act(() => {
|
||||
result.current[1]('cursor2');
|
||||
});
|
||||
rerender({ pageIndex: 3, pageSize: 2 });
|
||||
act(() => {
|
||||
result.current[1]('cursor3');
|
||||
});
|
||||
|
||||
rerender({ pageIndex: 2, pageSize: 2 });
|
||||
expect(result.current[0]).toEqual('cursor1');
|
||||
|
||||
rerender({ pageIndex: 3, pageSize: 2 });
|
||||
expect(result.current[0]).toEqual('cursor2');
|
||||
|
||||
rerender({ pageIndex: 4, pageSize: 2 });
|
||||
expect(result.current[0]).toEqual('cursor3');
|
||||
|
||||
rerender({ pageIndex: 6, pageSize: 2 });
|
||||
expect(result.current[0]).toEqual('cursor3');
|
||||
});
|
||||
});
|
43
x-pack/plugins/lists/public/common/hooks/use_cursor.ts
Normal file
43
x-pack/plugins/lists/public/common/hooks/use_cursor.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { useCallback, useState } from 'react';
|
||||
|
||||
export interface UseCursorProps {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}
|
||||
type Cursor = string | undefined;
|
||||
type SetCursor = (cursor: Cursor) => void;
|
||||
type UseCursor = (props: UseCursorProps) => [Cursor, SetCursor];
|
||||
|
||||
const hash = (props: UseCursorProps): string => JSON.stringify(props);
|
||||
|
||||
export const useCursor: UseCursor = ({ pageIndex, pageSize }) => {
|
||||
const [cache, setCache] = useState<Record<string, Cursor>>({});
|
||||
|
||||
const setCursor = useCallback<SetCursor>(
|
||||
(cursor) => {
|
||||
setCache({
|
||||
...cache,
|
||||
[hash({ pageIndex: pageIndex + 1, pageSize })]: cursor,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pageIndex, pageSize]
|
||||
);
|
||||
|
||||
let cursor: Cursor;
|
||||
for (let i = pageIndex; i >= 0; i--) {
|
||||
const currentProps = { pageIndex: i, pageSize };
|
||||
cursor = cache[hash(currentProps)];
|
||||
if (cursor) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [cursor, setCursor];
|
||||
};
|
|
@ -114,6 +114,7 @@ describe('Value Lists API', () => {
|
|||
it('sends pagination as query parameters', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
await findLists({
|
||||
cursor: 'cursor',
|
||||
http: httpMock,
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
|
@ -123,14 +124,21 @@ describe('Value Lists API', () => {
|
|||
expect(httpMock.fetch).toHaveBeenCalledWith(
|
||||
'/api/lists/_find',
|
||||
expect.objectContaining({
|
||||
query: { page: 1, per_page: 10 },
|
||||
query: {
|
||||
cursor: 'cursor',
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects with an error if request payload is invalid (and does not make API call)', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
const payload: ApiPayload<FindListsParams> = { pageIndex: 10, pageSize: 0 };
|
||||
const payload: ApiPayload<FindListsParams> = {
|
||||
pageIndex: 10,
|
||||
pageSize: 0,
|
||||
};
|
||||
|
||||
await expect(
|
||||
findLists({
|
||||
|
@ -144,7 +152,10 @@ describe('Value Lists API', () => {
|
|||
|
||||
it('rejects with an error if response payload is invalid', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
const payload: ApiPayload<FindListsParams> = { pageIndex: 1, pageSize: 10 };
|
||||
const payload: ApiPayload<FindListsParams> = {
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
const badResponse = { ...getFoundListSchemaMock(), cursor: undefined };
|
||||
httpMock.fetch.mockResolvedValue(badResponse);
|
||||
|
||||
|
@ -269,7 +280,7 @@ describe('Value Lists API', () => {
|
|||
|
||||
describe('exportList', () => {
|
||||
beforeEach(() => {
|
||||
httpMock.fetch.mockResolvedValue(getListResponseMock());
|
||||
httpMock.fetch.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('POSTs to the export endpoint', async () => {
|
||||
|
@ -319,66 +330,49 @@ describe('Value Lists API', () => {
|
|||
).rejects.toEqual(new Error('Invalid value "23" supplied to "list_id"'));
|
||||
expect(httpMock.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readListIndex', () => {
|
||||
beforeEach(() => {
|
||||
httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock());
|
||||
});
|
||||
|
||||
it('GETs the list index', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
await readListIndex({
|
||||
http: httpMock,
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
|
||||
expect(httpMock.fetch).toHaveBeenCalledWith(
|
||||
'/api/lists/index',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the response when valid', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
const result = await readListIndex({
|
||||
http: httpMock,
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual(getListItemIndexExistSchemaResponseMock());
|
||||
});
|
||||
|
||||
it('rejects with an error if response payload is invalid', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
const payload: ApiPayload<ExportListParams> = {
|
||||
listId: 'list-id',
|
||||
};
|
||||
const badResponse = { ...getListResponseMock(), id: undefined };
|
||||
const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined };
|
||||
httpMock.fetch.mockResolvedValue(badResponse);
|
||||
|
||||
await expect(
|
||||
exportList({
|
||||
readListIndex({
|
||||
http: httpMock,
|
||||
...payload,
|
||||
signal: abortCtrl.signal,
|
||||
})
|
||||
).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"'));
|
||||
});
|
||||
|
||||
describe('readListIndex', () => {
|
||||
beforeEach(() => {
|
||||
httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock());
|
||||
});
|
||||
|
||||
it('GETs the list index', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
await readListIndex({
|
||||
http: httpMock,
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
|
||||
expect(httpMock.fetch).toHaveBeenCalledWith(
|
||||
'/api/lists/index',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the response when valid', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
const result = await readListIndex({
|
||||
http: httpMock,
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual(getListItemIndexExistSchemaResponseMock());
|
||||
});
|
||||
|
||||
it('rejects with an error if response payload is invalid', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined };
|
||||
httpMock.fetch.mockResolvedValue(badResponse);
|
||||
|
||||
await expect(
|
||||
readListIndex({
|
||||
http: httpMock,
|
||||
signal: abortCtrl.signal,
|
||||
})
|
||||
).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"'));
|
||||
});
|
||||
).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"'));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -59,6 +59,7 @@ const findLists = async ({
|
|||
};
|
||||
|
||||
const findListsWithValidation = async ({
|
||||
cursor,
|
||||
http,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
|
@ -66,8 +67,9 @@ const findListsWithValidation = async ({
|
|||
}: FindListsParams): Promise<FoundListSchema> =>
|
||||
pipe(
|
||||
{
|
||||
page: String(pageIndex),
|
||||
per_page: String(pageSize),
|
||||
cursor: cursor?.toString(),
|
||||
page: pageIndex?.toString(),
|
||||
per_page: pageSize?.toString(),
|
||||
},
|
||||
(payload) => fromEither(validateEither(findListSchema, payload)),
|
||||
chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), toError)),
|
||||
|
@ -170,7 +172,6 @@ const exportListWithValidation = async ({
|
|||
{ list_id: listId },
|
||||
(payload) => fromEither(validateEither(exportListItemQuerySchema, payload)),
|
||||
chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), toError)),
|
||||
chain((response) => fromEither(validateEither(listSchema, response))),
|
||||
flow(toPromise)
|
||||
);
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ export interface ApiParams {
|
|||
export type ApiPayload<T extends ApiParams> = Omit<T, 'http' | 'signal'>;
|
||||
|
||||
export interface FindListsParams extends ApiParams {
|
||||
cursor?: string | undefined;
|
||||
pageSize: number | undefined;
|
||||
pageIndex: number | undefined;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ export { useExceptionList } from './exceptions/hooks/use_exception_list';
|
|||
export { useFindLists } from './lists/hooks/use_find_lists';
|
||||
export { useImportList } from './lists/hooks/use_import_list';
|
||||
export { useDeleteList } from './lists/hooks/use_delete_list';
|
||||
export { exportList } from './lists/api';
|
||||
export { useCursor } from './common/hooks/use_cursor';
|
||||
export { useExportList } from './lists/hooks/use_export_list';
|
||||
export { useReadListIndex } from './lists/hooks/use_read_list_index';
|
||||
export { useCreateListIndex } from './lists/hooks/use_create_list_index';
|
||||
|
|
|
@ -39,4 +39,5 @@ export {
|
|||
entriesList,
|
||||
namespaceType,
|
||||
ExceptionListType,
|
||||
Type,
|
||||
} from '../../lists/common';
|
||||
|
|
|
@ -8,12 +8,13 @@ import moment from 'moment-timezone';
|
|||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants';
|
||||
import { useUiSetting, useKibana } from './kibana_react';
|
||||
import { errorToToaster, useStateToaster } from '../../components/toasters';
|
||||
import { AuthenticatedUser } from '../../../../../security/common/model';
|
||||
import { convertToCamelCase } from '../../../cases/containers/utils';
|
||||
import { StartServices } from '../../../types';
|
||||
import { useUiSetting, useKibana } from './kibana_react';
|
||||
|
||||
export const useDateFormat = (): string => useUiSetting<string>(DEFAULT_DATE_FORMAT);
|
||||
|
||||
|
@ -24,6 +25,11 @@ export const useTimeZone = (): string => {
|
|||
|
||||
export const useBasePath = (): string => useKibana().services.http.basePath.get();
|
||||
|
||||
export const useToasts = (): StartServices['notifications']['toasts'] =>
|
||||
useKibana().services.notifications.toasts;
|
||||
|
||||
export const useHttp = (): StartServices['http'] => useKibana().services.http;
|
||||
|
||||
interface UserRealm {
|
||||
name: string;
|
||||
type: string;
|
||||
|
@ -125,8 +131,3 @@ export const useGetUserSavedObjectPermissions = () => {
|
|||
|
||||
return savedObjectsPermissions;
|
||||
};
|
||||
|
||||
export const useToasts = (): StartServices['notifications']['toasts'] =>
|
||||
useKibana().services.notifications.toasts;
|
||||
|
||||
export const useHttp = (): StartServices['http'] => useKibana().services.http;
|
||||
|
|
|
@ -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 { ReactWrapper } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
// Temporary fix for https://github.com/enzymejs/enzyme/issues/2073
|
||||
export const waitForUpdates = async <P>(wrapper: ReactWrapper<P>) => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
});
|
||||
};
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* 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, { FormEvent } from 'react';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { waitForUpdates } from '../../../common/utils/test_utils';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { ValueListsForm } from './form';
|
||||
import { useImportList } from '../../../shared_imports';
|
||||
|
||||
jest.mock('../../../shared_imports');
|
||||
const mockUseImportList = useImportList as jest.Mock;
|
||||
|
||||
const mockFile = ({
|
||||
name: 'foo.csv',
|
||||
path: '/home/foo.csv',
|
||||
} as unknown) as File;
|
||||
|
||||
const mockSelectFile: <P>(container: ReactWrapper<P>, file: File) => Promise<void> = async (
|
||||
container,
|
||||
file
|
||||
) => {
|
||||
const fileChange = container.find('EuiFilePicker').prop('onChange');
|
||||
act(() => {
|
||||
if (fileChange) {
|
||||
fileChange(([file] as unknown) as FormEvent);
|
||||
}
|
||||
});
|
||||
await waitForUpdates(container);
|
||||
expect(
|
||||
container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled')
|
||||
).not.toEqual(true);
|
||||
};
|
||||
|
||||
describe('ValueListsForm', () => {
|
||||
let mockImportList: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockImportList = jest.fn();
|
||||
mockUseImportList.mockImplementation(() => ({
|
||||
start: mockImportList,
|
||||
}));
|
||||
});
|
||||
|
||||
it('disables upload button when file is absent', () => {
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsForm onError={jest.fn()} onSuccess={jest.fn()} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(
|
||||
container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled')
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
it('calls importList when upload is clicked', async () => {
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsForm onError={jest.fn()} onSuccess={jest.fn()} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
await mockSelectFile(container, mockFile);
|
||||
|
||||
container.find('button[data-test-subj="value-lists-form-import-action"]').simulate('click');
|
||||
await waitForUpdates(container);
|
||||
|
||||
expect(mockImportList).toHaveBeenCalledWith(expect.objectContaining({ file: mockFile }));
|
||||
});
|
||||
|
||||
it('calls onError if import fails', async () => {
|
||||
mockUseImportList.mockImplementation(() => ({
|
||||
start: jest.fn(),
|
||||
error: 'whoops',
|
||||
}));
|
||||
|
||||
const onError = jest.fn();
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsForm onError={onError} onSuccess={jest.fn()} />
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(container);
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('whoops');
|
||||
});
|
||||
|
||||
it('calls onSuccess if import succeeds', async () => {
|
||||
mockUseImportList.mockImplementation(() => ({
|
||||
start: jest.fn(),
|
||||
result: { mockResult: true },
|
||||
}));
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsForm onSuccess={onSuccess} onError={jest.fn()} />
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(container);
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith({ mockResult: true });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
* 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, { useCallback, useState, ReactNode, useEffect, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiFilePicker,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiRadioGroup,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { useImportList, ListSchema, Type } from '../../../shared_imports';
|
||||
import * as i18n from './translations';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
|
||||
const InlineRadioGroup = styled(EuiRadioGroup)`
|
||||
display: flex;
|
||||
|
||||
.euiRadioGroup__item + .euiRadioGroup__item {
|
||||
margin: 0 0 0 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ListTypeOptions {
|
||||
id: Type;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
const options: ListTypeOptions[] = [
|
||||
{
|
||||
id: 'keyword',
|
||||
label: i18n.KEYWORDS_RADIO,
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
label: i18n.IP_RADIO,
|
||||
},
|
||||
];
|
||||
|
||||
const defaultListType: Type = 'keyword';
|
||||
|
||||
export interface ValueListsFormProps {
|
||||
onError: (error: Error) => void;
|
||||
onSuccess: (response: ListSchema) => void;
|
||||
}
|
||||
|
||||
export const ValueListsFormComponent: React.FC<ValueListsFormProps> = ({ onError, onSuccess }) => {
|
||||
const ctrl = useRef(new AbortController());
|
||||
const [files, setFiles] = useState<FileList | null>(null);
|
||||
const [type, setType] = useState<Type>(defaultListType);
|
||||
const filePickerRef = useRef<EuiFilePicker | null>(null);
|
||||
const { http } = useKibana().services;
|
||||
const { start: importList, ...importState } = useImportList();
|
||||
|
||||
// EuiRadioGroup's onChange only infers 'string' from our options
|
||||
const handleRadioChange = useCallback((t: string) => setType(t as Type), [setType]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
if (filePickerRef.current?.fileInput) {
|
||||
filePickerRef.current.fileInput.value = '';
|
||||
filePickerRef.current.handleChange();
|
||||
}
|
||||
setFiles(null);
|
||||
setType(defaultListType);
|
||||
}, [setType]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
ctrl.current.abort();
|
||||
}, []);
|
||||
|
||||
const handleSuccess = useCallback(
|
||||
(response: ListSchema) => {
|
||||
resetForm();
|
||||
onSuccess(response);
|
||||
},
|
||||
[resetForm, onSuccess]
|
||||
);
|
||||
const handleError = useCallback(
|
||||
(error: Error) => {
|
||||
onError(error);
|
||||
},
|
||||
[onError]
|
||||
);
|
||||
|
||||
const handleImport = useCallback(() => {
|
||||
if (!importState.loading && files && files.length) {
|
||||
ctrl.current = new AbortController();
|
||||
importList({
|
||||
file: files[0],
|
||||
listId: undefined,
|
||||
http,
|
||||
signal: ctrl.current.signal,
|
||||
type,
|
||||
});
|
||||
}
|
||||
}, [importState.loading, files, importList, http, type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importState.loading && importState.result) {
|
||||
handleSuccess(importState.result);
|
||||
} else if (!importState.loading && importState.error) {
|
||||
handleError(importState.error as Error);
|
||||
}
|
||||
}, [handleError, handleSuccess, importState.error, importState.loading, importState.result]);
|
||||
|
||||
useEffect(() => {
|
||||
return handleCancel;
|
||||
}, [handleCancel]);
|
||||
|
||||
return (
|
||||
<EuiForm>
|
||||
<EuiFormRow label={i18n.FILE_PICKER_LABEL} fullWidth>
|
||||
<EuiFilePicker
|
||||
id="value-list-file-picker"
|
||||
initialPromptText={i18n.FILE_PICKER_PROMPT}
|
||||
ref={filePickerRef}
|
||||
onChange={setFiles}
|
||||
fullWidth={true}
|
||||
isLoading={importState.loading}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow fullWidth>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow label={i18n.LIST_TYPES_RADIO_LABEL}>
|
||||
<InlineRadioGroup
|
||||
options={options}
|
||||
idSelected={type}
|
||||
onChange={handleRadioChange}
|
||||
name="valueListType"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow>
|
||||
<EuiFlexGroup alignItems="flexEnd">
|
||||
<EuiFlexItem>
|
||||
{importState.loading && (
|
||||
<EuiButtonEmpty onClick={handleCancel}>{i18n.CANCEL_BUTTON}</EuiButtonEmpty>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiButton
|
||||
data-test-subj="value-lists-form-import-action"
|
||||
onClick={handleImport}
|
||||
disabled={!files?.length || importState.loading}
|
||||
>
|
||||
{i18n.UPLOAD_BUTTON}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
||||
ValueListsFormComponent.displayName = 'ValueListsFormComponent';
|
||||
|
||||
export const ValueListsForm = React.memo(ValueListsFormComponent);
|
||||
|
||||
ValueListsForm.displayName = 'ValueListsForm';
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* 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 { ValueListsModal } from './modal';
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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 { mount } from 'enzyme';
|
||||
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { ValueListsModal } from './modal';
|
||||
import { waitForUpdates } from '../../../common/utils/test_utils';
|
||||
|
||||
describe('ValueListsModal', () => {
|
||||
it('renders nothing if showModal is false', () => {
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsModal showModal={false} onClose={jest.fn()} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(container.find('EuiModal')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders modal if showModal is true', async () => {
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsModal showModal={true} onClose={jest.fn()} />
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(container);
|
||||
|
||||
expect(container.find('EuiModal')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('calls onClose when modal is closed', async () => {
|
||||
const onClose = jest.fn();
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsModal showModal={true} onClose={onClose} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
container.find('button[data-test-subj="value-lists-modal-close-action"]').simulate('click');
|
||||
|
||||
await waitForUpdates(container);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders ValueListsForm and ValueListsTable', async () => {
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsModal showModal={true} onClose={jest.fn()} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
await waitForUpdates(container);
|
||||
|
||||
expect(container.find('ValueListsForm')).toHaveLength(1);
|
||||
expect(container.find('ValueListsTable')).toHaveLength(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* 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, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiOverlayMask,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import {
|
||||
ListSchema,
|
||||
exportList,
|
||||
useFindLists,
|
||||
useDeleteList,
|
||||
useCursor,
|
||||
} from '../../../shared_imports';
|
||||
import { useToasts, useKibana } from '../../../common/lib/kibana';
|
||||
import { GenericDownloader } from '../../../common/components/generic_downloader';
|
||||
import * as i18n from './translations';
|
||||
import { ValueListsTable } from './table';
|
||||
import { ValueListsForm } from './form';
|
||||
|
||||
interface ValueListsModalProps {
|
||||
onClose: () => void;
|
||||
showModal: boolean;
|
||||
}
|
||||
|
||||
export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({
|
||||
onClose,
|
||||
showModal,
|
||||
}) => {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(5);
|
||||
const [cursor, setCursor] = useCursor({ pageIndex, pageSize });
|
||||
const { http } = useKibana().services;
|
||||
const { start: findLists, ...lists } = useFindLists();
|
||||
const { start: deleteList, result: deleteResult } = useDeleteList();
|
||||
const [exportListId, setExportListId] = useState<string>();
|
||||
const toasts = useToasts();
|
||||
|
||||
const fetchLists = useCallback(() => {
|
||||
findLists({ cursor, http, pageIndex: pageIndex + 1, pageSize });
|
||||
}, [cursor, http, findLists, pageIndex, pageSize]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
({ id }: { id: string }) => {
|
||||
deleteList({ http, id });
|
||||
},
|
||||
[deleteList, http]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (deleteResult != null) {
|
||||
fetchLists();
|
||||
}
|
||||
}, [deleteResult, fetchLists]);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async ({ ids }: { ids: string[] }) =>
|
||||
exportList({ http, listId: ids[0], signal: new AbortController().signal }),
|
||||
[http]
|
||||
);
|
||||
const handleExportClick = useCallback(({ id }: { id: string }) => setExportListId(id), []);
|
||||
const handleExportComplete = useCallback(() => setExportListId(undefined), []);
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
({ page: { index, size } }: { page: { index: number; size: number } }) => {
|
||||
setPageIndex(index);
|
||||
setPageSize(size);
|
||||
},
|
||||
[setPageIndex, setPageSize]
|
||||
);
|
||||
const handleUploadError = useCallback(
|
||||
(error: Error) => {
|
||||
if (error.name !== 'AbortError') {
|
||||
toasts.addError(error, { title: i18n.UPLOAD_ERROR });
|
||||
}
|
||||
},
|
||||
[toasts]
|
||||
);
|
||||
const handleUploadSuccess = useCallback(
|
||||
(response: ListSchema) => {
|
||||
toasts.addSuccess({
|
||||
text: i18n.uploadSuccessMessage(response.name),
|
||||
title: i18n.UPLOAD_SUCCESS_TITLE,
|
||||
});
|
||||
fetchLists();
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[toasts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
fetchLists();
|
||||
}
|
||||
}, [showModal, fetchLists]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lists.loading && lists.result?.cursor) {
|
||||
setCursor(lists.result.cursor);
|
||||
}
|
||||
}, [lists.loading, lists.result, setCursor]);
|
||||
|
||||
if (!showModal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pagination = {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
totalItemCount: lists.result?.total ?? 0,
|
||||
hidePerPageOptions: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiOverlayMask onClick={onClose}>
|
||||
<EuiModal onClose={onClose} maxWidth={800}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>{i18n.MODAL_TITLE}</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody>
|
||||
<ValueListsForm onSuccess={handleUploadSuccess} onError={handleUploadError} />
|
||||
<EuiSpacer />
|
||||
<ValueListsTable
|
||||
lists={lists.result?.data ?? []}
|
||||
loading={lists.loading}
|
||||
onDelete={handleDelete}
|
||||
onExport={handleExportClick}
|
||||
onChange={handleTableChange}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</EuiModalBody>
|
||||
<EuiModalFooter>
|
||||
<EuiButton data-test-subj="value-lists-modal-close-action" onClick={onClose}>
|
||||
{i18n.CLOSE_BUTTON}
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
<GenericDownloader
|
||||
filename={exportListId ?? 'download.txt'}
|
||||
ids={exportListId != null ? [exportListId] : undefined}
|
||||
onExportSuccess={handleExportComplete}
|
||||
onExportFailure={handleExportComplete}
|
||||
exportSelectedData={handleExport}
|
||||
/>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
};
|
||||
|
||||
ValueListsModalComponent.displayName = 'ValueListsModalComponent';
|
||||
|
||||
export const ValueListsModal = React.memo(ValueListsModalComponent);
|
||||
|
||||
ValueListsModal.displayName = 'ValueListsModal';
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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 { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock';
|
||||
import { ListSchema } from '../../../../../lists/common/schemas/response';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { ValueListsTable } from './table';
|
||||
|
||||
describe('ValueListsTable', () => {
|
||||
it('renders a row for each list', () => {
|
||||
const lists = Array<ListSchema>(3).fill(getListResponseMock());
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsTable
|
||||
lists={lists}
|
||||
onChange={jest.fn()}
|
||||
loading={false}
|
||||
onExport={jest.fn()}
|
||||
onDelete={jest.fn()}
|
||||
pagination={{ pageIndex: 0, pageSize: 5, totalItemCount: 10 }}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(container.find('tbody tr')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('calls onChange when pagination is modified', () => {
|
||||
const lists = Array<ListSchema>(6).fill(getListResponseMock());
|
||||
const onChange = jest.fn();
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsTable
|
||||
lists={lists}
|
||||
onChange={onChange}
|
||||
loading={false}
|
||||
onExport={jest.fn()}
|
||||
onDelete={jest.fn()}
|
||||
pagination={{ pageIndex: 0, pageSize: 5, totalItemCount: 10 }}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
container.find('a[data-test-subj="pagination-button-next"]').simulate('click');
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: expect.objectContaining({ index: 1 }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onExport when export is clicked', () => {
|
||||
const lists = Array<ListSchema>(3).fill(getListResponseMock());
|
||||
const onExport = jest.fn();
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsTable
|
||||
lists={lists}
|
||||
onChange={jest.fn()}
|
||||
loading={false}
|
||||
onExport={onExport}
|
||||
onDelete={jest.fn()}
|
||||
pagination={{ pageIndex: 0, pageSize: 5, totalItemCount: 10 }}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
container
|
||||
.find('tbody tr')
|
||||
.first()
|
||||
.find('button[data-test-subj="action-export-value-list"]')
|
||||
.simulate('click');
|
||||
});
|
||||
|
||||
expect(onExport).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' }));
|
||||
});
|
||||
|
||||
it('calls onDelete when delete is clicked', () => {
|
||||
const lists = Array<ListSchema>(3).fill(getListResponseMock());
|
||||
const onDelete = jest.fn();
|
||||
const container = mount(
|
||||
<TestProviders>
|
||||
<ValueListsTable
|
||||
lists={lists}
|
||||
onChange={jest.fn()}
|
||||
loading={false}
|
||||
onExport={jest.fn()}
|
||||
onDelete={onDelete}
|
||||
pagination={{ pageIndex: 0, pageSize: 5, totalItemCount: 10 }}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
container
|
||||
.find('tbody tr')
|
||||
.first()
|
||||
.find('button[data-test-subj="action-delete-value-list"]')
|
||||
.simulate('click');
|
||||
});
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' }));
|
||||
});
|
||||
});
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 { EuiBasicTable, EuiBasicTableProps, EuiText, EuiPanel } from '@elastic/eui';
|
||||
|
||||
import { ListSchema } from '../../../../../lists/common/schemas/response';
|
||||
import { FormattedDate } from '../../../common/components/formatted_date';
|
||||
import * as i18n from './translations';
|
||||
|
||||
type TableProps = EuiBasicTableProps<ListSchema>;
|
||||
type ActionCallback = (item: ListSchema) => void;
|
||||
|
||||
export interface ValueListsTableProps {
|
||||
lists: TableProps['items'];
|
||||
loading: boolean;
|
||||
onChange: TableProps['onChange'];
|
||||
onExport: ActionCallback;
|
||||
onDelete: ActionCallback;
|
||||
pagination: Exclude<TableProps['pagination'], undefined>;
|
||||
}
|
||||
|
||||
const buildColumns = (
|
||||
onExport: ActionCallback,
|
||||
onDelete: ActionCallback
|
||||
): TableProps['columns'] => [
|
||||
{
|
||||
field: 'name',
|
||||
name: i18n.COLUMN_FILE_NAME,
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'created_at',
|
||||
name: i18n.COLUMN_UPLOAD_DATE,
|
||||
/* eslint-disable-next-line react/display-name */
|
||||
render: (value: ListSchema['created_at']) => (
|
||||
<FormattedDate value={value} fieldName="created_at" />
|
||||
),
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
field: 'created_by',
|
||||
name: i18n.COLUMN_CREATED_BY,
|
||||
truncateText: true,
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
name: i18n.COLUMN_ACTIONS,
|
||||
actions: [
|
||||
{
|
||||
name: i18n.ACTION_EXPORT_NAME,
|
||||
description: i18n.ACTION_EXPORT_DESCRIPTION,
|
||||
icon: 'exportAction',
|
||||
type: 'icon',
|
||||
onClick: onExport,
|
||||
'data-test-subj': 'action-export-value-list',
|
||||
},
|
||||
{
|
||||
name: i18n.ACTION_DELETE_NAME,
|
||||
description: i18n.ACTION_DELETE_DESCRIPTION,
|
||||
icon: 'trash',
|
||||
type: 'icon',
|
||||
onClick: onDelete,
|
||||
'data-test-subj': 'action-delete-value-list',
|
||||
},
|
||||
],
|
||||
width: '15%',
|
||||
},
|
||||
];
|
||||
|
||||
export const ValueListsTableComponent: React.FC<ValueListsTableProps> = ({
|
||||
lists,
|
||||
loading,
|
||||
onChange,
|
||||
onExport,
|
||||
onDelete,
|
||||
pagination,
|
||||
}) => {
|
||||
const columns = buildColumns(onExport, onDelete);
|
||||
return (
|
||||
<EuiPanel>
|
||||
<EuiText size="s">
|
||||
<h2>{i18n.TABLE_TITLE}</h2>
|
||||
</EuiText>
|
||||
<EuiBasicTable
|
||||
columns={columns}
|
||||
items={lists}
|
||||
loading={loading}
|
||||
onChange={onChange}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
ValueListsTableComponent.displayName = 'ValueListsTableComponent';
|
||||
|
||||
export const ValueListsTable = React.memo(ValueListsTableComponent);
|
||||
|
||||
ValueListsTable.displayName = 'ValueListsTable';
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const MODAL_TITLE = i18n.translate('xpack.securitySolution.lists.uploadValueListTitle', {
|
||||
defaultMessage: 'Upload value lists',
|
||||
});
|
||||
|
||||
export const FILE_PICKER_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.lists.uploadValueListDescription',
|
||||
{
|
||||
defaultMessage: 'Upload single value lists to use while writing rules or rule exceptions.',
|
||||
}
|
||||
);
|
||||
|
||||
export const FILE_PICKER_PROMPT = i18n.translate(
|
||||
'xpack.securitySolution.lists.uploadValueListPrompt',
|
||||
{
|
||||
defaultMessage: 'Select or drag and drop a file',
|
||||
}
|
||||
);
|
||||
|
||||
export const CLOSE_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.lists.closeValueListsModalTitle',
|
||||
{
|
||||
defaultMessage: 'Close',
|
||||
}
|
||||
);
|
||||
|
||||
export const CANCEL_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.lists.cancelValueListsUploadTitle',
|
||||
{
|
||||
defaultMessage: 'Cancel upload',
|
||||
}
|
||||
);
|
||||
|
||||
export const UPLOAD_BUTTON = i18n.translate('xpack.securitySolution.lists.valueListsUploadButton', {
|
||||
defaultMessage: 'Upload list',
|
||||
});
|
||||
|
||||
export const UPLOAD_SUCCESS_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsUploadSuccessTitle',
|
||||
{
|
||||
defaultMessage: 'Value list uploaded',
|
||||
}
|
||||
);
|
||||
|
||||
export const UPLOAD_ERROR = i18n.translate('xpack.securitySolution.lists.valueListsUploadError', {
|
||||
defaultMessage: 'There was an error uploading the value list.',
|
||||
});
|
||||
|
||||
export const uploadSuccessMessage = (fileName: string) =>
|
||||
i18n.translate('xpack.securitySolution.lists.valueListsUploadSuccess', {
|
||||
defaultMessage: "Value list '{fileName}' was uploaded",
|
||||
values: { fileName },
|
||||
});
|
||||
|
||||
export const COLUMN_FILE_NAME = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsTable.fileNameColumn',
|
||||
{
|
||||
defaultMessage: 'Filename',
|
||||
}
|
||||
);
|
||||
|
||||
export const COLUMN_UPLOAD_DATE = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsTable.uploadDateColumn',
|
||||
{
|
||||
defaultMessage: 'Upload Date',
|
||||
}
|
||||
);
|
||||
|
||||
export const COLUMN_CREATED_BY = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsTable.createdByColumn',
|
||||
{
|
||||
defaultMessage: 'Created by',
|
||||
}
|
||||
);
|
||||
|
||||
export const COLUMN_ACTIONS = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsTable.actionsColumn',
|
||||
{
|
||||
defaultMessage: 'Actions',
|
||||
}
|
||||
);
|
||||
|
||||
export const ACTION_EXPORT_NAME = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsTable.exportActionName',
|
||||
{
|
||||
defaultMessage: 'Export',
|
||||
}
|
||||
);
|
||||
|
||||
export const ACTION_EXPORT_DESCRIPTION = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsTable.exportActionDescription',
|
||||
{
|
||||
defaultMessage: 'Export value list',
|
||||
}
|
||||
);
|
||||
|
||||
export const ACTION_DELETE_NAME = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsTable.deleteActionName',
|
||||
{
|
||||
defaultMessage: 'Remove',
|
||||
}
|
||||
);
|
||||
|
||||
export const ACTION_DELETE_DESCRIPTION = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsTable.deleteActionDescription',
|
||||
{
|
||||
defaultMessage: 'Remove value list',
|
||||
}
|
||||
);
|
||||
|
||||
export const TABLE_TITLE = i18n.translate('xpack.securitySolution.lists.valueListsTable.title', {
|
||||
defaultMessage: 'Value lists',
|
||||
});
|
||||
|
||||
export const LIST_TYPES_RADIO_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsForm.listTypesRadioLabel',
|
||||
{
|
||||
defaultMessage: 'Type of value list',
|
||||
}
|
||||
);
|
||||
|
||||
export const IP_RADIO = i18n.translate('xpack.securitySolution.lists.valueListsForm.ipRadioLabel', {
|
||||
defaultMessage: 'IP addresses',
|
||||
});
|
||||
|
||||
export const KEYWORDS_RADIO = i18n.translate(
|
||||
'xpack.securitySolution.lists.valueListsForm.keywordsRadioLabel',
|
||||
{
|
||||
defaultMessage: 'Keywords',
|
||||
}
|
||||
);
|
|
@ -22,6 +22,7 @@ import { useUserInfo } from '../../../components/user_info';
|
|||
import { AllRules } from './all';
|
||||
import { ImportDataModal } from '../../../../common/components/import_data_modal';
|
||||
import { ReadOnlyCallOut } from '../../../components/rules/read_only_callout';
|
||||
import { ValueListsModal } from '../../../components/value_lists_management_modal';
|
||||
import { UpdatePrePackagedRulesCallOut } from '../../../components/rules/pre_packaged_rules/update_callout';
|
||||
import { getPrePackagedRuleStatus, redirectToDetections, userHasNoPermissions } from './helpers';
|
||||
import * as i18n from './translations';
|
||||
|
@ -34,6 +35,9 @@ type Func = (refreshPrePackagedRule?: boolean) => void;
|
|||
const RulesPageComponent: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [isValueListsModalShown, setIsValueListsModalShown] = useState(false);
|
||||
const showValueListsModal = useCallback(() => setIsValueListsModalShown(true), []);
|
||||
const hideValueListsModal = useCallback(() => setIsValueListsModalShown(false), []);
|
||||
const refreshRulesData = useRef<null | Func>(null);
|
||||
const {
|
||||
loading: userInfoLoading,
|
||||
|
@ -117,6 +121,7 @@ const RulesPageComponent: React.FC = () => {
|
|||
return (
|
||||
<>
|
||||
{userHasNoPermissions(canUserCRUD) && <ReadOnlyCallOut />}
|
||||
<ValueListsModal showModal={isValueListsModalShown} onClose={hideValueListsModal} />
|
||||
<ImportDataModal
|
||||
checkBoxLabel={i18n.OVERWRITE_WITH_SAME_NAME}
|
||||
closeModal={() => setShowImportModal(false)}
|
||||
|
@ -167,6 +172,15 @@ const RulesPageComponent: React.FC = () => {
|
|||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
iconType="importAction"
|
||||
isDisabled={userHasNoPermissions(canUserCRUD) || loading}
|
||||
onClick={showValueListsModal}
|
||||
>
|
||||
{i18n.UPLOAD_VALUE_LISTS}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
iconType="importAction"
|
||||
|
|
|
@ -20,6 +20,13 @@ export const IMPORT_RULE = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const UPLOAD_VALUE_LISTS = i18n.translate(
|
||||
'xpack.securitySolution.lists.detectionEngine.rules.uploadValueListsButton',
|
||||
{
|
||||
defaultMessage: 'Upload value lists',
|
||||
}
|
||||
);
|
||||
|
||||
export const ADD_NEW_RULE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.addNewRuleTitle',
|
||||
{
|
||||
|
|
|
@ -9,6 +9,7 @@ import React from 'react';
|
|||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import '../../common/mock/match_media';
|
||||
import { waitForUpdates } from '../../common/utils/test_utils';
|
||||
import { TestProviders } from '../../common/mock';
|
||||
import { useWithSource } from '../../common/containers/source';
|
||||
import {
|
||||
|
@ -61,7 +62,7 @@ describe('Overview', () => {
|
|||
mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false));
|
||||
});
|
||||
|
||||
it('renders the Setup Instructions text', () => {
|
||||
it('renders the Setup Instructions text', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<MemoryRouter>
|
||||
|
@ -69,10 +70,11 @@ describe('Overview', () => {
|
|||
</MemoryRouter>
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(wrapper);
|
||||
expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show Endpoint get ready button when ingest is not enabled', () => {
|
||||
it('does not show Endpoint get ready button when ingest is not enabled', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<MemoryRouter>
|
||||
|
@ -80,10 +82,11 @@ describe('Overview', () => {
|
|||
</MemoryRouter>
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(wrapper);
|
||||
expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows Endpoint get ready button when ingest is enabled', () => {
|
||||
it('shows Endpoint get ready button when ingest is enabled', async () => {
|
||||
(useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true });
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
|
@ -92,11 +95,12 @@ describe('Overview', () => {
|
|||
</MemoryRouter>
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(wrapper);
|
||||
expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('it DOES NOT render the Getting started text when an index is available', () => {
|
||||
it('it DOES NOT render the Getting started text when an index is available', async () => {
|
||||
(useWithSource as jest.Mock).mockReturnValue({
|
||||
indicesExist: true,
|
||||
indexPattern: {},
|
||||
|
@ -113,10 +117,12 @@ describe('Overview', () => {
|
|||
</MemoryRouter>
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(wrapper);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', () => {
|
||||
test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', async () => {
|
||||
(useWithSource as jest.Mock).mockReturnValueOnce({
|
||||
indicesExist: true,
|
||||
indexPattern: {},
|
||||
|
@ -138,10 +144,12 @@ describe('Overview', () => {
|
|||
</MemoryRouter>
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(wrapper);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', () => {
|
||||
test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', async () => {
|
||||
(useWithSource as jest.Mock).mockReturnValueOnce({
|
||||
indicesExist: true,
|
||||
indexPattern: {},
|
||||
|
@ -163,10 +171,12 @@ describe('Overview', () => {
|
|||
</MemoryRouter>
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(wrapper);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', () => {
|
||||
test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', async () => {
|
||||
(useWithSource as jest.Mock).mockReturnValue({
|
||||
indicesExist: true,
|
||||
indexPattern: {},
|
||||
|
@ -183,10 +193,12 @@ describe('Overview', () => {
|
|||
</MemoryRouter>
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(wrapper);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', () => {
|
||||
test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', async () => {
|
||||
(useWithSource as jest.Mock).mockReturnValue({
|
||||
indicesExist: true,
|
||||
indexPattern: {},
|
||||
|
@ -206,7 +218,7 @@ describe('Overview', () => {
|
|||
expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('it does NOT render the Endpoint banner when Ingest is NOT available', () => {
|
||||
test('it does NOT render the Endpoint banner when Ingest is NOT available', async () => {
|
||||
(useWithSource as jest.Mock).mockReturnValue({
|
||||
indicesExist: true,
|
||||
indexPattern: {},
|
||||
|
@ -223,6 +235,8 @@ describe('Overview', () => {
|
|||
</MemoryRouter>
|
||||
</TestProviders>
|
||||
);
|
||||
await waitForUpdates(wrapper);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,12 +27,16 @@ export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/for
|
|||
export { ERROR_CODE } from '../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types';
|
||||
|
||||
export {
|
||||
exportList,
|
||||
useIsMounted,
|
||||
useCursor,
|
||||
useApi,
|
||||
useExceptionList,
|
||||
usePersistExceptionItem,
|
||||
usePersistExceptionList,
|
||||
useFindLists,
|
||||
useDeleteList,
|
||||
useImportList,
|
||||
useCreateListIndex,
|
||||
useReadListIndex,
|
||||
useReadListPrivileges,
|
||||
|
|
Loading…
Reference in a new issue