[Security Solution][Detections] Adds loading states to export/delete on modal (#72562)

* Add loading spinners to Value Lists modal

While export or a delete is pending, we display a loading spinner
instead of the button that was clicked.

Since state is controlled in the parent, we must pass this additional
state in the same way; the table component simply reacts to this state.

* Fix bug with useAsync and multiple calls

Multiple calls to start() would not previously reset the hook's state,
where useEffect on the hook's state would fire improperly as subsequent
calls would not travel the same undefined -> result path.

* Fix style of loading spinner

This fits the size of the button it's replacing, so no shifting occurs
when replacing elements.

* Better styling of spinner

Keep it roughly the same size as the icons themselves, and fill the
space with margin.

* Fix circular dependency in value lists modal

Moves our shared types into a separate module to prevent a circular
dependency.
This commit is contained in:
Ryland Herrick 2020-07-21 15:26:51 -05:00 committed by GitHub
parent 33a9604800
commit 8d5a5d0860
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 185 additions and 67 deletions

View file

@ -95,4 +95,36 @@ describe('useAsync', () => {
expect(result.current.loading).toBe(false);
});
it('multiple start calls reset state', async () => {
let resolve: (result: string) => void;
fn.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve)));
const { result, waitForNextUpdate } = renderHook(() => useAsync(fn));
act(() => {
result.current.start(args);
});
expect(result.current.loading).toBe(true);
act(() => resolve('result'));
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.result).toBe('result');
act(() => {
result.current.start(args);
});
expect(result.current.loading).toBe(true);
expect(result.current.result).toBe(undefined);
act(() => resolve('result'));
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.result).toBe('result');
});
});

View file

