diff --git a/src/containers/AskGlific/AskGlific.module.css b/src/containers/AskGlific/AskGlific.module.css index e79013dd1..5cc15bac2 100644 --- a/src/containers/AskGlific/AskGlific.module.css +++ b/src/containers/AskGlific/AskGlific.module.css @@ -476,3 +476,497 @@ color: #119656 !important; } +.Container { + height: 100%; + width: 100%; + background-color: #fff; + overflow: hidden; + display: flex; + flex-direction: column; + border-radius: 1rem; +} + +.Header { + padding: 0.75rem 1rem; + background-color: #fff; + color: #2e2e2e; + font-weight: 600; + font-size: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + border-bottom: 1px solid #f0f0f0; +} + +.HeaderLeft { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.HeaderLeft span { + font-family: Heebo; + font-weight: 500; + font-size: 13px; + line-height: 18px; + letter-spacing: 0; + vertical-align: middle; + color: #191c1a; +} + +.HeaderLeft svg { + width: 20px; + height: 20px; +} + +.ChatIcon { + width: 32px; + height: 32px; + background-color: #119656; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.ChatIcon svg { + width: 18px; + height: 18px; +} + +.HeaderRight { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.HeaderIconButton { + padding: 0.35rem !important; + color: #555 !important; +} + +.HeaderIconButton:hover { + background-color: #f5f5f5 !important; +} + +.DisplayModeItem { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.9rem; + color: #333; + min-width: 160px; +} + +.DisplayModeItem:hover { + background-color: #f5f5f5; +} + +.DisplayModeItemActive { + composes: DisplayModeItem; + color: #119656; + font-weight: 500; +} + +.DisplayModeItemActive svg { + color: #119656; +} + +.WelcomeSection { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem; + gap: 1rem; +} + +.WelcomeIcon { + width: 56px; + height: 56px; + background-color: #119656; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.WelcomeIcon svg { + width: 28px; + height: 28px; +} + +.WelcomeText { + color: #191c1a; + text-align: center; + font-family: Heebo; + font-weight: 500; + font-size: 0.8rem; + line-height: 18px; + letter-spacing: 0; + vertical-align: middle; +} + +.SuggestionsGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + width: 100%; + max-width: 420px; + margin-top: 0.5rem; +} + +.SuggestionCard { + padding: 0.5rem 1rem; + border-radius: 0.75rem; + cursor: pointer; + color: #191c1a; + background-color: #f5f5f5; + font-size: 0.85rem; + text-align: center; + transition: background-color 0.2s; + border: 1px solid #e8e8e8; + font-family: Heebo; + font-weight: 400; + font-size: 0.75rem; + line-height: 18px; + letter-spacing: 0; + vertical-align: middle; +} + +.SuggestionCard:hover { + background-color: #eee; +} + +.Messages { + flex: 1; + width: 100%; + padding: 1rem; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.Message { + max-width: 80%; + font-size: 0.8rem; + word-wrap: break-word; + line-height: 1.5; + font-family: Heebo; + font-weight: 400; + letter-spacing: 0; + vertical-align: middle; +} + +.System { + composes: Message; + align-self: flex-start; + color: #2e2e2e; + padding: 0.25rem 0; +} + +.User { + composes: Message; + align-self: flex-end; + background-color: #f0f0f0; + color: #2e2e2e; + padding: 0.6rem 1rem; + border-radius: 1.25rem; +} + +.SystemWrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; +} + +.FeedbackButtons { + display: flex; + gap: 0.25rem; +} + +.FeedbackButton { + padding: 0.2rem !important; + color: #bbb !important; +} + +.FeedbackButton:hover { + color: #777 !important; +} + +.FeedbackButtonActive { + composes: FeedbackButton; + color: #119656 !important; +} + +.LoadingContainer { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0; + color: #888; + font-size: 0.85rem; +} + +.LoadingIcon { + animation: spin 1.5s linear infinite; + width: 18px; + height: 18px; + color: #119656; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.InputContainer { + width: 100%; + padding: 0.75rem 1rem 1rem; +} + +.InputWrapper { + border: 2px solid #119656; + border-radius: 0.75rem; + overflow: hidden; + display: flex; + flex-direction: column; + background-color: #fff; +} + +.TextArea { + width: 100%; + border: none; + outline: none; + padding: 0.75rem 1rem; + font-size: 0.95rem; + font-family: inherit; + resize: none; + min-height: 60px; + max-height: 120px; + color: #2e2e2e; +} + +.TextArea::placeholder { + color: #aaa; +} + +.InputFooter { + display: flex; + justify-content: end; + align-items: center; + padding: 0.25rem 0.75rem 0.5rem; +} + +.InputActions { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.SendButton { + width: 32px !important; + height: 32px !important; + background-color: #119656 !important; + color: #fff !important; + padding: 0 !important; + min-width: unset !important; +} + +.SendButton:disabled { + background-color: #ccc !important; + color: #fff !important; +} + +.SendButton svg { + font-size: 1.1rem; +} + +.FloatingWrapper { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 1300; + border-radius: 1rem; + box-shadow: 0 8px 32px rgba(0, 0, 0 / 15%); + overflow: hidden; + width: 414px; + height: 528px; + opacity: 1; +} + +.SidebarWrapper { + position: fixed; + top: 0; + right: 0; + width: 680px; + height: 100vh; + z-index: 1300; + box-shadow: -4px 0 24px rgba(0, 0, 0 /10%); + overflow: hidden; + display: flex; + flex-direction: row; +} + +.SidebarWrapper .Container { + border-radius: 0; +} + +.FullscreenWrapper { + position: fixed; + top: 0; + right: 0; + width: 100%; + height: 100vh; + z-index: 1300; + overflow: hidden; + display: flex; + flex-direction: row; +} + +.FullscreenWrapper .Container { + border-radius: 0; +} + +.HistoryPanel { + width: 260px; + min-width: 260px; + height: 100%; + background-color: #fff; + border-right: 1px solid #f0f0f0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.HistoryHeader { + padding: 0.75rem 1rem; + font-family: Heebo; + font-weight: 600; + font-size: 1rem; + color: #191c1a; + border-bottom: 1px solid #f0f0f0; +} + +.HistoryDropdown { + /* background-color: ; */ +} + +.HistoryList { + flex: 1; + overflow-y: auto; + padding: 0.5rem 0; +} + +.LoadMoreButton { + padding: 0.5rem 1rem; + text-align: center; + color: #119656; + font-size: 0.8rem; + cursor: pointer; + font-weight: 500; +} + +.LoadMoreButton:hover { + text-decoration: underline; +} + +.HistoryDateLabel { + padding: 0.5rem 1rem 0.25rem; + font-family: Heebo; + font-size: 0.7rem; + font-weight: 500; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.HistoryItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + cursor: pointer; + transition: background-color 0.15s; +} + +.HistoryItem:hover { + background-color: #f5f5f5; +} + +.HistoryItemActive { + background-color: #f5f5f5; +} + +.HistoryItemActive .HistoryItemTitle { + color: #119656; +} + +.HistoryItemContent { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + flex: 1; +} + +.HistoryItemTitle { + font-family: Heebo; + font-size: 0.85rem; + font-weight: 500; + color: #191c1a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.HistoryItemTime { + font-family: Heebo; + font-size: 0.7rem; + color: #999; +} + +.HistoryItemMenu { + color: #bbb !important; + padding: 0.2rem !important; + opacity: 0; + transition: opacity 0.15s; +} + +.HistoryItem:hover .HistoryItemMenu { + opacity: 1; +} + +.HistoryDropdownDate { + font-family: Heebo !important; + font-size: 0.7rem !important; + font-weight: 500 !important; + color: #888 !important; + text-transform: uppercase !important; + letter-spacing: 0.5px !important; + padding: 0.5rem 1rem 0.25rem !important; + min-height: unset !important; +} + +.HistoryDropdownItem { + font-family: Heebo !important; + font-size: 0.85rem !important; + font-weight: 500 !important; + color: #191c1a !important; + padding: 0.4rem 1rem !important; + min-width: 160px; +} + +.HistoryDropdownItemActive { + composes: HistoryDropdownItem; + color: #119656 !important; +} diff --git a/src/containers/AskGlific/AskGlific.test.tsx b/src/containers/AskGlific/AskGlific.test.tsx index bd18693b0..9fcd54086 100644 --- a/src/containers/AskGlific/AskGlific.test.tsx +++ b/src/containers/AskGlific/AskGlific.test.tsx @@ -1,15 +1,115 @@ import { MockedProvider } from '@apollo/client/testing'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { ASK_GLIFIC } from 'graphql/mutations/AskGlific'; +import { ASK_GLIFIC, ASK_GLIFIC_FEEDBACK } from 'graphql/mutations/AskGlific'; +import { GET_ASKME_BOT_CONVERSATIONS, GET_ASKME_BOT_MESSAGES } from 'graphql/queries/AskGlific'; import AskGlific from './AskGlific'; -const AskGlificMock = { +const now = Math.floor(Date.now() / 1000); + +const conversationsMock = { + request: { + query: GET_ASKME_BOT_CONVERSATIONS, + variables: { limit: 10, lastId: '' }, + }, + result: { + data: { + askmeBotConversations: { + conversations: [], + hasMore: false, + limit: 10, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, +}; + +const conversationsWithDataMock = { + request: { + query: GET_ASKME_BOT_CONVERSATIONS, + variables: { limit: 10, lastId: '' }, + }, + result: { + data: { + askmeBotConversations: { + conversations: [ + { + id: 'conv-abc', + name: 'Test Conversation', + status: 'normal', + createdAt: now, + updatedAt: now, + }, + { + id: 'conv-abc', + name: 'Test Conversation 2', + status: 'normal', + createdAt: now - 3600, + updatedAt: now - 60, + }, + { + id: 'conv-y', + name: 'Yesterday Chat', + status: 'normal', + createdAt: Math.floor(Date.now() / 1000) - 3600 * 3, + updatedAt: Math.floor(Date.now() / 1000) - 3600 * 3, + }, + { + id: 'conv-y', + name: 'Yesterday Chat', + status: 'normal', + createdAt: Math.floor(Date.now() / 1000) - 86400 - 100, + updatedAt: Math.floor(Date.now() / 1000) - 86400 - 100, + }, + { + id: 'conv-o', + name: 'Old Chat', + status: 'normal', + createdAt: Math.floor(Date.now() / 1000) - 86400 * 5, + updatedAt: Math.floor(Date.now() / 1000) - 86400 * 5, + }, + ], + hasMore: false, + limit: 10, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, +}; + +const messagesMock = { + request: { + query: GET_ASKME_BOT_MESSAGES, + variables: { conversationId: 'conv-abc', limit: 50 }, + }, + result: { + data: { + askGlificMessages: { + messages: [ + { + id: 'msg-1', + conversationId: 'conv-abc', + query: 'Hello bot', + answer: 'Hi there! How can I help?', + createdAt: now - 3000, + feedback: null, + }, + ], + hasMore: false, + limit: 50, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, +}; + +const createAskGlificMock = (query: string) => ({ request: { query: ASK_GLIFIC, variables: { input: { - query: 'Create your first chatbot', + query, conversationId: '', + pageUrl: window.location.href, }, }, }, @@ -18,10 +118,72 @@ const AskGlificMock = { askGlific: { answer: 'This is a mock response from the bot.', conversationId: 'conv-123', + conversationName: 'Test Chat', + messageId: 'msg-new-001', errors: null, }, }, }, +}); + +const suggestionMock = createAskGlificMock('Create your first chatbot'); + +const askGlificErrorMock = { + request: { + query: ASK_GLIFIC, + variables: { + input: { + query: 'Create your first chatbot', + conversationId: '', + pageUrl: window.location.href, + }, + }, + }, + error: new Error('Network error'), +}; + +const feedbackMock = { + request: { + query: ASK_GLIFIC_FEEDBACK, + variables: { + input: { + messageId: 'msg-new-001', + rating: 'like', + }, + }, + }, + result: { + data: { + askGlificFeedback: { + success: true, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, +}; + +const feedbackDislikeMock = { + request: { + query: ASK_GLIFIC_FEEDBACK, + variables: { + input: { + messageId: 'msg-new-001', + rating: 'dislike', + }, + }, + }, + result: { + data: { + askGlificFeedback: { + success: true, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, +}; + +const openPanel = () => { + fireEvent.click(screen.getByTestId('ask-glific-fab')); }; describe('AskGlific', () => { @@ -32,7 +194,7 @@ describe('AskGlific', () => { test('should render AskGlific component', async () => { render( - + ); @@ -41,7 +203,7 @@ describe('AskGlific', () => { expect(screen.getByTestId('ask-glific-fab')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('ask-glific-fab')); + openPanel(); await waitFor(() => { expect(screen.getByText('Ask Glific! Learn About How It Works?')).toBeInTheDocument(); @@ -56,12 +218,12 @@ describe('AskGlific', () => { test('it should send messages from suggestion', async () => { render( - + ); - fireEvent.click(screen.getByTestId('ask-glific-fab')); + openPanel(); await waitFor(() => { expect(screen.getByText('Ask Glific! Learn About How It Works?')).toBeInTheDocument(); @@ -80,12 +242,12 @@ describe('AskGlific', () => { test('it should allow new chat', async () => { render( - + ); - fireEvent.click(screen.getByTestId('ask-glific-fab')); + openPanel(); fireEvent.click(screen.getAllByTestId('suggestion')[0]); await waitFor(() => { @@ -101,12 +263,12 @@ describe('AskGlific', () => { test('it should show feedback buttons on bot responses', async () => { render( - + ); - fireEvent.click(screen.getByTestId('ask-glific-fab')); + openPanel(); fireEvent.click(screen.getAllByTestId('suggestion')[0]); await waitFor(() => { @@ -116,4 +278,611 @@ describe('AskGlific', () => { expect(screen.getByTestId('feedback-up')).toBeInTheDocument(); expect(screen.getByTestId('feedback-down')).toBeInTheDocument(); }); + + test('it should toggle feedback on click', async () => { + render( + + + + ); + + openPanel(); + fireEvent.click(screen.getAllByTestId('suggestion')[0]); + + await waitFor(() => { + expect(screen.getByText('This is a mock response from the bot.')).toBeInTheDocument(); + }); + + const thumbUp = screen.getByTestId('feedback-up'); + const thumbDown = screen.getByTestId('feedback-down'); + + fireEvent.click(thumbUp); + fireEvent.click(thumbDown); + fireEvent.click(thumbDown); + + expect(thumbUp).toBeInTheDocument(); + expect(thumbDown).toBeInTheDocument(); + }); + + test('it should send message via text input and Enter key (handleOk + handleKeyDown)', async () => { + const typedMessageMock = createAskGlificMock('How do flows work?'); + + render( + + + + ); + + openPanel(); + + await waitFor(() => { + expect(screen.getByTestId('textbox')).toBeInTheDocument(); + }); + + const textbox = screen.getByTestId('textbox'); + + fireEvent.change(textbox, { target: { value: 'How do flows work?' } }); + fireEvent.keyDown(textbox, { key: 'Enter', shiftKey: false }); + + await waitFor(() => { + expect(screen.getByText('How do flows work?')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('This is a mock response from the bot.')).toBeInTheDocument(); + }); + }); + + test('it should not send message on Shift+Enter', async () => { + render( + + + + ); + + openPanel(); + + const textbox = screen.getByTestId('textbox'); + fireEvent.change(textbox, { target: { value: 'test' } }); + fireEvent.keyDown(textbox, { key: 'Enter', shiftKey: true }); + + expect(screen.queryByText('thinking...')).not.toBeInTheDocument(); + }); + + test('it should not send empty message', async () => { + render( + + + + ); + + openPanel(); + + const sendButton = screen.getByTestId('send-icon'); + expect(sendButton).toBeDisabled(); + + const textbox = screen.getByTestId('textbox'); + fireEvent.change(textbox, { target: { value: ' ' } }); + fireEvent.keyDown(textbox, { key: 'Enter', shiftKey: false }); + + expect(screen.queryByText('thinking...')).not.toBeInTheDocument(); + }); + + test('it should show error message on mutation failure', async () => { + render( + + + + ); + + openPanel(); + fireEvent.click(screen.getAllByTestId('suggestion')[0]); + + await waitFor(() => { + expect( + screen.getByText('Sorry, I encountered an error while processing your request. Please try again.') + ).toBeInTheDocument(); + }); + }); + + test('it should load conversations and select one (loadConversations + handleSelectConversation)', async () => { + render( + + + + ); + + openPanel(); + + await waitFor(() => { + expect(screen.getByText('New chat')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('New chat')); + + await waitFor(() => { + expect(screen.getByText('Test Conversation')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Test Conversation')); + + await waitFor(() => { + expect(screen.getByText('Hello bot')).toBeInTheDocument(); + expect(screen.getByText('Hi there! How can I help?')).toBeInTheDocument(); + }); + }); + + test('it should switch display modes', async () => { + render( + + + + ); + + openPanel(); + + fireEvent.click(screen.getByTestId('display-mode-btn')); + + await waitFor(() => { + expect(screen.getByText('Sidebar')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Sidebar')); + + // Panel should still be visible + expect(screen.getByTestId('ask-me-bot-panel')).toBeInTheDocument(); + }); + + describe('getDateLabel', () => { + test('should group yesterday conversations under Yesterday label', async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(12, 0, 0, 0); + const yesterdayTs = Math.floor(yesterday.getTime() / 1000); + + const mock = { + request: { + query: GET_ASKME_BOT_CONVERSATIONS, + variables: { limit: 10, lastId: '' }, + }, + result: { + data: { + askmeBotConversations: { + conversations: [ + { + id: 'conv-yd', + name: 'Yesterday Date Chat', + status: 'normal', + createdAt: yesterdayTs, + updatedAt: yesterdayTs, + }, + ], + hasMore: false, + limit: 10, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, + }; + + render( + + + + ); + + openPanel(); + fireEvent.click(screen.getByText('New chat')); + + await waitFor(() => { + const yesterdayLabels = screen.getAllByText('Yesterday'); + expect(yesterdayLabels.length).toBeGreaterThanOrEqual(1); + }); + }); + + test('should show formatted date for older conversations', async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + const oldTs = Math.floor(oldDate.getTime() / 1000); + const expectedLabel = oldDate.toLocaleDateString(); + + const mock = { + request: { + query: GET_ASKME_BOT_CONVERSATIONS, + variables: { limit: 10, lastId: '' }, + }, + result: { + data: { + askmeBotConversations: { + conversations: [ + { id: 'conv-old', name: 'Old Date Chat', status: 'normal', createdAt: oldTs, updatedAt: oldTs }, + ], + hasMore: false, + limit: 10, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, + }; + + render( + + + + ); + + openPanel(); + fireEvent.click(screen.getByText('New chat')); + + await waitFor(() => { + // Both the date label and the timeAgo should use toLocaleDateString + const dateLabels = screen.getAllByText(expectedLabel); + expect(dateLabels.length).toBeGreaterThanOrEqual(1); + }); + }); + }); + + test('it should load more messages when load more button is clicked', async () => { + const messagesWithMoreMock = { + request: { + query: GET_ASKME_BOT_MESSAGES, + variables: { conversationId: 'conv-abc', limit: 50 }, + }, + result: { + data: { + askGlificMessages: { + messages: [ + { + id: 'msg-1', + conversationId: 'conv-abc', + query: 'First question', + answer: 'First answer', + createdAt: now - 3000, + feedback: null, + }, + ], + hasMore: true, + limit: 50, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, + }; + + const olderMessagesMock = { + request: { + query: GET_ASKME_BOT_MESSAGES, + variables: { conversationId: 'conv-abc', limit: 50, firstId: 'msg-1' }, + }, + result: { + data: { + askGlificMessages: { + messages: [ + { + id: 'msg-0', + conversationId: 'conv-abc', + query: 'Older question', + answer: 'Older answer', + createdAt: now - 6000, + feedback: null, + }, + ], + hasMore: false, + limit: 50, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, + }; + + render( + + + + ); + + openPanel(); + + fireEvent.click(screen.getByText('New chat')); + await waitFor(() => { + expect(screen.getByText('Test Conversation')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Test Conversation')); + + await waitFor(() => { + expect(screen.getByText('First question')).toBeInTheDocument(); + }); + + const loadMoreBtn = screen.getByTestId('load-more-messages'); + expect(loadMoreBtn).toBeInTheDocument(); + + fireEvent.click(loadMoreBtn); + + await waitFor(() => { + expect(screen.getByText('Older question')).toBeInTheDocument(); + expect(screen.getByText('Older answer')).toBeInTheDocument(); + }); + }); + + test('it should highlight active conversation in history', async () => { + render( + + + + ); + + openPanel(); + + fireEvent.click(screen.getByText('New chat')); + await waitFor(() => { + expect(screen.getByText('Test Conversation')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Test Conversation')); + + await waitFor(() => { + expect(screen.getByText('Hello bot')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Test Conversation')); + + await waitFor(() => { + const menuItems = screen.getAllByRole('menuitem'); + const activeItem = menuItems.find((item) => item.textContent === 'Test Conversation'); + expect(activeItem).toBeInTheDocument(); + }); + }); + + test('it should load more conversations when hasMore is true', async () => { + const conversationsPage1 = { + request: { + query: GET_ASKME_BOT_CONVERSATIONS, + variables: { limit: 10, lastId: '' }, + }, + result: { + data: { + askmeBotConversations: { + conversations: [ + { id: 'conv-1', name: 'First Chat', status: 'normal', createdAt: now - 60, updatedAt: now - 60 }, + ], + hasMore: true, + limit: 10, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, + }; + + const conversationsPage2 = { + request: { + query: GET_ASKME_BOT_CONVERSATIONS, + variables: { limit: 10, lastId: 'conv-1' }, + }, + result: { + data: { + askmeBotConversations: { + conversations: [ + { id: 'conv-2', name: 'Second Chat', status: 'normal', createdAt: now - 7200, updatedAt: now - 7200 }, + ], + hasMore: false, + limit: 10, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, + }; + + render( + + + + ); + + openPanel(); + + fireEvent.click(screen.getByText('New chat')); + + await waitFor(() => { + expect(screen.getByText('First Chat')).toBeInTheDocument(); + }); + + const loadMoreBtn = screen.getByTestId('load-more-conversations-dropdown'); + expect(loadMoreBtn).toBeInTheDocument(); + + fireEvent.click(loadMoreBtn); + + await waitFor(() => { + expect(screen.getByText('Second Chat')).toBeInTheDocument(); + }); + }); + + test('it should close history dropdown when clicking outside', async () => { + render( + + + + ); + + openPanel(); + + fireEvent.click(screen.getByText('New chat')); + + await waitFor(() => { + expect(screen.getByText('Test Conversation')).toBeInTheDocument(); + }); + + const backdrop = document.querySelector('.MuiBackdrop-root'); + if (backdrop) { + fireEvent.click(backdrop); + } + + await waitFor(() => { + expect(screen.queryByText('Test Conversation')).not.toBeInTheDocument(); + }); + }); + + test('it should close display mode menu on item selection', async () => { + render( + + + + ); + + openPanel(); + + fireEvent.click(screen.getByTestId('display-mode-btn')); + + await waitFor(() => { + expect(screen.getByText('Floating')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Floating')); + + await waitFor(() => { + expect(screen.queryByText('Sidebar')).not.toBeInTheDocument(); + }); + }); + + test('it should switch to fullscreen display mode', async () => { + render( + + + + ); + + openPanel(); + + fireEvent.click(screen.getByTestId('display-mode-btn')); + + await waitFor(() => { + expect(screen.getByText('Fullscreen')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Fullscreen')); + + expect(screen.getByTestId('ask-me-bot-panel')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText('Floating')).not.toBeInTheDocument(); + }); + }); + + test('it should show existing feedback when loading chat history', async () => { + const messagesWithFeedbackMock = { + request: { + query: GET_ASKME_BOT_MESSAGES, + variables: { conversationId: 'conv-abc', limit: 50 }, + }, + result: { + data: { + askGlificMessages: { + messages: [ + { + id: 'msg-fb-1', + conversationId: 'conv-abc', + query: 'How does Glific work?', + answer: 'Glific is a communication platform.', + createdAt: now - 3000, + feedback: 'like', + }, + ], + hasMore: false, + limit: 50, + }, + }, + }, + maxUsageCount: Number.MAX_SAFE_INTEGER, + }; + + render( + + + + ); + + openPanel(); + + fireEvent.click(screen.getByText('New chat')); + + await waitFor(() => { + expect(screen.getByText('Test Conversation')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Test Conversation')); + + await waitFor(() => { + expect(screen.getByText('Glific is a communication platform.')).toBeInTheDocument(); + }); + + const thumbUp = screen.getByTestId('feedback-up'); + expect(thumbUp.className).toContain('FeedbackButtonActive'); + }); + + test('it should call feedback mutation when clicking thumbs up', async () => { + const feedbackMutationMock = { + request: { + query: ASK_GLIFIC_FEEDBACK, + variables: { + input: { + messageId: 'msg-new-001', + rating: 'like', + }, + }, + }, + result: { + data: { + askGlificFeedback: { + success: true, + }, + }, + }, + }; + + render( + + + + ); + + openPanel(); + fireEvent.click(screen.getAllByTestId('suggestion')[0]); + + await waitFor(() => { + expect(screen.getByText('This is a mock response from the bot.')).toBeInTheDocument(); + }); + + const thumbUp = screen.getByTestId('feedback-up'); + fireEvent.click(thumbUp); + + expect(thumbUp.className).toContain('FeedbackButtonActive'); + }); + + test('it should show history panel in sidebar mode and select conversation', async () => { + render( + + + + ); + + openPanel(); + + fireEvent.click(screen.getByTestId('display-mode-btn')); + await waitFor(() => { + expect(screen.getByText('Sidebar')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Sidebar')); + + fireEvent.click(screen.getByText('New chat')); + + await waitFor(() => { + expect(screen.getByTestId('history-panel')).toBeInTheDocument(); + expect(screen.getByText('Chat History')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Test Conversation')); + + await waitFor(() => { + expect(screen.getByText('Hello bot')).toBeInTheDocument(); + }); + }); }); diff --git a/src/containers/AskGlific/AskGlific.tsx b/src/containers/AskGlific/AskGlific.tsx index eac0d90ba..a6d732e21 100644 --- a/src/containers/AskGlific/AskGlific.tsx +++ b/src/containers/AskGlific/AskGlific.tsx @@ -1,4 +1,4 @@ -import { useMutation } from '@apollo/client'; +import { useLazyQuery, useMutation } from '@apollo/client'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import CloseIcon from '@mui/icons-material/Close'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; @@ -16,7 +16,8 @@ import Markdown from 'react-markdown'; import AskGlificIcon from 'assets/images/icons/AskGlific/Icon.svg?react'; import EditIcon from 'assets/images/icons/Edit.svg?react'; -import { ASK_GLIFIC } from 'graphql/mutations/AskGlific'; +import { ASK_GLIFIC, ASK_GLIFIC_FEEDBACK } from 'graphql/mutations/AskGlific'; +import { GET_ASKME_BOT_CONVERSATIONS, GET_ASKME_BOT_MESSAGES } from 'graphql/queries/AskGlific'; import styles from './AskGlific.module.css'; interface Message { @@ -25,6 +26,15 @@ interface Message { timestamp?: Date; prompt?: boolean; feedback?: 'up' | 'down' | null; + messageId?: string; +} + +interface DifyConversation { + id: string; + name: string; + status: string; + createdAt: number; + updatedAt: number; } interface ChatHistoryItem { @@ -43,13 +53,34 @@ const QUICK_SUGGESTIONS = [ 'Run a survey using WA forms', ]; -// Mock chat history data — will be replaced with API call -const MOCK_CHAT_HISTORY: ChatHistoryItem[] = [ - { id: '1', title: 'Create Chatbot', timeAgo: '2 mins. ago', date: 'Today' }, - { id: '2', title: 'Testing flows', timeAgo: '10 mins. ago', date: 'Today' }, - { id: '3', title: 'HSM Templates', timeAgo: '2 hours ago', date: 'Today' }, - { id: '4', title: 'Bulk messaging setup', timeAgo: 'Yesterday', date: 'Yesterday' }, -]; +const formatTimeAgo = (timestamp: number): string => { + const now = Date.now() / 1000; + const diff = now - timestamp; + if (diff < 60) return 'Just now'; + if (diff < 3600) return `${Math.floor(diff / 60)} mins ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`; + if (diff < 172800) return 'Yesterday'; + return new Date(timestamp * 1000).toLocaleDateString(); +}; + +const getDateLabel = (timestamp: number): string => { + const date = new Date(timestamp * 1000); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (date.toDateString() === today.toDateString()) return 'Today'; + if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'; + return date.toLocaleDateString(); +}; + +const toHistoryItems = (conversations: DifyConversation[]): ChatHistoryItem[] => + conversations.map((conv) => ({ + id: conv.id, + title: conv.name || 'Untitled', + timeAgo: formatTimeAgo(conv.updatedAt || conv.createdAt), + date: getDateLabel(conv.updatedAt || conv.createdAt), + })); const AskGlific = () => { const [open, setOpen] = useState(false); @@ -60,18 +91,150 @@ const AskGlific = () => { const [displayMenuAnchor, setDisplayMenuAnchor] = useState(null); const [showHistory, setShowHistory] = useState(false); const [historyAnchor, setHistoryAnchor] = useState(null); - const [chatHistory] = useState(MOCK_CHAT_HISTORY); + const [chatHistory, setChatHistory] = useState([]); const [conversationId, setConversationId] = useState(null); + const [conversationName, setConversationName] = useState(null); const messagesEndRef = useRef(null); const textAreaRef = useRef(null); + const [hasMoreConversations, setHasMoreConversations] = useState(false); + const [lastConversationId, setLastConversationId] = useState(''); + const [hasMoreMessages, setHasMoreMessages] = useState(false); + const [firstMessageId, setFirstMessageId] = useState(''); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + const [askGlific] = useMutation(ASK_GLIFIC); + const [submitFeedback] = useMutation(ASK_GLIFIC_FEEDBACK); + const [fetchConversations] = useLazyQuery(GET_ASKME_BOT_CONVERSATIONS, { + fetchPolicy: 'network-only', + }); + const [fetchMessages] = useLazyQuery(GET_ASKME_BOT_MESSAGES, { + fetchPolicy: 'network-only', + }); + + const loadConversations = async (append = false) => { + const { data } = await fetchConversations({ + variables: { limit: 10, lastId: append ? lastConversationId : '' }, + }); + const result = data?.askmeBotConversations; + const conversations: DifyConversation[] = result?.conversations || []; + const newItems = toHistoryItems(conversations); + + setChatHistory((prev) => (append ? [...prev, ...newItems] : newItems)); + setHasMoreConversations(result?.hasMore || false); + if (conversations.length > 0) { + setLastConversationId(conversations[conversations.length - 1].id); + } + }; + + const handleSelectConversation = async (selectedConversationId: string) => { + const selected = chatHistory.find((item) => item.id === selectedConversationId); + setConversationId(selectedConversationId); + setConversationName(selected?.title || null); + setIsLoadingHistory(true); + setMessages([]); + setShowHistory(false); + setHistoryAnchor(null); + + try { + const { data } = await fetchMessages({ + variables: { conversationId: selectedConversationId, limit: 50 }, + }); + const result = data?.askGlificMessages; + const difyMessages = result?.messages || []; + + const loadedMessages: Message[] = difyMessages.flatMap( + (msg: { id: string; query: string; answer: string; createdAt: number; feedback: string | null }) => { + const items: Message[] = []; + if (msg.query) { + items.push({ + role: 'user', + content: msg.query, + timestamp: new Date(msg.createdAt * 1000), + }); + } + if (msg.answer) { + const feedbackValue = msg.feedback === 'like' ? 'up' : msg.feedback === 'dislike' ? 'down' : null; + items.push({ + role: 'system', + content: msg.answer, + timestamp: new Date(msg.createdAt * 1000), + feedback: feedbackValue, + messageId: msg.id, + }); + } + return items; + } + ); + + setMessages(loadedMessages); + setHasMoreMessages(result?.hasMore || false); + if (difyMessages.length > 0) { + setFirstMessageId(difyMessages[0].id); + } + } catch { + setMessages([{ role: 'error', content: 'Failed to load conversation history.', timestamp: new Date() }]); + } finally { + setIsLoadingHistory(false); + } + }; + + const loadMoreMessages = async () => { + if (!conversationId || !hasMoreMessages || isLoadingHistory) return; + setIsLoadingHistory(true); + + try { + const { data } = await fetchMessages({ + variables: { conversationId, limit: 50, firstId: firstMessageId }, + }); + const result = data?.askGlificMessages; + const difyMessages = result?.messages || []; + + const olderMessages: Message[] = difyMessages.flatMap( + (msg: { id: string; query: string; answer: string; createdAt: number; feedback: string | null }) => { + const items: Message[] = []; + if (msg.query) { + items.push({ + role: 'user', + content: msg.query, + timestamp: new Date(msg.createdAt * 1000), + }); + } + if (msg.answer) { + const feedbackValue = msg.feedback === 'like' ? 'up' : msg.feedback === 'dislike' ? 'down' : null; + items.push({ + role: 'system', + content: msg.answer, + timestamp: new Date(msg.createdAt * 1000), + feedback: feedbackValue, + messageId: msg.id, + }); + } + return items; + } + ); + + setMessages((prev) => [...olderMessages, ...prev]); + setHasMoreMessages(result?.hasMore || false); + if (difyMessages.length > 0) { + setFirstMessageId(difyMessages[0].id); + } + } finally { + setIsLoadingHistory(false); + } + }; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; + useEffect(() => { + if (open) { + loadConversations(); + } + }, [open]); + useEffect(() => { scrollToBottom(); }, [messages, isLoading, open]); @@ -85,6 +248,7 @@ const AskGlific = () => { input: { query: msg.content, conversationId: conversationId || '', + pageUrl: window.location.href, }, }, }); @@ -106,6 +270,10 @@ const AskGlific = () => { setConversationId(result.conversationId); } + if (result?.conversationName) { + setConversationName(result.conversationName); + } + const newMessages: Message[] = [ ...currentMessages, { @@ -113,6 +281,7 @@ const AskGlific = () => { content: answer, timestamp: new Date(), feedback: null, + messageId: result?.messageId || undefined, }, ]; @@ -155,20 +324,39 @@ const AskGlific = () => { const handleNewChat = () => { setMessages([]); setConversationId(null); + setConversationName(null); + setHasMoreMessages(false); + setFirstMessageId(''); }; const handleFeedback = (index: number, type: 'up' | 'down') => { + const targetMsg = messages[index]; + const newFeedback = targetMsg.feedback === type ? null : type; + const rating = newFeedback === 'up' ? 'like' : newFeedback === 'down' ? 'dislike' : null; + setMessages((prev) => prev.map((msg, i) => { if (i === index) { - return { ...msg, feedback: msg.feedback === type ? null : type }; + return { ...msg, feedback: newFeedback }; } return msg; }) ); + + if (targetMsg.messageId) { + submitFeedback({ + variables: { + input: { + messageId: targetMsg.messageId, + rating: rating ?? 'dislike', + }, + }, + }); + } }; const getChatTitle = (): string => { + if (conversationName) return conversationName; const firstUserMessage = messages.find((m) => m.role === 'user' && !m.prompt); if (!firstUserMessage) return 'New chat'; const title = firstUserMessage.content; @@ -237,8 +425,8 @@ const AskGlific = () => { {items.map((item) => (
setShowHistory(false)} + className={`${styles.HistoryItem} ${item.id === conversationId ? styles.HistoryItemActive : ''}`} + onClick={() => handleSelectConversation(item.id)} >
{item.title} @@ -251,6 +439,15 @@ const AskGlific = () => { ))}
))} + {hasMoreConversations && ( +
loadConversations(true)} + data-testid="load-more-conversations" + > + Load more conversations +
+ )}
)} @@ -290,6 +487,7 @@ const AskGlific = () => { onClose={() => setHistoryAnchor(null)} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} transformOrigin={{ vertical: 'top', horizontal: 'left' }} + className={styles.HistoryDropdown} > {Object.entries(groupedHistory).map(([date, items]) => [ @@ -298,15 +496,24 @@ const AskGlific = () => { ...items.map((item) => ( setHistoryAnchor(null)} + onClick={() => handleSelectConversation(item.id)} className={ - item.title === getChatTitle() ? styles.HistoryDropdownItemActive : styles.HistoryDropdownItem + item.id === conversationId ? styles.HistoryDropdownItemActive : styles.HistoryDropdownItem } > {item.title} )), ])} + {hasMoreConversations && ( + loadConversations(true)} + className={styles.HistoryDropdownItem} + data-testid="load-more-conversations-dropdown" + > + Load more... + + )}
@@ -403,6 +610,16 @@ const AskGlific = () => {
) : (
+ {hasMoreMessages && ( +
+ {isLoadingHistory ? 'Loading...' : 'Load older messages'} +
+ )} + {isLoadingHistory && !hasMoreMessages && messages.length === 0 && ( +
+ Loading conversation... +
+ )} {messages .filter((i) => !i.prompt) .map((msg) => { diff --git a/src/graphql/mutations/AskGlific.ts b/src/graphql/mutations/AskGlific.ts index dbedd0f0a..40550ac83 100644 --- a/src/graphql/mutations/AskGlific.ts +++ b/src/graphql/mutations/AskGlific.ts @@ -5,9 +5,19 @@ export const ASK_GLIFIC = gql` askGlific(input: $input) { answer conversationId + conversationName + messageId errors { message } } } `; + +export const ASK_GLIFIC_FEEDBACK = gql` + mutation AskGlificFeedback($input: AskGlificFeedbackInput!) { + askGlificFeedback(input: $input) { + success + } + } +`; diff --git a/src/graphql/queries/AskGlific.ts b/src/graphql/queries/AskGlific.ts new file mode 100644 index 000000000..03c7d49f8 --- /dev/null +++ b/src/graphql/queries/AskGlific.ts @@ -0,0 +1,34 @@ +import { gql } from '@apollo/client'; + +export const GET_ASKME_BOT_CONVERSATIONS = gql` + query AskmeBotConversations($limit: Int, $lastId: String) { + askmeBotConversations(limit: $limit, lastId: $lastId) { + conversations { + id + name + status + createdAt + updatedAt + } + hasMore + limit + } + } +`; + +export const GET_ASKME_BOT_MESSAGES = gql` + query AskGlificMessages($conversationId: String!, $limit: Int, $firstId: String) { + askGlificMessages(conversationId: $conversationId, limit: $limit, firstId: $firstId) { + messages { + id + conversationId + query + answer + createdAt + feedback + } + hasMore + limit + } + } +`;