Skip to content

Commit 1f64c32

Browse files
rylndelasticmachine
andcommitted
[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 enzymejs/enzyme#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>
1 parent 16dee4a commit 1f64c32

File tree

22 files changed

+1157
-71
lines changed

22 files changed

+1157
-71
lines changed

x-pack/plugins/lists/common/shared_exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,5 @@ export {
3939
entriesList,
4040
namespaceType,
4141
ExceptionListType,
42+
Type,
4243
} from './schemas';
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { act, renderHook } from '@testing-library/react-hooks';
8+
9+
import { UseCursorProps, useCursor } from './use_cursor';
10+
11+
describe('useCursor', () => {
12+
it('returns undefined cursor if no values have been set', () => {
13+
const { result } = renderHook((props: UseCursorProps) => useCursor(props), {
14+
initialProps: { pageIndex: 0, pageSize: 0 },
15+
});
16+
17+
expect(result.current[0]).toBeUndefined();
18+
});
19+
20+
it('retrieves a cursor for the next page of a given page size', () => {
21+
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
22+
initialProps: { pageIndex: 0, pageSize: 0 },
23+
});
24+
rerender({ pageIndex: 1, pageSize: 1 });
25+
act(() => {
26+
result.current[1]('new_cursor');
27+
});
28+
29+
expect(result.current[0]).toBeUndefined();
30+
31+
rerender({ pageIndex: 2, pageSize: 1 });
32+
expect(result.current[0]).toEqual('new_cursor');
33+
});
34+
35+
it('returns undefined cursor for an unknown search', () => {
36+
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
37+
initialProps: { pageIndex: 0, pageSize: 0 },
38+
});
39+
act(() => {
40+
result.current[1]('new_cursor');
41+
});
42+
43+
rerender({ pageIndex: 1, pageSize: 2 });
44+
expect(result.current[0]).toBeUndefined();
45+
});
46+
47+
it('remembers cursor through rerenders', () => {
48+
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
49+
initialProps: { pageIndex: 0, pageSize: 0 },
50+
});
51+
52+
rerender({ pageIndex: 1, pageSize: 1 });
53+
act(() => {
54+
result.current[1]('new_cursor');
55+
});
56+
57+
rerender({ pageIndex: 2, pageSize: 1 });
58+
expect(result.current[0]).toEqual('new_cursor');
59+
60+
rerender({ pageIndex: 0, pageSize: 0 });
61+
expect(result.current[0]).toBeUndefined();
62+
63+
rerender({ pageIndex: 2, pageSize: 1 });
64+
expect(result.current[0]).toEqual('new_cursor');
65+
});
66+
67+
it('remembers multiple cursors', () => {
68+
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
69+
initialProps: { pageIndex: 0, pageSize: 0 },
70+
});
71+
72+
rerender({ pageIndex: 1, pageSize: 1 });
73+
act(() => {
74+
result.current[1]('new_cursor');
75+
});
76+
rerender({ pageIndex: 2, pageSize: 2 });
77+
act(() => {
78+
result.current[1]('another_cursor');
79+
});
80+
81+
rerender({ pageIndex: 2, pageSize: 1 });
82+
expect(result.current[0]).toEqual('new_cursor');
83+
84+
rerender({ pageIndex: 3, pageSize: 2 });
85+
expect(result.current[0]).toEqual('another_cursor');
86+
});
87+
88+
it('returns the "nearest" cursor for the given page size', () => {
89+
const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
90+
initialProps: { pageIndex: 0, pageSize: 0 },
91+
});
92+
93+
rerender({ pageIndex: 1, pageSize: 2 });
94+
act(() => {
95+
result.current[1]('cursor1');
96+
});
97+
rerender({ pageIndex: 2, pageSize: 2 });
98+
act(() => {
99+
result.current[1]('cursor2');
100+
});
101+
rerender({ pageIndex: 3, pageSize: 2 });
102+
act(() => {
103+
result.current[1]('cursor3');
104+
});
105+
106+
rerender({ pageIndex: 2, pageSize: 2 });
107+
expect(result.current[0]).toEqual('cursor1');
108+
109+
rerender({ pageIndex: 3, pageSize: 2 });
110+
expect(result.current[0]).toEqual('cursor2');
111+
112+
rerender({ pageIndex: 4, pageSize: 2 });
113+
expect(result.current[0]).toEqual('cursor3');
114+
115+
rerender({ pageIndex: 6, pageSize: 2 });
116+
expect(result.current[0]).toEqual('cursor3');
117+
});
118+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { useCallback, useState } from 'react';
8+
9+
export interface UseCursorProps {
10+
pageIndex: number;
11+
pageSize: number;
12+
}
13+
type Cursor = string | undefined;
14+
type SetCursor = (cursor: Cursor) => void;
15+
type UseCursor = (props: UseCursorProps) => [Cursor, SetCursor];
16+
17+
const hash = (props: UseCursorProps): string => JSON.stringify(props);
18+
19+
export const useCursor: UseCursor = ({ pageIndex, pageSize }) => {
20+
const [cache, setCache] = useState<Record<string, Cursor>>({});
21+
22+
const setCursor = useCallback<SetCursor>(
23+
(cursor) => {
24+
setCache({
25+
...cache,
26+
[hash({ pageIndex: pageIndex + 1, pageSize })]: cursor,
27+
});
28+
},
29+
// eslint-disable-next-line react-hooks/exhaustive-deps
30+
[pageIndex, pageSize]
31+
);
32+
33+
let cursor: Cursor;
34+
for (let i = pageIndex; i >= 0; i--) {
35+
const currentProps = { pageIndex: i, pageSize };
36+
cursor = cache[hash(currentProps)];
37+
if (cursor) {
38+
break;
39+
}
40+
}
41+
42+
return [cursor, setCursor];
43+
};