@ -32,6 +32,8 @@ export const useAsync = <Args extends unknown[], Result>(
const start = useCallback(
(...args: Args) => {
setLoading(true);
setResult(undefined);
setError(undefined);
fn(...args)
.then((r) => isMounted() && setResult(r))
.catch((e) => isMounted() && setError(e))

View file

@ -46,6 +46,7 @@ export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({
const { start: findLists, ...lists } = useFindLists();
const { start: deleteList, result: deleteResult } = useDeleteList();
const [exportListId, setExportListId] = useState<string>();
const [deletingListIds, setDeletingListIds] = useState<string[]>([]);
const { addError, addSuccess } = useAppToasts();
const fetchLists = useCallback(() => {
@ -54,16 +55,18 @@ export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({
const handleDelete = useCallback(
({ id }: { id: string }) => {
setDeletingListIds([...deletingListIds, id]);
deleteList({ http, id });
},
[deleteList, http]
[deleteList, deletingListIds, http]
);
useEffect(() => {
if (deleteResult != null) {
if (deleteResult != null && deletingListIds.length > 0) {
setDeletingListIds([...deletingListIds.filter((id) => id !== deleteResult.id)]);
fetchLists();
}
}, [deleteResult, fetchLists]);
}, [deleteResult, deletingListIds, fetchLists]);
const handleExport = useCallback(
async ({ ids }: { ids: string[] }) =>
@ -116,6 +119,12 @@ export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({
return null;
}
const tableItems = (lists.result?.data ?? []).map((item) => ({
...item,
isExporting: item.id === exportListId,
isDeleting: deletingListIds.includes(item.id),
}));
const pagination = {
pageIndex,
pageSize,
@ -133,7 +142,7 @@ export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({
<ValueListsForm onSuccess={handleUploadSuccess} onError={handleUploadError} />
<EuiSpacer />
<ValueListsTable
lists={lists.result?.data ?? []}
items={tableItems}
loading={lists.loading}
onDelete={handleDelete}
onExport={handleExportClick}

View file

@ -12,14 +12,23 @@ import { getListResponseMock } from '../../../../../lists/common/schemas/respons
import { ListSchema } from '../../../../../lists/common/schemas/response';
import { TestProviders } from '../../../common/mock';
import { ValueListsTable } from './table';
import { TableItem } from './types';
const buildItems = (lists: ListSchema[]): TableItem[] =>
lists.map((list) => ({
...list,
isDeleting: false,
isExporting: false,
}));
describe('ValueListsTable', () => {
it('renders a row for each list', () => {
const lists = Array<ListSchema>(3).fill(getListResponseMock());
const items = buildItems(lists);
const container = mount(
<TestProviders>
<ValueListsTable
lists={lists}
items={items}
onChange={jest.fn()}
loading={false}
onExport={jest.fn()}
@ -34,11 +43,12 @@ describe('ValueListsTable', () => {
it('calls onChange when pagination is modified', () => {
const lists = Array<ListSchema>(6).fill(getListResponseMock());
const items = buildItems(lists);
const onChange = jest.fn();
const container = mount(
<TestProviders>
<ValueListsTable
lists={lists}
items={items}
onChange={onChange}
loading={false}
onExport={jest.fn()}
@ -59,11 +69,12 @@ describe('ValueListsTable', () => {
it('calls onExport when export is clicked', () => {
const lists = Array<ListSchema>(3).fill(getListResponseMock());
const items = buildItems(lists);
const onExport = jest.fn();
const container = mount(
<TestProviders>
<ValueListsTable
lists={lists}
items={items}
onChange={jest.fn()}
loading={false}
onExport={onExport}
@ -86,11 +97,12 @@ describe('ValueListsTable', () => {
it('calls onDelete when delete is clicked', () => {
const lists = Array<ListSchema>(3).fill(getListResponseMock());
const items = buildItems(lists);
const onDelete = jest.fn();
const container = mount(
<TestProviders>
<ValueListsTable
lists={lists}
items={items}
onChange={jest.fn()}
loading={false}
onExport={jest.fn()}

View file

@ -5,74 +5,23 @@
*/
import React from 'react';
import { EuiBasicTable, EuiBasicTableProps, EuiText, EuiPanel } from '@elastic/eui';
import { EuiBasicTable, 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;
import { buildColumns } from './table_helpers';
import { TableProps, TableItemCallback } from './types';
export interface ValueListsTableProps {
lists: TableProps['items'];
items: TableProps['items'];
loading: boolean;
onChange: TableProps['onChange'];
onExport: ActionCallback;
onDelete: ActionCallback;
onExport: TableItemCallback;
onDelete: TableItemCallback;
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,
items,
loading,
onChange,
onExport,
@ -87,7 +36,7 @@ export const ValueListsTableComponent: React.FC<ValueListsTableProps> = ({
</EuiText>
<EuiBasicTable
columns={columns}
items={lists}
items={items}
loading={loading}
onChange={onChange}
pagination={pagination}

View file

@ -0,0 +1,98 @@
/*
* 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.
*/
/* eslint-disable react/display-name */
import React from 'react';
import styled from 'styled-components';
import { EuiButtonIcon, IconType, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
import { ListSchema } from '../../../../../lists/common/schemas/response';
import { FormattedDate } from '../../../common/components/formatted_date';
import * as i18n from './translations';
import { TableItem, TableItemCallback, TableProps } from './types';
const AlignedSpinner = styled(EuiLoadingSpinner)`
margin: ${({ theme }) => theme.eui.euiSizeXS};
vertical-align: middle;
`;
const ActionButton: React.FC<{
content: string;
dataTestSubj: string;
icon: IconType;
isLoading: boolean;
item: TableItem;
onClick: TableItemCallback;
}> = ({ content, dataTestSubj, icon, item, onClick, isLoading }) => (
<EuiToolTip content={content}>
{isLoading ? (
<AlignedSpinner size="m" />
) : (
<EuiButtonIcon
aria-label={content}
data-test-subj={dataTestSubj}
iconType={icon}
onClick={() => onClick(item)}
/>
)}
</EuiToolTip>
);
export const buildColumns = (
onExport: TableItemCallback,
onDelete: TableItemCallback
): TableProps['columns'] => [
{
field: 'name',
name: i18n.COLUMN_FILE_NAME,
truncateText: true,
},
{
field: 'created_at',
name: i18n.COLUMN_UPLOAD_DATE,
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: [
{
render: (item) => (
<ActionButton
content={i18n.ACTION_EXPORT_DESCRIPTION}
dataTestSubj="action-export-value-list"
icon="exportAction"
item={item}
onClick={onExport}
isLoading={item.isExporting}
/>
),
},
{
render: (item) => (
<ActionButton
content={i18n.ACTION_DELETE_DESCRIPTION}
dataTestSubj="action-delete-value-list"
icon="trash"
item={item}
onClick={onDelete}
isLoading={item.isDeleting}
/>
),
},
],
width: '15%',
},
];

View file

@ -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 { EuiBasicTableProps } from '@elastic/eui';
import { ListSchema } from '../../../../../lists/common/schemas/response';
export interface TableItem extends ListSchema {
isDeleting: boolean;
isExporting: boolean;
}
export type TableProps = EuiBasicTableProps<TableItem>;
export type TableItemCallback = (item: TableItem) => void;