diff --git a/src/constants/request.ts b/src/constants/request.ts index 87274ffe9..cc3aa4274 100644 --- a/src/constants/request.ts +++ b/src/constants/request.ts @@ -76,7 +76,8 @@ export const URLs = { messages: { get: '/messages', post: '/messages', - delete: '/messages' + delete: '/messages', + patch: '/messages' }, quizzes: { get: '/quizzes', diff --git a/src/constants/translations/en/chat.json b/src/constants/translations/en/chat.json index b5cc69b6f..e8ac84f73 100644 --- a/src/constants/translations/en/chat.json +++ b/src/constants/translations/en/chat.json @@ -31,6 +31,18 @@ "sendingMessage": "Sending...", "interlocutor": "Your interlocutor" }, + "chatMenu": { + "deleteChat": "Delete chat", + "markingAsDeletedTitle": "Mark chat as deleted?", + "markingAsDeletedWarning": "This operation will delete chat only for you. Your interlocutor will still have access to this chat. Are you sure?", + "fullDeleteTitle": "Confirm chat deletion", + "fullDeleteWarning": "This operation is irreversible and your chat will be deleted completely and permanently!", + "deleteSuccess": "Your chat was deleted successfully!", + "clearHistory": "Clear history", + "clearHistoryTitle": "Do you want to clear your chat history?", + "clearHistoryWarning": "Your interlocutor will still have access to all old messages. Are you sure?", + "historyClearSuccess": "Chat history cleared successfully!" + }, "youCanAsk": "You can ask:", "firstQuestion": { "teachMethod": "What’s your teaching method?", @@ -38,13 +50,6 @@ "tutoringSession": "How long is each tutoring session?" }, "creating": "Creating new chat for you, please wait...", - "deleteSuccess": "Your chat was deleted successfully!", - "fullDeleteTitle": "Confirm chat deletion", - "markingAsDeletedTitle": "Mark chat as deleted?", - "fullDeleteWarning": "This operation is irreversible and your chat will be deleted completely and permanently!", - "markingAsDeletedWarning": "This operation will delete chat only for you. Your interlocutor will still have access to this chat. Are you sure?", "deleted": "Chat was deleted", - "deletedChip": "{{userName}} deleted this chat, and now it is read-only", - "deleteChat": "Delete chat", - "clearHistory": "Clear history" + "deletedChip": "{{userName}} deleted this chat, and now it is read-only" } diff --git a/src/constants/translations/en/errors.json b/src/constants/translations/en/errors.json index b677e171e..da07bb3e9 100644 --- a/src/constants/translations/en/errors.json +++ b/src/constants/translations/en/errors.json @@ -10,5 +10,6 @@ "UNAUTHORIZED": "User is not authorized", "EMAIL_NOT_FOUND": "There is no user registered with that email", "BAD_RESET_TOKEN": "Your time is up please try again", - "DOCUMENT_NOT_FOUND": "Not found" + "DOCUMENT_NOT_FOUND": "Not found", + "UNKNOWN_ERROR": "Something went wrong" } diff --git a/src/constants/translations/ua/chat.json b/src/constants/translations/ua/chat.json index 8ccce7537..3a4ea150b 100644 --- a/src/constants/translations/ua/chat.json +++ b/src/constants/translations/ua/chat.json @@ -31,6 +31,18 @@ "sendingMessage": "Надсилання...", "interlocutor": "Ваш співрозмовник" }, + "chatMenu": { + "deleteChat": "Видалити чат", + "markingAsDeletedTitle": "Позначити чат як видалений?", + "markingAsDeletedWarning": "Ця операція видалить чат лише для вас. Ваш співрозмовник як і раніше матиме доступ до цього чату. Ви впевнені?", + "fullDeleteTitle": "Підтвердіть видалення чату", + "fullDeleteWarning": "Ця операція є незворотною, і ваш чат буде видалено повністю та назавжди!", + "deleteSuccess": "Ваш чат успішно видалено!", + "clearHistory": "Очистити історію", + "clearHistoryTitle": "Бажаєте очистити історію чату?", + "clearHistoryWarning": "Ваш співрозмовник як і раніше матиме доступ до всіх старих повідомлень. Ви впевнені?", + "historyClearSuccess": "Історія чату успішно очищена!" + }, "youCanAsk": "Ви можете запитати:", "firstQuestion": { "teachMethod": "Який ваш метод навчання?", @@ -38,13 +50,6 @@ "tutoringSession": "Яка тривалість кожного уроку?" }, "creating": "Створюємо новий чат, зачекайте...", - "deleteSuccess": "Ваш чат успішно видалено!", - "fullDeleteTitle": "Підтвердіть видалення чату", - "markingAsDeletedTitle": "Позначити чат як видалений?", - "fullDeleteWarning": "Ця операція є незворотною, і ваш чат буде видалено повністю та назавжди!", - "markingAsDeletedWarning": "Ця операція видалить чат лише для вас. Ваш співрозмовник як і раніше матиме доступ до цього чату. Ви впевнені?", "deleted": "Чат було видалено", - "deletedChip": "Користувач {{userName}} видалив цей чат, і тепер він доступний лише для читання", - "deleteChat": "Видалити чат", - "clearHistory": "Очистити історію" + "deletedChip": "Користувач {{userName}} видалив цей чат, і тепер він доступний лише для читання" } diff --git a/src/constants/translations/ua/errors.json b/src/constants/translations/ua/errors.json index b3d2ca75c..ab4a878ce 100644 --- a/src/constants/translations/ua/errors.json +++ b/src/constants/translations/ua/errors.json @@ -10,5 +10,6 @@ "UNAUTHORIZED": "Користувач не авторизований", "EMAIL_NOT_FOUND": "Користувача з таким email не знайдено", "BAD_RESET_TOKEN": "Час вийшов, спробуйте знову", - "DOCUMENT_NOT_FOUND": "Не знайдено" + "DOCUMENT_NOT_FOUND": "Не знайдено", + "UNKNOWN_ERROR": "Щось пішло не так" } diff --git a/src/containers/chat/chat-header/ChatHeader.tsx b/src/containers/chat/chat-header/ChatHeader.tsx index b21123eca..5acb50831 100644 --- a/src/containers/chat/chat-header/ChatHeader.tsx +++ b/src/containers/chat/chat-header/ChatHeader.tsx @@ -20,6 +20,7 @@ interface ChatHeaderProps { onClick: (e?: MouseEvent) => void onMenuClick: (e: MouseEvent) => void updateChats: () => Promise + updateMessages: () => Promise currentChat: ChatResponse user: Pick messages: { text: string }[] @@ -31,6 +32,7 @@ const ChatHeader: FC = ({ onClick, user, updateChats, + updateMessages, onMenuClick, currentChat, messages, @@ -83,8 +85,10 @@ const ChatHeader: FC = ({ {isMobile && ( diff --git a/src/containers/layout/chat-menu/ChatMenu.tsx b/src/containers/layout/chat-menu/ChatMenu.tsx index 36daa99c9..93204a436 100644 --- a/src/containers/layout/chat-menu/ChatMenu.tsx +++ b/src/containers/layout/chat-menu/ChatMenu.tsx @@ -19,32 +19,41 @@ import { defaultResponses, snackbarVariants } from '~/constants' interface ChatMenuProps { anchorEl: Element | null currentChat: ChatResponse + messagesLength: number onClose: () => void updateChats: () => Promise + updateMessages: () => Promise } const ChatMenu: FC = ({ anchorEl, currentChat, + messagesLength, onClose, - updateChats + updateChats, + updateMessages }) => { const { t } = useTranslation() const { openDialog } = useConfirm() const { setAlert } = useSnackBarContext() - const onResponse = useCallback(() => { - setAlert({ - severity: snackbarVariants.success, - message: 'chatPage.deleteSuccess' - }) - }, [setAlert]) + const onResponse = useCallback( + (isDeleting = true) => { + setAlert({ + severity: snackbarVariants.success, + message: isDeleting + ? 'chatPage.chatMenu.deleteSuccess' + : 'chatPage.chatMenu.historyClearSuccess' + }) + }, + [setAlert] + ) const onResponseError = useCallback( (error: ErrorResponse) => { setAlert({ severity: snackbarVariants.error, - message: error ? `errors.${error.code}` : '' + message: error ? `errors.${error.code}` : 'errors.UNKNOWN_ERROR' }) }, [setAlert] @@ -67,6 +76,12 @@ const ChatMenu: FC = ({ [] ) + const clearHistoryService = useCallback( + (id?: string): Promise => + messageService.clearChatHistory(id ?? ''), + [] + ) + const { fetchData: markAsDeleted } = useAxios({ service: markAsDeletedService, defaultResponse: defaultResponses.object, @@ -91,6 +106,14 @@ const ChatMenu: FC = ({ fetchOnMount: false }) + const { fetchData: clearHistory } = useAxios({ + service: clearHistoryService, + defaultResponse: defaultResponses.object, + onResponse: () => onResponse(false), + onResponseError, + fetchOnMount: false + }) + const handleDeletion = async ( id: string, isConfirmed: boolean, @@ -106,9 +129,23 @@ const ChatMenu: FC = ({ } } - const onClearHistory = (e: MouseEvent) => { + const handleClearChat = async (id: string, isConfirmed: boolean) => { + if (isConfirmed) { + await clearHistory(id) + await updateMessages() + } + } + + const onClearHistory = (e: MouseEvent, id: string) => { e.stopPropagation() onClose() + + openDialog({ + message: 'chatPage.chatMenu.clearHistoryWarning', + sendConfirm: (isConfirmed: boolean) => + void handleClearChat(id, isConfirmed), + title: 'chatPage.chatMenu.clearHistoryTitle' + }) } const onDelete = (e: MouseEvent, id: string) => { @@ -118,13 +155,13 @@ const ChatMenu: FC = ({ openDialog({ message: deletingFully - ? 'chatPage.fullDeleteWarning' - : 'chatPage.markingAsDeletedWarning', + ? 'chatPage.chatMenu.fullDeleteWarning' + : 'chatPage.chatMenu.markingAsDeletedWarning', sendConfirm: (isConfirmed: boolean) => void handleDeletion(id, isConfirmed, deletingFully), title: deletingFully - ? 'chatPage.fullDeleteTitle' - : 'chatPage.markingAsDeletedTitle' + ? 'chatPage.chatMenu.fullDeleteTitle' + : 'chatPage.chatMenu.markingAsDeletedTitle' }) } @@ -132,13 +169,16 @@ const ChatMenu: FC = ({ { _id: 1, icon: , - name: t('chatPage.clearHistory'), - handleOnClick: (e: MouseEvent) => onClearHistory(e) + isAvailable: !!messagesLength, + name: t('chatPage.chatMenu.clearHistory'), + handleOnClick: (e: MouseEvent) => + onClearHistory(e, currentChat._id) }, { _id: 2, icon: , - name: t('chatPage.deleteChat'), + isAvailable: !!currentChat, + name: t('chatPage.chatMenu.deleteChat'), handleOnClick: (e: MouseEvent) => onDelete(e, currentChat._id), isDangerous: true @@ -148,6 +188,7 @@ const ChatMenu: FC = ({ const menuItems = menuButtons.map((item) => ( { onFilteredMessagesChange={handleFilteredMessage} onMenuClick={openChatsHandler} updateChats={handleUpdateChats} + updateMessages={fetchData} user={userToSpeak?.user} /> )} diff --git a/src/services/message-service.ts b/src/services/message-service.ts index 9695e7008..0f45807f6 100644 --- a/src/services/message-service.ts +++ b/src/services/message-service.ts @@ -21,5 +21,9 @@ export const messageService = { ): Promise> => { const chat = createUrlPath(URLs.chats.delete, chatId) return axiosClient.delete(`${chat}${URLs.messages.delete}`) + }, + clearChatHistory: async (chatId: string): Promise => { + const chat = createUrlPath(URLs.chats.patch, chatId) + return axiosClient.patch(`${chat}${URLs.messages.patch}`) } } diff --git a/tests/unit/containers/layout/chat-menu/ChatMenu.spec.jsx b/tests/unit/containers/layout/chat-menu/ChatMenu.spec.jsx index a6915c966..a3b261a9c 100644 --- a/tests/unit/containers/layout/chat-menu/ChatMenu.spec.jsx +++ b/tests/unit/containers/layout/chat-menu/ChatMenu.spec.jsx @@ -1,9 +1,10 @@ -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, cleanup, waitFor } from '@testing-library/react' import { renderWithProviders } from '~tests/test-utils' import { expect, vi } from 'vitest' import ChatMenu from '~/containers/layout/chat-menu/ChatMenu' const mockOpenDialog = vi.fn() +const mockSetAlert = vi.fn() vi.mock('~/hooks/use-confirm', () => { return { default: () => ({ @@ -27,7 +28,7 @@ vi.mock('~/context/snackbar-context', async () => { const actual = await vi.importActual('~/context/snackbar-context') return { useSnackBarContext: vi.fn(() => ({ - setAlert: vi.fn() + setAlert: mockSetAlert })), ...actual } @@ -50,55 +51,100 @@ describe('ChatMenu Component', () => { _id: '1', deletedFor: [] } + const messages = { + len: 0 + } const onClose = vi.fn() const updateChats = vi.fn() + const updateMessages = vi.fn() - beforeEach(() => { + const renderComponent = () => renderWithProviders( ) + + beforeEach(() => { + renderComponent() }) it('renders without errors', () => { - expect(screen.getByText('chatPage.clearHistory')).toBeInTheDocument() - expect(screen.getByText('chatPage.deleteChat')).toBeInTheDocument() + expect( + screen.getByText('chatPage.chatMenu.clearHistory') + ).toBeInTheDocument() + expect(screen.getByText('chatPage.chatMenu.deleteChat')).toBeInTheDocument() }) - it('handles Clear History button click', () => { - const clearHistoryButton = screen.getByText('chatPage.clearHistory') + it('should disable Clear History button if there are no messages', () => { + const clearHistoryButton = screen.getByText( + 'chatPage.chatMenu.clearHistory' + ) fireEvent.click(clearHistoryButton) + expect(clearHistoryButton).toHaveAttribute('disabled') + expect(onClose).not.toHaveBeenCalled() + expect(updateMessages).not.toHaveBeenCalled() + }) + + it('handles Clear History button click', async () => { + messages.len = 5 + cleanup() + renderComponent() + + const clearHistoryButton = screen.getByText( + 'chatPage.chatMenu.clearHistory' + ) + + fireEvent.click(clearHistoryButton) + const confirmFunction = mockOpenDialog.mock.calls[0][0].sendConfirm + await confirmFunction(true) + expect(onClose).toHaveBeenCalled() + expect(mockOpenDialog).toHaveBeenCalledWith({ + message: 'chatPage.chatMenu.clearHistoryWarning', + sendConfirm: expect.any(Function), + title: 'chatPage.chatMenu.clearHistoryTitle' + }) + waitFor(() => expect(updateMessages).toHaveBeenCalled()) }) - it('handles Delete button click (mark as deleted)', () => { - const deleteButton = screen.getByText('chatPage.deleteChat') - const modalTitle = 'chatPage.markingAsDeletedTitle' - const modalDescription = 'chatPage.markingAsDeletedWarning' + it('handles Delete button click (mark as deleted)', async () => { + const deleteButton = screen.getByText('chatPage.chatMenu.deleteChat') fireEvent.click(deleteButton) + const confirmFunction = mockOpenDialog.mock.calls[1][0].sendConfirm + await confirmFunction(true) expect(onClose).toHaveBeenCalled() - expect(modalTitle).toBe(mockOpenDialog.mock.calls[0][0].title) - expect(modalDescription).toBe(mockOpenDialog.mock.calls[0][0].message) + expect(mockOpenDialog).toHaveBeenCalledWith({ + message: 'chatPage.chatMenu.markingAsDeletedWarning', + sendConfirm: expect.any(Function), + title: 'chatPage.chatMenu.markingAsDeletedTitle' + }) + waitFor(() => expect(updateChats).toHaveBeenCalled()) }) it('handles Delete button click (fully deleting)', async () => { - const deleteButton = screen.getByText('chatPage.deleteChat') - const modalTitle = 'chatPage.fullDeleteTitle' - const modalDescription = 'chatPage.fullDeleteWarning' + const deleteButton = screen.getByText('chatPage.chatMenu.deleteChat') currentChat.deletedFor = ['user1'] fireEvent.click(deleteButton) + const confirmFunction = mockOpenDialog.mock.calls[2][0].sendConfirm + await confirmFunction(true) expect(onClose).toHaveBeenCalled() - expect(modalTitle).toBe(mockOpenDialog.mock.calls[1][0].title) - expect(modalDescription).toBe(mockOpenDialog.mock.calls[1][0].message) + expect(mockOpenDialog).toHaveBeenCalledWith({ + message: 'chatPage.chatMenu.fullDeleteWarning', + sendConfirm: expect.any(Function), + title: 'chatPage.chatMenu.fullDeleteTitle' + }) + waitFor(() => expect(updateChats).toHaveBeenCalled()) }) })