x-pack/plugins/lists/public/lists/api.test.ts

Lines changed: 47 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ describe('Value Lists API', () => {
114114
it('sends pagination as query parameters', async () => {
115115
const abortCtrl = new AbortController();
116116
await findLists({
117+
cursor: 'cursor',
117118
http: httpMock,
118119
pageIndex: 1,
119120
pageSize: 10,
@@ -123,14 +124,21 @@ describe('Value Lists API', () => {
123124
expect(httpMock.fetch).toHaveBeenCalledWith(
124125
'/api/lists/_find',
125126
expect.objectContaining({
126-
query: { page: 1, per_page: 10 },
127+
query: {
128+
cursor: 'cursor',
129+
page: 1,
130+
per_page: 10,
131+
},
127132
})
128133
);
129134
});
130135

131136
it('rejects with an error if request payload is invalid (and does not make API call)', async () => {
132137
const abortCtrl = new AbortController();
133-
const payload: ApiPayload<FindListsParams> = { pageIndex: 10, pageSize: 0 };
138+
const payload: ApiPayload<FindListsParams> = {
139+
pageIndex: 10,
140+
pageSize: 0,
141+
};
134142

135143
await expect(
136144
findLists({
@@ -144,7 +152,10 @@ describe('Value Lists API', () => {
144152

145153
it('rejects with an error if response payload is invalid', async () => {
146154
const abortCtrl = new AbortController();
147-
const payload: ApiPayload<FindListsParams> = { pageIndex: 1, pageSize: 10 };
155+
const payload: ApiPayload<FindListsParams> = {
156+
pageIndex: 1,
157+
pageSize: 10,
158+
};
148159
const badResponse = { ...getFoundListSchemaMock(), cursor: undefined };
149160
httpMock.fetch.mockResolvedValue(badResponse);
150161

@@ -269,7 +280,7 @@ describe('Value Lists API', () => {
269280

270281
describe('exportList', () => {
271282
beforeEach(() => {
272-
httpMock.fetch.mockResolvedValue(getListResponseMock());
283+
httpMock.fetch.mockResolvedValue({});
273284
});
274285

275286
it('POSTs to the export endpoint', async () => {
@@ -319,66 +330,49 @@ describe('Value Lists API', () => {
319330
).rejects.toEqual(new Error('Invalid value "23" supplied to "list_id"'));
320331
expect(httpMock.fetch).not.toHaveBeenCalled();
321332
});
333+
});
334+
335+
describe('readListIndex', () => {
336+
beforeEach(() => {
337+
httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock());
338+
});
322339

323-
it('rejects with an error if response payload is invalid', async () => {
340+
it('GETs the list index', async () => {
324341
const abortCtrl = new AbortController();
325-
const payload: ApiPayload<ExportListParams> = {
326-
listId: 'list-id',
327-
};
328-
const badResponse = { ...getListResponseMock(), id: undefined };
329-
httpMock.fetch.mockResolvedValue(badResponse);
342+
await readListIndex({
343+
http: httpMock,
344+
signal: abortCtrl.signal,
345+
});
330346

331-
await expect(
332-
exportList({
333-
http: httpMock,
334-
...payload,
335-
signal: abortCtrl.signal,
347+
expect(httpMock.fetch).toHaveBeenCalledWith(
348+
'/api/lists/index',
349+
expect.objectContaining({
350+
method: 'GET',
336351
})
337-
).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"'));
352+
);
338353
});
339354

340-
describe('readListIndex', () => {
341-
beforeEach(() => {
342-
httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock());
355+
it('returns the response when valid', async () => {
356+
const abortCtrl = new AbortController();
357+
const result = await readListIndex({
358+
http: httpMock,
359+
signal: abortCtrl.signal,
343360
});
344361

345-
it('GETs the list index', async () => {
346-
const abortCtrl = new AbortController();
347-
await readListIndex({
348-
http: httpMock,
349-
signal: abortCtrl.signal,
350-
});
351-
352-
expect(httpMock.fetch).toHaveBeenCalledWith(
353-
'/api/lists/index',
354-
expect.objectContaining({
355-
method: 'GET',
356-
})
357-
);
358-
});
362+
expect(result).toEqual(getListItemIndexExistSchemaResponseMock());
363+
});
364+
365+
it('rejects with an error if response payload is invalid', async () => {
366+
const abortCtrl = new AbortController();
367+
const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined };
368+
httpMock.fetch.mockResolvedValue(badResponse);
359369

360-
it('returns the response when valid', async () => {
361-
const abortCtrl = new AbortController();
362-
const result = await readListIndex({
370+
await expect(
371+
readListIndex({
363372
http: httpMock,
364373
signal: abortCtrl.signal,
365-
});
366-
367-
expect(result).toEqual(getListItemIndexExistSchemaResponseMock());
368-
});
369-
370-
it('rejects with an error if response payload is invalid', async () => {
371-
const abortCtrl = new AbortController();
372-
const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined };
373-
httpMock.fetch.mockResolvedValue(badResponse);
374-
375-
await expect(
376-
readListIndex({
377-
http: httpMock,
378-
signal: abortCtrl.signal,
379-
})
380-
).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"'));
381-
});
374+
})
375+
).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"'));
382376
});
383377
});
384378

x-pack/plugins/lists/public/lists/api.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,17 @@ const findLists = async ({
5959
};
6060

6161
const findListsWithValidation = async ({
62+
cursor,
6263
http,
6364
pageIndex,
6465
pageSize,
6566
signal,
6667
}: FindListsParams): Promise<FoundListSchema> =>
6768
pipe(
6869
{
69-
page: String(pageIndex),
70-
per_page: String(pageSize),
70+
cursor: cursor?.toString(),
71+
page: pageIndex?.toString(),
72+
per_page: pageSize?.toString(),
7173
},
7274
(payload) => fromEither(validateEither(findListSchema, payload)),
7375
chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), toError)),
@@ -170,7 +172,6 @@ const exportListWithValidation = async ({
170172
{ list_id: listId },
171173
(payload) => fromEither(validateEither(exportListItemQuerySchema, payload)),
172174
chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), toError)),
173-
chain((response) => fromEither(validateEither(listSchema, response))),
174175
flow(toPromise)
175176
);
176177

x-pack/plugins/lists/public/lists/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface ApiParams {
1414
export type ApiPayload<T extends ApiParams> = Omit<T, 'http' | 'signal'>;
1515

1616
export interface FindListsParams extends ApiParams {
17+
cursor?: string | undefined;
1718
pageSize: number | undefined;
1819
pageIndex: number | undefined;
1920
}

x-pack/plugins/lists/public/shared_exports.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export { useExceptionList } from './exceptions/hooks/use_exception_list';
1313
export { useFindLists } from './lists/hooks/use_find_lists';
1414
export { useImportList } from './lists/hooks/use_import_list';
1515
export { useDeleteList } from './lists/hooks/use_delete_list';
16+
export { exportList } from './lists/api';
17+
export { useCursor } from './common/hooks/use_cursor';
1618
export { useExportList } from './lists/hooks/use_export_list';
1719
export { useReadListIndex } from './lists/hooks/use_read_list_index';
1820
export { useCreateListIndex } from './lists/hooks/use_create_list_index';

x-pack/plugins/security_solution/common/shared_imports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,5 @@ export {
3939
entriesList,
4040
namespaceType,
4141
ExceptionListType,
42+
Type,
4243
} from '../../lists/common';

0 commit comments

Comments
 (0)