Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 107 additions & 49 deletions libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ 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 }[] };

type ChatBotHistoryProps = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
onApiCall: typeof fetch;
startNewConversation: VoidFunction;
startNewConversation: (closeDrawer?: boolean) => void;
loadConversation: (id: string) => Promise<unknown>;
conversationId?: string;
};
Expand All @@ -27,10 +29,30 @@ const ChatBotHistory = ({
startNewConversation,
loadConversation,
}: React.PropsWithChildren<ChatBotHistoryProps>) => {
const [deleteConversation, setDeleteConversation] = React.useState<string>();
const [isLoading, setIsLoading] = React.useState(true);
const [conversations, setConversations] = React.useState<Conversation[]>([]);
const [error, setError] = React.useState<string>();

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;
Expand All @@ -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) {
Expand All @@ -64,44 +74,92 @@ const ChatBotHistory = ({
}
})();
return () => abortController.abort();
}, [isOpen, onApiCall]);
}, [isOpen, fetchConversations]);

return (
<ChatbotConversationHistoryNav
setIsDrawerOpen={setIsOpen}
isDrawerOpen={isOpen}
drawerContent={children}
displayMode={ChatbotDisplayMode.default}
onDrawerToggle={() => {
setIsOpen(!isOpen);
}}
isLoading={isLoading}
conversations={conversations}
onNewChat={startNewConversation}
onSelectActiveItem={(_, itemId) => {
itemId !== undefined && void loadConversation(`${itemId}`);
setIsOpen(!isOpen);
}}
activeItemId={conversationId}
errorState={
error
? {
bodyText: (
<Alert variant="danger" isInline title="Failed to load conversation history">
{error}
</Alert>
),
<>
<ChatbotConversationHistoryNav
setIsDrawerOpen={setIsOpen}
isDrawerOpen={isOpen}
drawerContent={children}
displayMode={ChatbotDisplayMode.default}
onDrawerToggle={() => {
setIsOpen(!isOpen);
}}
isLoading={isLoading}
conversations={conversations.map<Conversation>((c) => ({
...c,
additionalProps: {
actions: (
<MenuItemAction
icon={<TrashAltIcon />}
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: (
<Alert variant="danger" isInline title="Failed to load conversation history">
{error}
</Alert>
),
}
: undefined
}
emptyState={
!isLoading && !conversations.length
? {
bodyText: 'No conversation history',
}
: undefined
}
/>
{deleteConversation && (
<DeleteConversationModal
conversation={conversations.find(({ id }) => 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();
}}
/>
)}
</>
);
};

Expand Down
4 changes: 2 additions & 2 deletions libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
89 changes: 89 additions & 0 deletions libs/chatbot/lib/components/ChatBot/DeleteConversationModal.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>;
};

const DeleteConversationModal = ({
onClose,
conversation,
onDelete,
}: DeleteConversationModalProps) => {
const [isDeleting, setIsDeleting] = React.useState(false);
const [error, setError] = React.useState<string>();

const handleDelete = React.useCallback(async () => {
setError(undefined);
setIsDeleting(true);
try {
await onDelete();
onClose();
} catch (e) {
setError(getErrorMessage(e));
} finally {
setIsDeleting(false);
}
}, [onDelete, onClose]);

return (
<Modal
isOpen
onClose={isDeleting ? undefined : onClose}
ouiaId="DeleteConversationModal"
aria-labelledby="delete-conversation-modal"
aria-describedby="modal-box-body-delete-conversation"
variant="small"
disableFocusTrap
>
<ModalHeader
titleIconVariant="danger"
title="Delete conversation?"
labelId="delete-conversation-modal"
/>
<ModalBody id="modal-box-body-delete-conversation">
<Stack hasGutter>
<StackItem>
Are you sure you want to delete conversation from <b>{conversation.text}</b>?
</StackItem>
{error && (
<StackItem>
<Alert isInline variant="danger" title="Failed to delete conversation">
{error}
</Alert>
</StackItem>
)}
</Stack>
</ModalBody>
<ModalFooter>
<Button
key="confirm"
variant="danger"
onClick={() => void handleDelete()}
isDisabled={isDeleting}
isLoading={isDeleting}
>
Delete
</Button>
<Button key="cancel" variant="link" onClick={onClose} isDisabled={isDeleting}>
Cancel
</Button>
</ModalFooter>
</Modal>
);
};

export default DeleteConversationModal;
Loading