From d06b063eb57d06bb77d8cad1a9520d6ac1d0ff5c Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 29 Aug 2024 09:06:20 -0400 Subject: [PATCH] feat(rca): edit notes (#191546) --- .../src/rest_specs/index.ts | 2 + .../src/rest_specs/update_note.ts | 30 +++++ .../hooks/use_update_investigation_note.ts | 46 +++++++ .../register_embeddable_item.tsx | 2 +- .../items/esql_item/register_esql_item.tsx | 4 +- .../esql_widget_preview.tsx | 4 +- .../investigation_details.tsx | 4 +- .../investigation_items.tsx | 9 +- .../investigation_notes/edit_note_form.tsx | 88 +++++++++++++ .../investigation_notes.tsx | 32 ++--- .../components/investigation_notes/note.tsx | 121 ++++++++++++++++++ .../investigation_notes/timeline_message.tsx | 67 ---------- .../investigate_app/public/plugin.tsx | 1 - ...investigate_app_server_route_repository.ts | 35 ++++- .../services/update_investigation_note.ts | 37 ++++++ 15 files changed, 380 insertions(+), 102 deletions(-) create mode 100644 packages/kbn-investigation-shared/src/rest_specs/update_note.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/hooks/use_update_investigation_note.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/edit_note_form.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/note.tsx delete mode 100644 x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/timeline_message.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts diff --git a/packages/kbn-investigation-shared/src/rest_specs/index.ts b/packages/kbn-investigation-shared/src/rest_specs/index.ts index cb13c11886481..9022266becf2a 100644 --- a/packages/kbn-investigation-shared/src/rest_specs/index.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/index.ts @@ -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'; diff --git a/packages/kbn-investigation-shared/src/rest_specs/update_note.ts b/packages/kbn-investigation-shared/src/rest_specs/update_note.ts new file mode 100644 index 0000000000000..22e84db8242d4 --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/update_note.ts @@ -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; + +export { updateInvestigationNoteParamsSchema, updateInvestigationNoteResponseSchema }; +export type { UpdateInvestigationNoteParams, UpdateInvestigationNoteResponse }; diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_update_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_update_investigation_note.ts new file mode 100644 index 0000000000000..312cb90ad289b --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_update_investigation_note.ts @@ -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; + +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( + `/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' }); + }, + } + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/items/embeddable_item/register_embeddable_item.tsx b/x-pack/plugins/observability_solution/investigate_app/public/items/embeddable_item/register_embeddable_item.tsx index e07c940c8ca5f..7e331a6604721 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/items/embeddable_item/register_embeddable_item.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/items/embeddable_item/register_embeddable_item.tsx @@ -166,7 +166,7 @@ export function registerEmbeddableItem({ services, }: Options) { investigate.registerItemDefinition({ - type: 'esql', + type: 'embeddable', generate: async (option: { itemParams: EmbeddableItemParams; globalParams: GlobalWidgetParameters; diff --git a/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx b/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx index 1b62b5476f021..695daf1f48cb2 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx @@ -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({ - type: 'esql', + type: ESQL_ITEM_TYPE, generate: async (option: { itemParams: EsqlItemParams; globalParams: GlobalWidgetParameters; diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/add_investigation_item/esql_widget_preview.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/add_investigation_item/esql_widget_preview.tsx index c865dfcf91826..7ceedaed3b312 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/add_investigation_item/esql_widget_preview.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/add_investigation_item/esql_widget_preview.tsx @@ -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, diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/investigation_details.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/investigation_details.tsx index 5f6e1ca4515d3..ebeb1a4dc1b42 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/investigation_details.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/investigation_details.tsx @@ -27,11 +27,11 @@ export function InvestigationDetails({ user, investigationId }: Props) { return ( - + - + ); diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_items/investigation_items.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_items/investigation_items.tsx index dcc83eb968344..7be8799077118 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_items/investigation_items.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_items/investigation_items.tsx @@ -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(); }; diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/edit_note_form.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/edit_note_form.tsx new file mode 100644 index 0000000000000..9a0e7e585bb63 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/edit_note_form.tsx @@ -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 ( + + + { + setNoteInput(value); + }} + onSubmit={() => { + handleUpdateNote(); + }} + placeholder={note.content} + /> + + + + + onCancel()} + > + {i18n.translate('xpack.investigateApp.investigationNotes.cancelEditButtonLabel', { + defaultMessage: 'Cancel', + })} + + + + { + handleUpdateNote(); + }} + > + {i18n.translate('xpack.investigateApp.investigationNotes.updateNoteButtonLabel', { + defaultMessage: 'Update note', + })} + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx index 8406ba8fe3f03..9096b2ec7434e 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx @@ -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) { {notes?.map((currNote: InvestigationNoteResponse) => { return ( - } + 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) { 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 ( + + + + + + + + + {formatDistance(new Date(note.createdAt), new Date(), { addSuffix: true })} + + + + + + + { + setIsEditing(!isEditing); + }} + /> + + + deleteNote()} + data-test-subj="deleteInvestigationNoteButton" + /> + + + + + {isEditing ? ( + setIsEditing(false)} + onUpdate={() => handleUpdateCompleted()} + /> + ) : ( + + {note.content} + + )} + + + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/timeline_message.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/timeline_message.tsx deleted file mode 100644 index d93492d8c5d33..0000000000000 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/timeline_message.tsx +++ /dev/null @@ -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 ( - - - - {icon} - - - {formatDistance(new Date(note.createdAt), new Date(), { addSuffix: true })} - - - - - - - - - - - {note.content} - - - - ); -} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/plugin.tsx b/x-pack/plugins/observability_solution/investigate_app/public/plugin.tsx index abbe762562541..de3aa5f5ed003 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/plugin.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/plugin.tsx @@ -125,7 +125,6 @@ export class InvestigateAppPlugin .getStartServices() .then(([, pluginsStart]) => pluginsStart); - // new Promise.all([ pluginsStartPromise, import('./items/register_items').then((m) => m.registerItems), diff --git a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts index 0829a11762160..07e83e1fad368 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts @@ -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, diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts new file mode 100644 index 0000000000000..4c359c06d5e32 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts @@ -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 { + 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); +}