diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6810fb37..c96bf28b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -20,5 +20,6 @@ jobs:
- name: Build
run: yarn build
- - name: Unit Tests
- run: yarn vitest
+ # enable again when we have unit tests
+ # - name: Unit Tests
+ # run: yarn vitest
diff --git a/cypress/e2e/analytics/main.cy.ts b/cypress/e2e/analytics/main.cy.ts
index b35c22a3..fd3dcdd3 100644
--- a/cypress/e2e/analytics/main.cy.ts
+++ b/cypress/e2e/analytics/main.cy.ts
@@ -7,7 +7,6 @@ import {
ANALYTICS_VIEW_CY,
ANALYTICS_WORDS_CLOUD_MODAL_ID,
KEYWORD_CHIP_COUNT_ID,
- PLAYER_VIEW_CY,
buildCheckWholeMemberChatButtonId,
buildDataCy,
buildKeywordChipId,
@@ -72,7 +71,14 @@ describe('Analytics View', () => {
cy.get(
`#${buildCheckWholeMemberChatButtonId(actions[0]?.account?.id)}`,
).click();
- cy.get(buildDataCy(PLAYER_VIEW_CY)).should('be.visible');
+
+ // show conversation
+ cy.get('[role="dialog"]')
+ .should('contain', MOCK_APP_SETTING.data.chatbotCue)
+ .should('contain', MOCK_APP_SETTING.data.chatbotName);
+
+ // do not show textbox
+ cy.get('[role="textbox"]').should('not.exist');
});
});
});
diff --git a/cypress/e2e/player/main.cy.ts b/cypress/e2e/player/main.cy.ts
index b99a60fb..514b6fdf 100644
--- a/cypress/e2e/player/main.cy.ts
+++ b/cypress/e2e/player/main.cy.ts
@@ -1,7 +1,6 @@
import { AppDataVisibility, Context, PermissionLevel } from '@graasp/sdk';
import {
- PLAYER_VIEW_CY,
buildCommentContainerDataCy,
buildDataCy,
} from '../../../src/config/selectors';
@@ -24,7 +23,7 @@ const defaultAppData = [
];
describe('Player View', () => {
- beforeEach(() => {
+ it('Show messages and write a new one', () => {
cy.setUpApi(
{
appData: defaultAppData,
@@ -36,10 +35,6 @@ describe('Player View', () => {
},
);
cy.visit('/');
- });
-
- it('Show messages and write a new one', () => {
- cy.get(buildDataCy(PLAYER_VIEW_CY)).should('be.visible');
// expect previously saved app data
const previousAppData = defaultAppData[0];
@@ -65,4 +60,82 @@ describe('Player View', () => {
'i am a bot', // default return value of the mocked chatbot
);
});
+
+ it('Show cue and write a message', () => {
+ cy.setUpApi(
+ {
+ appData: [],
+ appSettings: [MOCK_APP_SETTING],
+ },
+ {
+ context: Context.Player,
+ permission: PermissionLevel.Write,
+ },
+ );
+ cy.visit('/');
+
+ // expect cue
+ cy.get(buildDataCy(buildCommentContainerDataCy('cue'))).should(
+ 'contain',
+ MOCK_APP_SETTING.data.chatbotCue,
+ );
+
+ // type and send message
+ const message = 'My message';
+ cy.get('[role="textbox"]').type(message);
+ cy.get('[name="send"]').click();
+
+ // expect user message
+ cy.get(buildDataCy(buildCommentContainerDataCy('2'))).should(
+ 'contain',
+ message,
+ );
+
+ // expect chatbot message
+ cy.get(buildDataCy(buildCommentContainerDataCy('3'))).should(
+ 'contain',
+ 'i am a bot', // default return value of the mocked chatbot
+ );
+ });
+
+ it('Show dates', () => {
+ cy.setUpApi(
+ {
+ appData: [
+ {
+ account: CURRENT_MEMBER,
+ createdAt: '2025-11-18T16:35:22.010Z',
+ creator: CURRENT_MEMBER,
+ data: { content: 'A previously saved message' },
+ id: '0',
+ item: MOCK_SERVER_ITEM,
+ type: 'comment',
+ updatedAt: '2025-11-18T16:35:22.010Z',
+ visibility: AppDataVisibility.Member,
+ },
+ {
+ account: CURRENT_MEMBER,
+ createdAt: '2024-11-18T16:35:22.010Z',
+ creator: CURRENT_MEMBER,
+ data: { content: 'A previously saved message' },
+ id: '1',
+ item: MOCK_SERVER_ITEM,
+ type: 'comment',
+ updatedAt: '2025-11-18T16:35:22.010Z',
+ visibility: AppDataVisibility.Member,
+ },
+ ],
+ appSettings: [MOCK_APP_SETTING],
+ },
+ {
+ context: Context.Player,
+ permission: PermissionLevel.Write,
+ },
+ );
+ cy.visit('/');
+
+ // expect dates
+ cy.get('#root').should('contain', 'November 18, 2024');
+ cy.get('#root').should('contain', 'November 18, 2025');
+ });
});
diff --git a/src/config/selectors.ts b/src/config/selectors.ts
index 9d3439ef..29014044 100644
--- a/src/config/selectors.ts
+++ b/src/config/selectors.ts
@@ -3,7 +3,6 @@ export const TABLE_VIEW_TABLE_CYPRESS = 'table_view_table';
export const TABLE_VIEW_PANE_CYPRESS = 'table_view_pane';
export const SETTINGS_VIEW_PANE_CYPRESS = 'settings_view_pane';
export const ABOUT_VIEW_PANE_CYPRESS = 'about_view_pane';
-export const PLAYER_VIEW_CY = 'player-view';
export const BUILDER_VIEW_CY = 'builder-view';
export const ANALYTICS_VIEW_CY = 'analytics_view';
export const TAB_PRESET_VIEW_CYPRESS = 'tab_preset_view';
@@ -102,7 +101,6 @@ export const buildChatbotPromptContainerDataCy = (id: string): string =>
export const buildCommentResponseBoxDataCy = (id: string): string =>
`${COMMENT_RESPONSE_BOX_CY}-${id}`;
-export const COMMENT_THREAD_CONTAINER_CYPRESS = 'comment_thread_container';
export const ORPHAN_BUTTON_CYPRESS = 'orphan_button';
export const CODE_EXECUTION_CONTAINER_CYPRESS = 'code_execution_container';
export const CODE_EDITOR_ID_CY = 'code_editor';
diff --git a/src/langs/en.json b/src/langs/en.json
index 8987ebc0..2cce0721 100644
--- a/src/langs/en.json
+++ b/src/langs/en.json
@@ -61,6 +61,9 @@
"FILTER_BY_COMMON_KEYWORDS": "Filter by most frequent keywords",
"SEARCH_BY_OTHER_KEYWORDS": "Search by other keywords or regex",
"CHECK_WHOLE_CHAT": "Check the whole chat",
- "CONFIGURE_BUTTON": "Configure"
+ "CONFIGURE_BUTTON": "Configure",
+ "CLOSE": "Close",
+ "SIGN_OUT_ALERT": "You should be signed in to interact with the chatbot",
+ "ANALYTICS_CONVERSATION_MEMBER": "Conversation"
}
}
diff --git a/src/modules/analytics/FrequentWords.tsx b/src/modules/analytics/FrequentWords.tsx
index 66ed3aa6..074b030a 100644
--- a/src/modules/analytics/FrequentWords.tsx
+++ b/src/modules/analytics/FrequentWords.tsx
@@ -4,7 +4,10 @@ import { useTranslation } from 'react-i18next';
import {
Button,
Chip,
- Grid2,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
Stack,
TextField,
Typography,
@@ -19,9 +22,9 @@ import {
buildKeywordChipId,
} from '@/config/selectors';
+import { ConversationForUser } from '../comment/ConversationForUser';
import KeywordChip from '../common/KeywordChip';
import TextWithHighlightedKeywords from '../common/TextWithHighlightedKeywords';
-import PlayerView from '../main/PlayerView';
import { createRegexFromString, getTopFrequentWords } from './utils';
type Props = {
@@ -126,43 +129,41 @@ function FrequentWords({
))}
-
-
-
- {commentsMatchSelectedWords.map((ele) => (
- setChatMemberID(ele.account.id)}
- buttonId={buildCheckWholeMemberChatButtonId(ele.account.id)}
- />
- ))}
+
+ {commentsMatchSelectedWords.map((ele) => (
+ setChatMemberID(ele.account.id)}
+ buttonId={buildCheckWholeMemberChatButtonId(ele.account.id)}
+ />
+ ))}
- {
- // oxlint-disable-next-line eslint/yoda
- commentsMatchSelectedWords.length === 0 && (
- {t('NO_RESULTS_MATCH_WORDS')}
- )
- }
-
-
- {chatMemberID && (
-
-
-
- )}
-
+ {
+ // oxlint-disable-next-line eslint/yoda
+ commentsMatchSelectedWords.length === 0 && (
+ {t('NO_RESULTS_MATCH_WORDS')}
+ )
+ }
+
+ {chatMemberID && (
+
+ )}
);
}
diff --git a/src/modules/comment/ChatbotHeader.tsx b/src/modules/comment/ChatbotHeader.tsx
new file mode 100644
index 00000000..570901c3
--- /dev/null
+++ b/src/modules/comment/ChatbotHeader.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Stack, Typography } from '@mui/material';
+
+import ChatbotAvatar from '../common/ChatbotAvatar';
+
+function ChatbotHeader({ name }: Readonly<{ name: string }>) {
+ return (
+
+
+
+ {name}
+
+
+ );
+}
+
+export default ChatbotHeader;
diff --git a/src/modules/comment/CommentContainer.tsx b/src/modules/comment/CommentContainer.tsx
index 27f3e928..9fefec0c 100644
--- a/src/modules/comment/CommentContainer.tsx
+++ b/src/modules/comment/CommentContainer.tsx
@@ -5,7 +5,7 @@ import { BIG_BORDER_RADIUS } from '../../constants';
const CommentContainer = styled('div')(({ theme }) => ({
backgroundColor: 'white',
border: 'solid silver 1px',
- padding: theme.spacing(1, 0),
+ padding: theme.spacing(3, 0),
borderRadius: BIG_BORDER_RADIUS,
}));
export default CommentContainer;
diff --git a/src/modules/comment/Conversation.tsx b/src/modules/comment/Conversation.tsx
new file mode 100644
index 00000000..aa690d5e
--- /dev/null
+++ b/src/modules/comment/Conversation.tsx
@@ -0,0 +1,69 @@
+import { useTranslation } from 'react-i18next';
+
+import {
+ Alert,
+ Box,
+ CircularProgress,
+ Divider,
+ Stack,
+ SxProps,
+ Theme,
+} from '@mui/material';
+
+import { ChatbotPromptSettings } from '@/config/appSetting';
+
+import CommentEditor from '../common/CommentEditor';
+import CommentThread from '../common/CommentThread';
+import { Comment } from '../common/useConversation';
+import ChatbotHeader from './ChatbotHeader';
+import CommentContainer from './CommentContainer';
+
+function Conversation({
+ threadSx,
+ comments,
+ chatbotPrompt,
+ isLoading,
+ mode = 'read',
+}: Readonly<{
+ chatbotPrompt?: ChatbotPromptSettings;
+ threadSx?: SxProps;
+ isLoading?: boolean;
+ comments: Comment[];
+ mode?: 'read' | 'write';
+}>) {
+ const { t } = useTranslation();
+
+ if (chatbotPrompt) {
+ const { chatbotName } = chatbotPrompt;
+
+ return (
+
+
+
+
+
+
+ {'write' === mode && (
+
+ )}
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ return {t('CHATBOT_CONFIGURATION_MISSING')};
+}
+
+export default Conversation;
diff --git a/src/modules/comment/ConversationForUser.tsx b/src/modules/comment/ConversationForUser.tsx
new file mode 100644
index 00000000..6dc67b46
--- /dev/null
+++ b/src/modules/comment/ConversationForUser.tsx
@@ -0,0 +1,17 @@
+import { useConversation } from '../common/useConversation';
+import Conversation from './Conversation';
+
+export const ConversationForUser = ({
+ userId,
+}: Readonly<{ userId: string }>) => {
+ const { comments, isLoading, chatbotPrompt } = useConversation(userId);
+
+ return (
+
+ );
+};
diff --git a/src/modules/common/ChatbotPrompt.tsx b/src/modules/common/ChatbotPrompt.tsx
index 204d222b..617fd5f6 100644
--- a/src/modules/common/ChatbotPrompt.tsx
+++ b/src/modules/common/ChatbotPrompt.tsx
@@ -1,83 +1,18 @@
-import { useTranslation } from 'react-i18next';
-
-import { Alert, CardContent, CardHeader } from '@mui/material';
-
-import { useLocalContext } from '@graasp/apps-query-client';
-import type { UUID } from '@graasp/sdk';
-
-import type { CommentData } from '@/config/appData';
-import type { ChatbotPromptSettings } from '@/config/appSetting';
-import { SettingsKeys } from '@/config/appSetting';
-import { hooks } from '@/config/queryClient';
-import { buildChatbotPromptContainerDataCy } from '@/config/selectors';
import { DEFAULT_BOT_USERNAME } from '@/constants';
-import CustomCommentCard from '../comment/CustomCommentCard';
-import ChatbotAvatar from './ChatbotAvatar';
-import CommentBody from './CommentBody';
+import { Comment } from './Comment';
type Props = {
- id?: UUID;
+ chatbotCue?: string;
+ chatbotName?: string;
};
-function ChatbotPrompt({ id }: Props): JSX.Element | null {
- const { t } = useTranslation();
- const { data: appData } = hooks.useAppData();
- const {
- data: chatbotPrompts,
- isSuccess,
- isError,
- } = hooks.useAppSettings({
- name: SettingsKeys.ChatbotPrompt,
- });
- const chatbotPrompt = chatbotPrompts?.[0];
-
- const { accountId } = useLocalContext();
-
- const comments = appData?.filter((c) => c.creator?.id === (id ?? accountId));
-
- const realChatbotPromptExists = comments?.find(
- (c) => c.data.chatbotPromptSettingId !== undefined,
- );
-
- if (!chatbotPrompt) {
- if (isSuccess) {
- return (
- {t('CHATBOT_CONFIGURATION_MISSING')}
- );
- }
- if (isError) {
- return (
- {t('CHATBOT_CONFIGURATION_FETCH_ERROR')}
- );
- }
- // do not show anything if it has not finished fetching
- return null; // oxlint-disable-line eslint-plugin-unicorn/no-null
- }
- const chatbotName = chatbotPrompt?.data?.chatbotName || DEFAULT_BOT_USERNAME;
-
- // display only if real chatbot prompt does not exist yet
- if (!realChatbotPromptExists) {
- if ('' === chatbotPrompt?.data?.chatbotCue) {
- return <>Please configure the chatbot prompt.>;
- }
- return (
- <>
-
- }
- />
-
- {chatbotPrompt?.data?.chatbotCue}
-
-
- >
- );
+function ChatbotPrompt({
+ chatbotCue,
+ chatbotName = DEFAULT_BOT_USERNAME,
+}: Readonly): JSX.Element | null {
+ if (chatbotCue) {
+ return ;
}
return null;
}
diff --git a/src/modules/common/Comment.tsx b/src/modules/common/Comment.tsx
index 58f052db..4a73b6c4 100644
--- a/src/modules/common/Comment.tsx
+++ b/src/modules/common/Comment.tsx
@@ -1,69 +1,52 @@
import { useRef } from 'react';
-import { useTranslation } from 'react-i18next';
-import type { CardProps } from '@mui/material';
-import { Card, CardContent, CardHeader, styled } from '@mui/material';
+import { Stack, Typography } from '@mui/material';
-import { useLocalContext } from '@graasp/apps-query-client';
-import { formatDate } from '@graasp/sdk';
-
-import type { CommentAppData } from '@/config/appData';
-import { AppDataTypes } from '@/config/appData';
-import type { ChatbotPromptSettings } from '@/config/appSetting';
-import { ChatbotPromptSettingsKeys, SettingsKeys } from '@/config/appSetting';
-import { hooks } from '@/config/queryClient';
import { buildCommentContainerDataCy } from '@/config/selectors';
-import { BIG_BORDER_RADIUS, DEFAULT_BOT_USERNAME } from '@/constants';
import ChatbotAvatar from './ChatbotAvatar';
import CommentBody from './CommentBody';
import CustomAvatar from './CustomAvatar';
-const CustomCard = styled(Card)({
- borderRadius: BIG_BORDER_RADIUS,
-});
-
type Props = {
- comment: CommentAppData;
+ id: string;
+ body: string;
+ isBot: boolean;
+ username: string;
};
-function Comment({ comment }: Props): JSX.Element {
- const { i18n } = useTranslation();
-
- const { accountId } = useLocalContext();
- const { data: appContext } = hooks.useAppContext();
- const currentMember = appContext?.members.find((m) => m.id === accountId);
- const { data: chatbotPrompts } = hooks.useAppSettings({
- name: SettingsKeys.ChatbotPrompt,
- });
- const chatbotPrompt = chatbotPrompts?.[0];
+export function Comment({
+ id,
+ isBot,
+ body,
+ username,
+}: Readonly): JSX.Element {
const commentRef = useRef(null);
- const isBot = comment.type === AppDataTypes.BotComment;
+ const avatar = isBot ? (
+
+ ) : (
+
+ );
return (
-
- :
- }
- />
-
- {comment.data.content}
-
-
+
+
+ {avatar}
+
+ {username}
+
+ {body}
+
+
+
+
);
}
-
-export default Comment;
diff --git a/src/modules/common/CommentBody.tsx b/src/modules/common/CommentBody.tsx
index 70436c3d..ec324e13 100644
--- a/src/modules/common/CommentBody.tsx
+++ b/src/modules/common/CommentBody.tsx
@@ -11,6 +11,8 @@ import { BIG_BORDER_RADIUS } from '@/constants';
// oxlint-disable no-magic-numbers
const StyledReactMarkdown = styled(ReactMarkdown)(({ theme }) => ({
+ borderRadius: 8,
+ padding: theme.spacing(1),
'& .prism-code': {
fontFamily: 'var(--monospace-fonts)',
backgroundColor: 'transparent !important',
@@ -112,11 +114,17 @@ function code(props: {
);
}
-function CommentBody({ children }: Readonly<{ children: string }>) {
+function CommentBody({
+ children,
+ background = '#efefef',
+}: Readonly<{ children: string; background?: string }>) {
return (
{children}
diff --git a/src/modules/common/CommentEditor.tsx b/src/modules/common/CommentEditor.tsx
index 6a5f238c..5ae6deb9 100644
--- a/src/modules/common/CommentEditor.tsx
+++ b/src/modules/common/CommentEditor.tsx
@@ -61,7 +61,8 @@ function CommentEditor({
const { generateChatbotAnswer, isLoading: askChatbotLoading } =
useAskChatbot(chatbotPrompt);
- const { sendMessage, isLoading: sendMessageLoading } = useSendMessage();
+ const { sendMessage, isLoading: sendMessageLoading } =
+ useSendMessage(chatbotPrompt);
const onSendHandler = async (newUserComment: string) => {
if (!chatbotPrompt) {
diff --git a/src/modules/common/CommentThread.tsx b/src/modules/common/CommentThread.tsx
index 98ed4141..84ae5a82 100644
--- a/src/modules/common/CommentThread.tsx
+++ b/src/modules/common/CommentThread.tsx
@@ -1,35 +1,73 @@
-import { Fragment } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Fragment } from 'react/jsx-runtime';
import type { SxProps, Theme } from '@mui/material';
-import { Box } from '@mui/material';
+import { Stack, Typography } from '@mui/material';
-import type { CommentAppData } from '@/config/appData';
-import { COMMENT_THREAD_CONTAINER_CYPRESS } from '@/config/selectors';
+import { intlFormat } from 'date-fns';
+import groupby from 'lodash.groupby';
-import Comment from './Comment';
+import { Comment } from './Comment';
+import { type Comment as CommentType } from './useConversation';
type Props = {
- comments?: CommentAppData[];
- threadSx: SxProps;
+ comments?: CommentType[];
+ threadSx?: SxProps;
};
function CommentThread({
comments,
threadSx,
}: Readonly): JSX.Element | null {
+ const { i18n } = useTranslation();
+
//oxlint-disable-next-line eslint/yoda
if (!comments || comments.length === 0) {
return null;
}
+ const commentsPerDay = groupby(comments, (c) =>
+ intlFormat(
+ new Date(c.createdAt),
+ {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ },
+ { locale: i18n.language },
+ ),
+ );
+
return (
-
- {comments.map((c) => (
-
-
-
- ))}
-
+
+ {Object.entries(commentsPerDay).map(([date, commentsForDay]) => {
+ return (
+
+
+ {date}
+
+
+ {commentsForDay.map((c) => {
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
);
}
diff --git a/src/modules/common/CustomAvatar.tsx b/src/modules/common/CustomAvatar.tsx
index 862b5895..d08bae98 100644
--- a/src/modules/common/CustomAvatar.tsx
+++ b/src/modules/common/CustomAvatar.tsx
@@ -1,25 +1,29 @@
import { Avatar } from '@mui/material';
-import type { Member } from '@graasp/sdk';
import { stringToColor } from '@graasp/ui/apps';
import { ANONYMOUS_USER } from '@/constants';
-import { getInitials } from '@/utils/utils';
type Props = {
- member?: Member;
+ username?: string;
imgSrc?: string;
};
-function CustomAvatar({ member, imgSrc }: Readonly): JSX.Element {
- const userName = member?.name ?? ANONYMOUS_USER;
+function CustomAvatar({
+ username = ANONYMOUS_USER,
+ imgSrc,
+}: Readonly): JSX.Element {
return (
- {getInitials(userName)}
+ {username[0]}
);
}
diff --git a/src/modules/common/CustomDialog.tsx b/src/modules/common/CustomDialog.tsx
index 22d5e515..f3ac1304 100644
--- a/src/modules/common/CustomDialog.tsx
+++ b/src/modules/common/CustomDialog.tsx
@@ -1,4 +1,9 @@
-import type { MutableRefObject, ReactElement, RefObject } from 'react';
+import type {
+ MutableRefObject,
+ ReactElement,
+ ReactNode,
+ RefObject,
+} from 'react';
import type { Breakpoint } from '@mui/material';
import {
@@ -38,7 +43,7 @@ const StyledDialogTitle = styled(DialogTitle)({});
type Props = {
open: boolean;
title: string | ReactElement;
- content: ReactElement | string;
+ children: ReactNode;
actions?: ReactElement;
onClose?: () => void;
dataCy?: string;
@@ -52,7 +57,6 @@ type Props = {
function CustomDialog({
open,
title,
- content,
actions,
onClose,
dataCy,
@@ -61,6 +65,7 @@ function CustomDialog({
maxWidth = 'sm',
noPadding = false,
anchor = null,
+ children,
}: Props) {
return (