feat(rca): edit notes (#191546)

This commit is contained in:
Kevin Delemme 2024-08-29 09:06:20 -04:00 committed by GitHub
parent 4c77c7a57c
commit d06b063eb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 380 additions and 102 deletions

View file

@ -18,6 +18,7 @@ export type * from './create_item';
export type * from './delete_item';
export type * from './get_items';
export type * from './investigation_item';
export type * from './update_note';
export * from './create';
export * from './create_note';
@ -31,3 +32,4 @@ export * from './create_item';
export * from './delete_item';
export * from './get_items';
export * from './investigation_item';
export * from './update_note';

View file

@ -0,0 +1,30 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { investigationNoteResponseSchema } from './investigation_note';
const updateInvestigationNoteParamsSchema = t.type({
path: t.type({
investigationId: t.string,
noteId: t.string,
}),
body: t.type({
content: t.string,
}),
});
const updateInvestigationNoteResponseSchema = investigationNoteResponseSchema;
type UpdateInvestigationNoteParams = t.TypeOf<
typeof updateInvestigationNoteParamsSchema.props.body
>;
type UpdateInvestigationNoteResponse = t.OutputOf<typeof updateInvestigationNoteResponseSchema>;
export { updateInvestigationNoteParamsSchema, updateInvestigationNoteResponseSchema };
export type { UpdateInvestigationNoteParams, UpdateInvestigationNoteResponse };

View file

@ -0,0 +1,46 @@
/*
* 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 { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import { UpdateInvestigationNoteParams } from '@kbn/investigation-shared';
import { useMutation } from '@tanstack/react-query';
import { useKibana } from './use_kibana';
type ServerError = IHttpFetchError<ResponseErrorBody>;
export function useUpdateInvestigationNote() {
const {
core: {
http,
notifications: { toasts },
},
} = useKibana();
return useMutation<
void,
ServerError,
{ investigationId: string; noteId: string; note: UpdateInvestigationNoteParams },
{ investigationId: string }
>(
['deleteInvestigationNote'],
({ investigationId, noteId, note }) => {
const body = JSON.stringify(note);
return http.put<void>(
`/api/observability/investigations/${investigationId}/notes/${noteId}`,
{ body, version: '2023-10-31' }
);
},
{
onSuccess: (response, {}) => {
toasts.addSuccess('Note updated');
},
onError: (error, {}, context) => {
toasts.addError(new Error(error.body?.message ?? 'An error occurred'), { title: 'Error' });
},
}
);
}

View file

@ -166,7 +166,7 @@ export function registerEmbeddableItem({
services,
}: Options) {
investigate.registerItemDefinition<EmbeddableItemParams, {}>({
type: 'esql',
type: 'embeddable',
generate: async (option: {
itemParams: EmbeddableItemParams;
globalParams: GlobalWidgetParameters;

View file

@ -59,6 +59,8 @@ interface EsqlItemData {
};
}
export const ESQL_ITEM_TYPE = 'esql';
export function EsqlWidget({
suggestion,
dataView,
@ -228,7 +230,7 @@ export function registerEsqlItem({
services,
}: Options) {
investigate.registerItemDefinition<EsqlItemParams, EsqlItemData>({
type: 'esql',
type: ESQL_ITEM_TYPE,
generate: async (option: {
itemParams: EsqlItemParams;
globalParams: GlobalWidgetParameters;

View file

@ -17,7 +17,7 @@ import { ErrorMessage } from '../../../../components/error_message';
import { SuggestVisualizationList } from '../../../../components/suggest_visualization_list';
import { useKibana } from '../../../../hooks/use_kibana';
import { getDateHistogramResults } from '../../../../items/esql_item/get_date_histogram_results';
import { EsqlWidget } from '../../../../items/esql_item/register_esql_item';
import { ESQL_ITEM_TYPE, EsqlWidget } from '../../../../items/esql_item/register_esql_item';
import { getEsFilterFromOverrides } from '../../../../utils/get_es_filter_from_overrides';
function getItemFromSuggestion({
@ -29,7 +29,7 @@ function getItemFromSuggestion({
}): Item {
return {
title: suggestion.title,
type: 'esql',
type: ESQL_ITEM_TYPE,
params: {
esql: query,
suggestion,

View file

@ -27,11 +27,11 @@ export function InvestigationDetails({ user, investigationId }: Props) {
return (
<EuiFlexGroup direction="row">
<EuiFlexItem grow={8}>
<InvestigationItems investigationId={investigationId} investigation={investigation} />
<InvestigationItems investigation={investigation} />
</EuiFlexItem>
<EuiFlexItem grow={2}>
<InvestigationNotes investigationId={investigationId} investigation={investigation} />
<InvestigationNotes investigation={investigation} user={user} />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -18,13 +18,12 @@ import { InvestigationItemsList } from '../investigation_items_list/investigatio
import { InvestigationSearchBar } from '../investigation_search_bar/investigation_search_bar';
export interface Props {
investigationId: string;
investigation: GetInvestigationResponse;
}
export function InvestigationItems({ investigationId, investigation }: Props) {
export function InvestigationItems({ investigation }: Props) {
const { data: items, refetch } = useFetchInvestigationItems({
investigationId,
investigationId: investigation.id,
initialItems: investigation.items,
});
const renderableItems = useRenderItems({ items, params: investigation.params });
@ -34,12 +33,12 @@ export function InvestigationItems({ investigationId, investigation }: Props) {
useDeleteInvestigationItem();
const onAddItem = async (item: Item) => {
await addInvestigationItem({ investigationId, item });
await addInvestigationItem({ investigationId: investigation.id, item });
refetch();
};
const onDeleteItem = async (itemId: string) => {
await deleteInvestigationItem({ investigationId, itemId });
await deleteInvestigationItem({ investigationId: investigation.id, itemId });
refetch();
};

View file

@ -0,0 +1,88 @@
/*
* 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 { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { InvestigationNoteResponse } from '@kbn/investigation-shared';
import React, { useState } from 'react';
import { ResizableTextInput } from './resizable_text_input';
import { useUpdateInvestigationNote } from '../../../../hooks/use_update_investigation_note';
interface Props {
investigationId: string;
note: InvestigationNoteResponse;
onCancel: () => void;
onUpdate: () => void;
}
export function EditNoteForm({ investigationId, note, onCancel, onUpdate }: Props) {
const [noteInput, setNoteInput] = useState(note.content);
const { mutateAsync: updateNote, isLoading: isUpdating } = useUpdateInvestigationNote();
const handleUpdateNote = async () => {
await updateNote({ investigationId, noteId: note.id, note: { content: noteInput.trim() } });
onUpdate();
};
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<ResizableTextInput
disabled={isUpdating}
value={noteInput}
onChange={(value) => {
setNoteInput(value);
}}
onSubmit={() => {
handleUpdateNote();
}}
placeholder={note.content}
/>
</EuiFlexItem>
<EuiFlexGroup direction="row" gutterSize="s" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
disabled={isUpdating}
data-test-subj="cancelEditNoteButton"
color="text"
aria-label={i18n.translate(
'xpack.investigateApp.investigationNotes.cancelEditButtonLabel',
{ defaultMessage: 'Cancel' }
)}
size="m"
onClick={() => onCancel()}
>
{i18n.translate('xpack.investigateApp.investigationNotes.cancelEditButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="updateNoteButton"
color="primary"
aria-label={i18n.translate(
'xpack.investigateApp.investigationNotes.updateNoteButtonLabel',
{ defaultMessage: 'Update note' }
)}
disabled={isUpdating || noteInput.trim() === ''}
isLoading={isUpdating}
size="m"
onClick={() => {
handleUpdateNote();
}}
>
{i18n.translate('xpack.investigateApp.investigationNotes.updateNoteButtonLabel', {
defaultMessage: 'Update note',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
);
}

View file

@ -6,7 +6,6 @@
*/
import {
EuiAvatar,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
@ -16,43 +15,36 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import { InvestigationNoteResponse, GetInvestigationResponse } from '@kbn/investigation-shared';
import { GetInvestigationResponse, InvestigationNoteResponse } from '@kbn/investigation-shared';
import { AuthenticatedUser } from '@kbn/security-plugin/common';
import React, { useState } from 'react';
import { useAddInvestigationNote } from '../../../../hooks/use_add_investigation_note';
import { useDeleteInvestigationNote } from '../../../../hooks/use_delete_investigation_note';
import { useFetchInvestigationNotes } from '../../../../hooks/use_fetch_investigation_notes';
import { useTheme } from '../../../../hooks/use_theme';
import { Note } from './note';
import { ResizableTextInput } from './resizable_text_input';
import { TimelineMessage } from './timeline_message';
export interface Props {
investigationId: string;
investigation: GetInvestigationResponse;
user: AuthenticatedUser;
}
export function InvestigationNotes({ investigationId, investigation }: Props) {
export function InvestigationNotes({ investigation, user }: Props) {
const theme = useTheme();
const [noteInput, setNoteInput] = useState('');
const { data: notes, refetch } = useFetchInvestigationNotes({
investigationId,
investigationId: investigation.id,
initialNotes: investigation.notes,
});
const { mutateAsync: addInvestigationNote, isLoading: isAdding } = useAddInvestigationNote();
const { mutateAsync: deleteInvestigationNote, isLoading: isDeleting } =
useDeleteInvestigationNote();
const onAddNote = async (content: string) => {
await addInvestigationNote({ investigationId, note: { content } });
await addInvestigationNote({ investigationId: investigation.id, note: { content } });
refetch();
setNoteInput('');
};
const onDeleteNote = async (noteId: string) => {
await deleteInvestigationNote({ investigationId, noteId });
refetch();
};
const panelClassName = css`
background-color: ${theme.colors.lightShade};
`;
@ -72,12 +64,12 @@ export function InvestigationNotes({ investigationId, investigation }: Props) {
<EuiFlexGroup direction="column" gutterSize="m">
{notes?.map((currNote: InvestigationNoteResponse) => {
return (
<TimelineMessage
<Note
key={currNote.id}
icon={<EuiAvatar name={currNote.createdBy} size="s" />}
investigationId={investigation.id}
note={currNote}
onDelete={() => onDeleteNote(currNote.id)}
isDeleting={isDeleting}
disabled={currNote.createdBy !== user.username}
onUpdateOrDeleteCompleted={() => refetch()}
/>
);
})}
@ -110,7 +102,7 @@ export function InvestigationNotes({ investigationId, investigation }: Props) {
<EuiButton
data-test-subj="investigateAppInvestigationNotesAddButton"
fullWidth
color="text"
color="primary"
aria-label={i18n.translate('xpack.investigateApp.investigationNotes.addButtonLabel', {
defaultMessage: 'Add',
})}

View file

@ -0,0 +1,121 @@
/*
* 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 {
EuiAvatar,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiMarkdownFormat,
EuiText,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { InvestigationNoteResponse } from '@kbn/investigation-shared';
// eslint-disable-next-line import/no-extraneous-dependencies
import { formatDistance } from 'date-fns';
import React, { useState } from 'react';
import { useTheme } from '../../../../hooks/use_theme';
import { EditNoteForm } from './edit_note_form';
import { useDeleteInvestigationNote } from '../../../../hooks/use_delete_investigation_note';
const textContainerClassName = css`
padding-top: 2px;
`;
interface Props {
note: InvestigationNoteResponse;
investigationId: string;
disabled: boolean;
onUpdateOrDeleteCompleted: () => void;
}
export function Note({ note, investigationId, disabled, onUpdateOrDeleteCompleted }: Props) {
const [isEditing, setIsEditing] = useState(false);
const { mutateAsync: deleteInvestigationNote, isLoading: isDeleting } =
useDeleteInvestigationNote();
const theme = useTheme();
const timelineContainerClassName = css`
padding-bottom: 16px;
border-bottom: 1px solid ${theme.colors.lightShade};
:last-child {
border-bottom: 0px;
}
`;
const deleteNote = async () => {
await deleteInvestigationNote({ investigationId, noteId: note.id });
onUpdateOrDeleteCompleted();
};
const handleUpdateCompleted = async () => {
setIsEditing(false);
onUpdateOrDeleteCompleted();
};
return (
<EuiFlexGroup direction="column" gutterSize="s" className={timelineContainerClassName}>
<EuiFlexGroup direction="row" alignItems="center" justifyContent="spaceBetween">
<EuiFlexGroup direction="row" alignItems="center" justifyContent="flexStart" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiAvatar name={note.createdBy} size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs">
{formatDistance(new Date(note.createdAt), new Date(), { addSuffix: true })}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
direction="row"
alignItems="center"
justifyContent="flexEnd"
gutterSize="none"
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="editInvestigationNoteButton"
size="s"
iconSize="s"
color="text"
iconType="pencil"
disabled={disabled || isDeleting}
onClick={() => {
setIsEditing(!isEditing);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
iconSize="s"
color="text"
iconType="trash"
disabled={disabled || isDeleting}
onClick={() => deleteNote()}
data-test-subj="deleteInvestigationNoteButton"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
<EuiFlexItem className={textContainerClassName}>
{isEditing ? (
<EditNoteForm
investigationId={investigationId}
note={note}
onCancel={() => setIsEditing(false)}
onUpdate={() => handleUpdateCompleted()}
/>
) : (
<EuiText size="s">
<EuiMarkdownFormat textSize="s">{note.content}</EuiMarkdownFormat>
</EuiText>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,67 +0,0 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiMarkdownFormat, EuiText } from '@elastic/eui';
import { css } from '@emotion/css';
import { InvestigationNoteResponse } from '@kbn/investigation-shared';
// eslint-disable-next-line import/no-extraneous-dependencies
import { formatDistance } from 'date-fns';
import React from 'react';
import { InvestigateTextButton } from '../../../../components/investigate_text_button';
import { useTheme } from '../../../../hooks/use_theme';
const textContainerClassName = css`
padding-top: 2px;
`;
export function TimelineMessage({
icon,
note,
onDelete,
isDeleting,
}: {
icon: React.ReactNode;
note: InvestigationNoteResponse;
onDelete: () => void;
isDeleting: boolean;
}) {
const theme = useTheme();
const timelineContainerClassName = css`
padding-bottom: 16px;
border-bottom: 1px solid ${theme.colors.lightShade};
:last-child {
border-bottom: 0px;
}
`;
return (
<EuiFlexGroup direction="column" gutterSize="s" className={timelineContainerClassName}>
<EuiFlexGroup direction="row" alignItems="center" justifyContent="spaceBetween">
<EuiFlexGroup direction="row" alignItems="center" justifyContent="flexStart" gutterSize="s">
<EuiFlexItem grow={false}>{icon}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs">
{formatDistance(new Date(note.createdAt), new Date(), { addSuffix: true })}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem grow={false}>
<InvestigateTextButton
data-test-subj="investigateAppTimelineMessageButton"
iconType="trash"
disabled={isDeleting}
onClick={onDelete}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem className={textContainerClassName}>
<EuiText size="s">
<EuiMarkdownFormat textSize="s">{note.content}</EuiMarkdownFormat>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -125,7 +125,6 @@ export class InvestigateAppPlugin
.getStartServices()
.then(([, pluginsStart]) => pluginsStart);
// new
Promise.all([
pluginsStartPromise,
import('./items/register_items').then((m) => m.registerItems),

View file

@ -16,6 +16,7 @@ import {
getInvestigationItemsParamsSchema,
getInvestigationNotesParamsSchema,
getInvestigationParamsSchema,
updateInvestigationNoteParamsSchema,
} from '@kbn/investigation-shared';
import { createInvestigation } from '../services/create_investigation';
import { createInvestigationItem } from '../services/create_investigation_item';
@ -29,6 +30,7 @@ import { getInvestigationNotes } from '../services/get_investigation_notes';
import { investigationRepositoryFactory } from '../services/investigation_repository';
import { createInvestigateAppServerRoute } from './create_investigate_app_server_route';
import { getInvestigationItems } from '../services/get_investigation_items';
import { updateInvestigationNote } from '../services/update_investigation_note';
const createInvestigationRoute = createInvestigateAppServerRoute({
endpoint: 'POST /api/observability/investigations 2023-10-31',
@ -125,7 +127,33 @@ const getInvestigationNotesRoute = createInvestigateAppServerRoute({
},
});
const deleteInvestigationNotesRoute = createInvestigateAppServerRoute({
const updateInvestigationNoteRoute = createInvestigateAppServerRoute({
endpoint: 'PUT /api/observability/investigations/{investigationId}/notes/{noteId} 2023-10-31',
options: {
tags: [],
},
params: updateInvestigationNoteParamsSchema,
handler: async ({ params, context, request, logger }) => {
const user = (await context.core).coreStart.security.authc.getCurrentUser(request);
if (!user) {
throw new Error('User is not authenticated');
}
const soClient = (await context.core).savedObjects.client;
const repository = investigationRepositoryFactory({ soClient, logger });
return await updateInvestigationNote(
params.path.investigationId,
params.path.noteId,
params.body,
{
repository,
user,
}
);
},
});
const deleteInvestigationNoteRoute = createInvestigateAppServerRoute({
endpoint: 'DELETE /api/observability/investigations/{investigationId}/notes/{noteId} 2023-10-31',
options: {
tags: [],
@ -207,10 +235,11 @@ export function getGlobalInvestigateAppServerRouteRepository() {
...createInvestigationRoute,
...findInvestigationsRoute,
...getInvestigationRoute,
...deleteInvestigationRoute,
...createInvestigationNoteRoute,
...getInvestigationNotesRoute,
...deleteInvestigationNotesRoute,
...updateInvestigationNoteRoute,
...deleteInvestigationNoteRoute,
...deleteInvestigationRoute,
...createInvestigationItemRoute,
...deleteInvestigationItemRoute,
...getInvestigationItemsRoute,

View file

@ -0,0 +1,37 @@
/*
* 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 type { AuthenticatedUser } from '@kbn/core-security-common';
import { UpdateInvestigationNoteParams } from '@kbn/investigation-shared';
import { InvestigationRepository } from './investigation_repository';
export async function updateInvestigationNote(
investigationId: string,
noteId: string,
params: UpdateInvestigationNoteParams,
{ repository, user }: { repository: InvestigationRepository; user: AuthenticatedUser }
): Promise<void> {
const investigation = await repository.findById(investigationId);
const note = investigation.notes.find((currNote) => currNote.id === noteId);
if (!note) {
throw new Error('Note not found');
}
if (note.createdBy !== user.username) {
throw new Error('User does not have permission to delete note');
}
investigation.notes = investigation.notes.filter((currNote) => {
if (currNote.id === noteId) {
currNote.content = params.content;
}
return currNote;
});
await repository.save(investigation);
}