From 6423763230ecb64ee505c4432995f4a53d2dbeaf Mon Sep 17 00:00:00 2001 From: rawagner Date: Mon, 1 Sep 2025 13:54:50 +0200 Subject: [PATCH] MGMT-21647: Add delete conversation option in chat history --- .../lib/components/ChatBot/ChatBotHistory.tsx | 156 ++++++++++++------ .../lib/components/ChatBot/ChatBotWindow.tsx | 4 +- .../ChatBot/DeleteConversationModal.tsx | 89 ++++++++++ 3 files changed, 198 insertions(+), 51 deletions(-) create mode 100644 libs/chatbot/lib/components/ChatBot/DeleteConversationModal.tsx diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx index 27129dbe3f..9c22aa76cd 100644 --- a/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx +++ b/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx @@ -4,8 +4,10 @@ import { ChatbotDisplayMode, Conversation, } from '@patternfly/chatbot'; -import { Alert } from '@patternfly-6/react-core'; +import { Alert, MenuItemAction } from '@patternfly-6/react-core'; import { getErrorMessage } from './helpers'; +import { TrashAltIcon } from '@patternfly-6/react-icons'; +import DeleteConversationModal from './DeleteConversationModal'; type ConversationHistory = { conversations: { conversation_id: string; created_at: string }[] }; @@ -13,7 +15,7 @@ type ChatBotHistoryProps = { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; onApiCall: typeof fetch; - startNewConversation: VoidFunction; + startNewConversation: (closeDrawer?: boolean) => void; loadConversation: (id: string) => Promise; conversationId?: string; }; @@ -27,10 +29,30 @@ const ChatBotHistory = ({ startNewConversation, loadConversation, }: React.PropsWithChildren) => { + const [deleteConversation, setDeleteConversation] = React.useState(); const [isLoading, setIsLoading] = React.useState(true); const [conversations, setConversations] = React.useState([]); const [error, setError] = React.useState(); + const fetchConversations = React.useCallback( + async (signal?: AbortSignal) => { + const resp = await onApiCall('/v1/conversations', { signal }); + if (!resp.ok) { + throw Error(`Unexpected response code: ${resp.status}`); + } + const cnvs = (await resp.json()) as ConversationHistory; + setConversations( + cnvs.conversations + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .map(({ conversation_id, created_at }) => ({ + id: conversation_id, + text: new Date(created_at).toLocaleString(), + })), + ); + }, + [onApiCall], + ); + React.useEffect(() => { if (!isOpen) { return; @@ -40,19 +62,7 @@ const ChatBotHistory = ({ setError(undefined); void (async () => { try { - const resp = await onApiCall('/v1/conversations', { signal: abortController.signal }); - if (!resp.ok) { - throw Error(`Unexpected response code: ${resp.status}`); - } - const cnvs = (await resp.json()) as ConversationHistory; - setConversations( - cnvs.conversations - .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) - .map(({ conversation_id, created_at }) => ({ - id: conversation_id, - text: new Date(created_at).toLocaleString(), - })), - ); + await fetchConversations(abortController.signal); } catch (e) { // aborting fetch throws 'AbortError', we can ignore it if (abortController.signal.aborted) { @@ -64,44 +74,92 @@ const ChatBotHistory = ({ } })(); return () => abortController.abort(); - }, [isOpen, onApiCall]); + }, [isOpen, fetchConversations]); return ( - { - setIsOpen(!isOpen); - }} - isLoading={isLoading} - conversations={conversations} - onNewChat={startNewConversation} - onSelectActiveItem={(_, itemId) => { - itemId !== undefined && void loadConversation(`${itemId}`); - setIsOpen(!isOpen); - }} - activeItemId={conversationId} - errorState={ - error - ? { - bodyText: ( - - {error} - - ), + <> + { + setIsOpen(!isOpen); + }} + isLoading={isLoading} + conversations={conversations.map((c) => ({ + ...c, + additionalProps: { + actions: ( + } + actionId="delete" + onClick={() => setDeleteConversation(c.id)} + aria-label={`Delete conversation from ${c.text}`} + /> + ), + }, + }))} + onNewChat={startNewConversation} + onSelectActiveItem={(_, itemId) => { + itemId !== undefined && void loadConversation(`${itemId}`); + setIsOpen(!isOpen); + }} + activeItemId={conversationId} + errorState={ + error + ? { + bodyText: ( + + {error} + + ), + } + : undefined + } + emptyState={ + !isLoading && !conversations.length + ? { + bodyText: 'No conversation history', + } + : undefined + } + /> + {deleteConversation && ( + id === deleteConversation) as Conversation} + onClose={() => setDeleteConversation(undefined)} + onDelete={async () => { + const resp = await onApiCall(`/v1/conversations/${deleteConversation}`, { + method: 'DELETE', + }); + if (!resp.ok) { + let errMsg = `Unexpected response code ${resp.status}`; + try { + const errDetail = (await resp.json()) as { detail?: { cause?: string } }; + if (errDetail.detail?.cause) { + errMsg = errDetail.detail.cause; + } + } catch { + //failed to get err cause + } + throw errMsg; } - : undefined - } - emptyState={ - !isLoading && !conversations.length - ? { - bodyText: 'No conversation history', + const deleteResult = (await resp.json()) as { success: boolean; response: string }; + if (!deleteResult.success) { + throw deleteResult.response; } - : undefined - } - /> + + // if current conversationId is deleted, start a new conversation + if (deleteConversation === conversationId) { + startNewConversation(false); + } + + await fetchConversations(); + }} + /> + )} + ); }; diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx index d9f24758ae..79215c0d52 100644 --- a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx +++ b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx @@ -105,9 +105,9 @@ const ChatBotWindow = ({ isOpen={isDrawerOpen} setIsOpen={setIsDrawerOpen} onApiCall={onApiCall} - startNewConversation={() => { + startNewConversation={(closeDrawer = true) => { startNewConversation(); - setIsDrawerOpen(false); + closeDrawer && setIsDrawerOpen(false); }} loadConversation={(id) => { setTriggerScroll(0); diff --git a/libs/chatbot/lib/components/ChatBot/DeleteConversationModal.tsx b/libs/chatbot/lib/components/ChatBot/DeleteConversationModal.tsx new file mode 100644 index 0000000000..1945a8ecdd --- /dev/null +++ b/libs/chatbot/lib/components/ChatBot/DeleteConversationModal.tsx @@ -0,0 +1,89 @@ +import { + Alert, + Button, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Stack, + StackItem, +} from '@patternfly-6/react-core'; +import * as React from 'react'; +import { getErrorMessage } from './helpers'; +import { Conversation } from '@patternfly/chatbot'; + +type DeleteConversationModalProps = { + onClose: VoidFunction; + conversation: Conversation; + onDelete: () => Promise; +}; + +const DeleteConversationModal = ({ + onClose, + conversation, + onDelete, +}: DeleteConversationModalProps) => { + const [isDeleting, setIsDeleting] = React.useState(false); + const [error, setError] = React.useState(); + + const handleDelete = React.useCallback(async () => { + setError(undefined); + setIsDeleting(true); + try { + await onDelete(); + onClose(); + } catch (e) { + setError(getErrorMessage(e)); + } finally { + setIsDeleting(false); + } + }, [onDelete, onClose]); + + return ( + + + + + + Are you sure you want to delete conversation from {conversation.text}? + + {error && ( + + + {error} + + + )} + + + + + + + + ); +}; + +export default DeleteConversationModal;