Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SIEM][Detections] Value Lists Management Modal #67068

Merged
merged 31 commits into from
Jul 14, 2020
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f807600
Add Frontend components for Value Lists Management Modal
rylnd Jun 26, 2020
cc34d86
Update value list components to use newest Lists API
rylnd Jun 27, 2020
af6a86e
Close modal on outside click
rylnd Jun 27, 2020
bf7fb72
Add hook for using a cursor with paged API calls.
rylnd Jun 29, 2020
38fa27c
Better implementation of useCursor
rylnd Jun 29, 2020
b606f57
Fixes useCursor hook functionality
rylnd Jun 29, 2020
ce1edb2
Add cursor to lists query
rylnd Jun 29, 2020
e39116c
Do not validate response of export
rylnd Jun 29, 2020
df70e5c
Fix double callback post-import
rylnd Jun 29, 2020
344b81c
Update ValueListsForm to manually abort import request
rylnd Jun 30, 2020
23fd1ee
Default modal table to five rows
rylnd Jul 1, 2020
e3670ea
Update translation keys following plugin rename
rylnd Jul 1, 2020
c36e959
Try to fit table contents on a single row
rylnd Jul 1, 2020
312fbde
Add helper function to prevent tests from logging errors
rylnd Jul 2, 2020
9587b7e
Add jest tests for our form, table, and modal components
rylnd Jul 2, 2020
ab66483
Fix translation conflict
rylnd Jul 2, 2020
7789c91
Merge branch 'master' into value_lists_ui
rylnd Jul 2, 2020
f16cf4e
Add more waitForUpdates to new overview page tests
rylnd Jul 2, 2020
5be0e87
Fix bad merge resolution
rylnd Jul 2, 2020
0e4fee7
Make cursor an optional parameter to findLists
rylnd Jul 2, 2020
728b272
Tweaking Table column sizes
rylnd Jul 2, 2020
0abbfc4
Fix bug where onSuccess is called upon pagination change
rylnd Jul 3, 2020
e0cb6a5
Merge branch 'master' into value_lists_ui
elasticmachine Jul 6, 2020
18459c1
Merge branch 'master' into value_lists_ui
rylnd Jul 8, 2020
551a435
Merge branch 'master' into value_lists_ui
rylnd Jul 13, 2020
8fb7e4c
Fix failing test
rylnd Jul 13, 2020
e496614
Hide page size options on ValueLists modal table
rylnd Jul 13, 2020
3f12007
Update error callbacks now that we have Errors
rylnd Jul 13, 2020
bc984c4
Synchronize delete with the subsequent fetch
rylnd Jul 13, 2020
66d8241
Cast our unknown error to an Error
rylnd Jul 14, 2020
59e3b02
Import lists code from our new, standardized modules
rylnd Jul 14, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 43 additions & 0 deletions x-pack/plugins/lists/public/common/hooks/use_cursor.ts
Original file line number Diff line number Diff line change
@@ -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];
};
3 changes: 2 additions & 1 deletion x-pack/plugins/lists/public/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,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 { useExportList } from './lists/hooks/use_export_list';
export { exportList } from './lists/api';
export { useCursor } from './common/hooks/use_cursor';
export {
ExceptionList,
ExceptionIdentifiers,
Expand Down
36 changes: 15 additions & 21 deletions x-pack/plugins/lists/public/lists/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,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,
Expand All @@ -114,14 +115,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({
Expand All @@ -135,7 +143,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);

Expand Down Expand Up @@ -260,7 +271,7 @@ describe('Value Lists API', () => {

describe('exportList', () => {
beforeEach(() => {
httpMock.fetch.mockResolvedValue(getListResponseMock());
httpMock.fetch.mockResolvedValue({});
});

it('POSTs to the export endpoint', async () => {
Expand Down Expand Up @@ -310,22 +321,5 @@ describe('Value Lists API', () => {
).rejects.toEqual('Invalid value "23" supplied to "list_id"');
expect(httpMock.fetch).not.toHaveBeenCalled();
});

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 };
httpMock.fetch.mockResolvedValue(badResponse);

await expect(
exportList({
http: httpMock,
...payload,
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "undefined" supplied to "id"');
});
});
});
7 changes: 4 additions & 3 deletions x-pack/plugins/lists/public/lists/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,17 @@ const findLists = async ({
};

const findListsWithValidation = async ({
cursor,
http,
pageIndex,
pageSize,
signal,
}: 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 }), String)),
Expand Down Expand Up @@ -166,7 +168,6 @@ const exportListWithValidation = async ({
{ list_id: listId },
(payload) => fromEither(validateEither(exportListItemQuerySchema, payload)),
chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), String)),
chain((response) => fromEither(validateEither(listSchema, response))),
flow(toPromise)
);

Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/lists/public/lists/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 '../../../lists_plugin_deps';

jest.mock('../../../lists_plugin_deps');
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 });
});
});
Loading