[Investigation App] add basic table (#192016)

## Summary

Relates to https://github.com/elastic/kibana/issues/191549

Adds basic investigation list page with pagination and delete action.

<img width="1478" alt="Screenshot 2024-09-06 at 9 48 37 AM"
src="https://github.com/user-attachments/assets/ce20db46-8da1-4f35-b374-d64c6e46e451">
This commit is contained in:
Dominique Clarke 2024-09-09 22:39:23 -04:00 committed by GitHub
parent 0b980d8d9d
commit 9b13685565
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 279 additions and 25 deletions

View file

@ -9,6 +9,7 @@ import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { Route, Router, Routes } from '@kbn/shared-ux-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import type { History } from 'history';
import React, { useMemo } from 'react';
import type { Observable } from 'rxjs';
@ -18,6 +19,8 @@ import { getRoutes } from './routes/config';
import { InvestigateAppServices } from './services/types';
import type { InvestigateAppStartDependencies } from './types';
const queryClient = new QueryClient();
function Application({
coreStart,
history,
@ -61,8 +64,6 @@ function Application({
);
};
const queryClient = new QueryClient();
return (
<KibanaThemeProvider theme={theme}>
<InvestigateAppContextProvider context={context}>
@ -71,6 +72,7 @@ function Application({
<Router history={history}>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</Router>
</coreStart.i18n.Context>

View file

@ -7,8 +7,8 @@
export const investigationKeys = {
all: ['investigation'] as const,
list: (params: { page: number; perPage: number }) =>
[...investigationKeys.all, 'list', params] as const,
list: (params?: { page: number; perPage: number }) =>
[...investigationKeys.all, 'list', ...(params ? [params] : [])] as const,
fetch: (params: { id: string }) => [...investigationKeys.all, 'fetch', params] as const,
notes: ['investigation', 'notes'] as const,
fetchNotes: (params: { investigationId: string }) =>

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useKibana } from './use_kibana';
import { investigationKeys } from './query_key_factory';
type ServerError = IHttpFetchError<ResponseErrorBody>;
export function useDeleteInvestigation() {
const queryClient = useQueryClient();
const {
core: {
http,
notifications: { toasts },
},
} = useKibana();
return useMutation<void, ServerError, { investigationId: string }, { investigationId: string }>(
['deleteInvestigation'],
({ investigationId }) => {
return http.delete<void>(`/api/observability/investigations/${investigationId}`, {
version: '2023-10-31',
});
},
{
onSuccess: (response, {}) => {
toasts.addSuccess(
i18n.translate('xpack.investigateApp.deleteInvestigationSuccess', {
defaultMessage: 'Investigation deleted successfully',
})
);
queryClient.invalidateQueries({
queryKey: investigationKeys.list(),
exact: false,
refetchType: 'all',
});
},
onError: (error, {}, context) => {
toasts.addError(
new Error(
error.body?.message ??
i18n.translate('xpack.investigateApp.deleteInvestigationError', {
defaultMessage: 'Unable to delete investigation: an error occurred',
})
),
{
title: i18n.translate('xpack.investigateApp.deleteInvestigationErrorTitle', {
defaultMessage: 'Error',
}),
}
);
},
}
);
}

View file

@ -4,21 +4,31 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui';
import React, { useState } from 'react';
import moment from 'moment';
import { Criteria, EuiBasicTable, EuiBasicTableColumn, EuiLink, EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { InvestigationResponse } from '@kbn/investigation-shared/src/rest_specs/investigation';
import { InvestigationListActions } from './investigation_list_actions';
import { useFetchInvestigationList } from '../../../hooks/use_fetch_investigation_list';
import { useKibana } from '../../../hooks/use_kibana';
import { paths } from '../../../../common/paths';
export function InvestigationList() {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const {
core: {
http: { basePath },
uiSettings,
},
} = useKibana();
const { data, isLoading, isError } = useFetchInvestigationList();
const { data, isLoading, isError } = useFetchInvestigationList({
page: pageIndex + 1,
perPage: pageSize,
});
const dateFormat = uiSettings.get('dateFormat');
const tz = uiSettings.get('dateFormat:tz');
if (isLoading) {
return (
@ -41,23 +51,90 @@ export function InvestigationList() {
}
const investigations = data?.results ?? [];
const total = data?.total ?? 0;
const columns: Array<EuiBasicTableColumn<InvestigationResponse>> = [
{
field: 'title',
name: i18n.translate('xpack.investigateApp.investigationList.titleLabel', {
defaultMessage: 'Name',
}),
render: (title: InvestigationResponse['title'], investigation: InvestigationResponse) => {
return (
<EuiLink
data-test-subj="investigateAppInvestigationListDirectLink"
href={basePath.prepend(paths.investigationDetails(investigation.id))}
>
{title}
</EuiLink>
);
},
},
{
field: 'createdBy',
name: i18n.translate('xpack.investigateApp.investigationList.createdByLabel', {
defaultMessage: 'Created by',
}),
truncateText: true,
},
{
field: 'notes',
name: i18n.translate('xpack.investigateApp.investigationList.notesLabel', {
defaultMessage: 'Comments',
}),
render: (notes: InvestigationResponse['notes']) => <span>{notes?.length || 0}</span>,
},
{
field: 'createdAt',
name: i18n.translate('xpack.investigateApp.investigationList.createdAtLabel', {
defaultMessage: 'Created at',
}),
render: (createdAt: InvestigationResponse['createdAt']) => (
<span>{moment(createdAt).tz(tz).format(dateFormat)}</span>
),
},
{
field: 'status',
name: 'Status',
render: (status: InvestigationResponse['status']) => {
const color = status === 'ongoing' ? 'danger' : 'success';
return <EuiBadge color={color}>{status}</EuiBadge>;
},
},
{
name: 'Actions',
render: (investigation: InvestigationResponse) => (
<InvestigationListActions investigation={investigation} />
),
},
];
const pagination = {
pageIndex,
pageSize,
totalItemCount: total || 0,
pageSizeOptions: [10, 50],
showPerPageOptions: true,
};
const onTableChange = ({ page }: Criteria<InvestigationResponse>) => {
if (page) {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
}
};
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<ul>
{investigations.map((investigation) => (
<li key={investigation.id}>
<EuiLink
data-test-subj="investigateAppInvestigationListLink"
href={basePath.prepend(paths.investigationDetails(investigation.id))}
>
{investigation.title}
</EuiLink>
</li>
))}
</ul>
</EuiFlexItem>
</EuiFlexGroup>
<EuiBasicTable
tableCaption={i18n.translate('xpack.investigateApp.investigationList.tableCaption', {
defaultMessage: 'Investigations List',
})}
items={investigations}
pagination={pagination}
rowHeader="title"
columns={columns}
onChange={onTableChange}
/>
);
}

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useEffect } from 'react';
import {
EuiButton,
EuiButtonIcon,
EuiButtonEmpty,
EuiToolTip,
EuiModal,
EuiModalHeader,
EuiModalBody,
EuiModalFooter,
EuiModalHeaderTitle,
useGeneratedHtmlId,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { InvestigationResponse } from '@kbn/investigation-shared/src/rest_specs/investigation';
import { useDeleteInvestigation } from '../../../hooks/use_delete_investigation';
export function InvestigationListActions({
investigation,
}: {
investigation: InvestigationResponse;
}) {
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const {
mutate: deleteInvestigation,
isLoading: isDeleting,
isError,
isSuccess,
} = useDeleteInvestigation();
const closeDeleteModal = () => setIsDeleteModalVisible(false);
useEffect(() => {
if (isError || isSuccess) {
closeDeleteModal();
}
}, [isError, isSuccess]);
const modalTitleId = useGeneratedHtmlId();
return (
<>
{isDeleteModalVisible && (
<EuiModal aria-labelledby={modalTitleId} onClose={closeDeleteModal}>
<EuiModalHeader>
<EuiModalHeaderTitle id={modalTitleId}>
{i18n.translate('xpack.investigateApp.deleteModal.title', {
defaultMessage: 'Delete',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
{i18n.translate('xpack.investigateApp.deleteModal.description', {
defaultMessage: "You can't recover this investigation after deletion.",
})}
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={closeDeleteModal}
data-test-subj="investigateAppInvestigationListDeleteModalCancelButton"
>
{i18n.translate('xpack.investigateApp.deleteModal.cancel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="investigateAppInvestigationListDeleteModalConfirmButton"
onClick={() => {
deleteInvestigation({ investigationId: investigation.id });
}}
fill
color="danger"
isLoading={isDeleting}
>
{i18n.translate('xpack.investigateApp.deleteModal.confirm', {
defaultMessage: 'Delete',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
)}
<EuiToolTip
content={i18n.translate('xpack.investigateApp.investigationList.deleteAction', {
defaultMessage: 'Delete',
})}
>
<EuiButtonIcon
data-test-subj="investigateAppInvestigationListDeleteButton"
aria-label={i18n.translate(
'xpack.investigateApp.investigationList.deleteAction.ariaLabel',
{
defaultMessage: 'Delete investigation "{name}"',
values: { name: investigation.title },
}
)}
iconType="trash"
onClick={() => setIsDeleteModalVisible(true)}
/>
</EuiToolTip>
</>
);
}

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';