Skip to content

Commit 40f29bf

Browse files
committed
Add a view for student discussions for research courses
1 parent 66aec46 commit 40f29bf

7 files changed

Lines changed: 139 additions & 22 deletions

File tree

src/client/components/Courses/Course/Discussions.tsx

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
1-
import { useParams, Link as RouterLink } from 'react-router-dom'
1+
import { useParams, Link as RouterLink, Route, Routes, useNavigate } from 'react-router-dom'
22
import { useTranslation } from 'react-i18next'
3-
import { TableBody, TableCell, TableHead, TableRow, Table, Link, Paper, Typography, Alert } from '@mui/material'
3+
import { TableBody, TableCell, TableHead, TableRow, Table, Link, Paper, Typography, Alert, Box, Stack } from '@mui/material'
4+
import ReactMarkdown from 'react-markdown'
5+
import remarkGfm from 'remark-gfm'
46
import useCurrentUser from '../../../hooks/useCurrentUser'
5-
import useCourse, { useCourseDiscussers } from '../../../hooks/useCourse'
7+
import useCourse, { useCourseDiscussers, useCourseDiscussion } from '../../../hooks/useCourse'
8+
import { BlueButton } from '../../ChatV2/general/Buttons'
9+
import type { Discussion } from '../../../../shared/types'
610

7-
const Discussion: React.FC = () => {
11+
const DiscussionView: React.FC = () => {
12+
return (
13+
<Routes>
14+
<Route index element={<DiscussionList />} />
15+
<Route path=":userId" element={<DiscussionDetail />} />
16+
</Routes>
17+
)
18+
}
19+
20+
const DiscussionList: React.FC = () => {
821
const { t, i18n } = useTranslation()
922
const { language } = i18n
10-
const { courseId } = useParams() as { courseId: string }
23+
const { courseId } = useParams<{ courseId: string }>()
1124
const { user, isLoading: isUserLoading } = useCurrentUser()
12-
const { data: course, isSuccess: isCourseSuccess } = useCourse(courseId)
13-
const { discussers, isLoading: discussersLoading } = useCourseDiscussers(courseId)
25+
const { data: course, isSuccess: isCourseSuccess } = useCourse(courseId ?? '')
26+
const { discussers, isLoading: discussersLoading } = useCourseDiscussers(courseId ?? '')
1427

15-
if (!course || !isCourseSuccess || isUserLoading || !user || discussersLoading) return null
28+
if (!courseId || !course || !isCourseSuccess || isUserLoading || !user || discussersLoading) return null
1629

1730
return (
1831
<div>
@@ -38,16 +51,16 @@ const Discussion: React.FC = () => {
3851
<Table>
3952
<TableHead>
4053
<TableRow>
41-
<TableCell>User ID</TableCell>
42-
<TableCell>Messages</TableCell>
54+
<TableCell>{t('course:student')}</TableCell>
55+
<TableCell>{t('course:messages')}</TableCell>
4356
</TableRow>
4457
</TableHead>
4558
<TableBody>
46-
{discussers.map((d) => (
59+
{discussers.map((d, idx) => (
4760
<TableRow key={d.user_id}>
4861
<TableCell>
4962
<Link to={`${d.user_id}`} component={RouterLink}>
50-
{d.user_id}
63+
{t('course:student')} {idx + 1}
5164
</Link>
5265
</TableCell>
5366
<TableCell>{d.discussion_count}</TableCell>
@@ -59,4 +72,88 @@ const Discussion: React.FC = () => {
5972
)
6073
}
6174

62-
export default Discussion
75+
const DiscussionDetail: React.FC = () => {
76+
const { t } = useTranslation()
77+
const navigate = useNavigate()
78+
const { courseId, userId } = useParams<{ courseId: string; userId: string }>()
79+
const { data: discussions, isLoading } = useCourseDiscussion(courseId ?? '', userId ?? '')
80+
81+
if (!courseId || !userId || isLoading) return null
82+
83+
if (!discussions || discussions.length === 0) {
84+
return (
85+
<Box py={3}>
86+
<Box sx={{ position: 'sticky', top: 0, zIndex: 1, py: 1 }}>
87+
<BlueButton onClick={() => navigate('..', { relative: 'path' })}>{t('common:back')}</BlueButton>
88+
</Box>
89+
<Typography mt={2}>{t('course:noDiscussions')}</Typography>
90+
</Box>
91+
)
92+
}
93+
94+
const sessions: Discussion[] = []
95+
for (let i = 0; i < discussions.length; i++) {
96+
const curr = discussions[i]
97+
const next = discussions[i + 1]
98+
if (!next) {
99+
sessions.push(curr)
100+
continue
101+
}
102+
const currLen = curr.metadata.chatMessages.length
103+
const nextLen = next.metadata.chatMessages.length
104+
const nextExtendsThis =
105+
nextLen > currLen &&
106+
curr.metadata.chatMessages.every((msg, j) => next.metadata.chatMessages[j]?.role === msg.role && next.metadata.chatMessages[j]?.content === msg.content)
107+
if (!nextExtendsThis) sessions.push(curr)
108+
}
109+
110+
return (
111+
<Box py={3}>
112+
<Box sx={{ position: 'sticky', top: 0, zIndex: 1, py: 1 }}>
113+
<BlueButton onClick={() => navigate('..', { relative: 'path' })}>{t('common:back')}</BlueButton>
114+
</Box>
115+
116+
<Stack spacing={4} mt={2} sx={{ maxWidth: '900px', mx: 'auto' }}>
117+
{sessions.map((discussion) => {
118+
const messages = [...discussion.metadata.chatMessages, { role: 'assistant', content: discussion.response }]
119+
return (
120+
<Paper key={discussion.id} elevation={3} sx={{ p: 3, borderRadius: 2 }}>
121+
<Typography variant="caption" color="text.secondary">
122+
{new Date(discussion.createdAt).toLocaleString()}
123+
</Typography>
124+
<Stack spacing={1} mt={1}>
125+
{messages.map((msg, msgIdx) =>
126+
msg.role === 'user' ? (
127+
<Box
128+
key={msgIdx}
129+
sx={{
130+
alignSelf: 'flex-end',
131+
backgroundColor: 'action.hover',
132+
borderRadius: '1rem 0 1rem 1rem',
133+
boxShadow: '0px 2px 2px rgba(0, 0, 0, 0.2)',
134+
px: '1.5rem',
135+
py: '1rem',
136+
maxWidth: { xs: '90vw', sm: '60vw', md: '50vw' },
137+
width: 'fit-content',
138+
wordBreak: 'break-word',
139+
whiteSpace: 'pre-wrap',
140+
}}
141+
>
142+
<Typography variant="body2">{typeof msg.content === 'string' ? msg.content : '[image]'}</Typography>
143+
</Box>
144+
) : (
145+
<Box key={msgIdx} sx={{ width: '100%', wordBreak: 'break-word' }}>
146+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{typeof msg.content === 'string' ? msg.content : ''}</ReactMarkdown>
147+
</Box>
148+
),
149+
)}
150+
</Stack>
151+
</Paper>
152+
)
153+
})}
154+
</Stack>
155+
</Box>
156+
)
157+
}
158+
159+
export default DiscussionView
Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
import { Tabs } from '@mui/material'
22
import React from 'react'
3-
import { matchPath, useLocation } from 'react-router-dom'
3+
import { useLocation } from 'react-router-dom'
44

55
const stripSearch = (path: string) => path.split('?')[0]
66

7-
export const RouterTabs = ({ children }: { children: (React.ReactElement | false)[] }) => {
7+
export const RouterTabs = ({ children }: { children: React.ReactNode }) => {
88
const { pathname } = useLocation()
99

10-
const activeIndex = React.Children.toArray(children)
11-
.filter((c) => React.isValidElement(c))
12-
.findIndex((c) => !!matchPath(pathname, stripSearch((c.props as { to: string }).to)))
10+
const tabs = React.Children.toArray(children).filter((c) => React.isValidElement(c))
1311

14-
return <Tabs value={activeIndex < 0 ? 0 : activeIndex} slotProps={{ indicator: { style: { backgroundColor: 'black' } } }} textColor='inherit' >{children}</Tabs>
12+
const activeIndex = tabs.reduce<number>((best, c, idx) => {
13+
const to = stripSearch((c.props as { to: string }).to)
14+
const matches = pathname === to || pathname.startsWith(to + '/')
15+
if (!matches) return best
16+
const bestTo = best >= 0 ? stripSearch((tabs[best].props as { to: string }).to) : ''
17+
return to.length > bestTo.length ? idx : best
18+
}, -1)
19+
20+
return (
21+
<Tabs value={activeIndex < 0 ? false : activeIndex} slotProps={{ indicator: { style: { backgroundColor: 'black' } } }} textColor="inherit">
22+
{children}
23+
</Tabs>
24+
)
1525
}

src/client/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"edit": "Edit",
1010
"delete": "Delete",
1111
"update": "Update",
12+
"back": "Back",
1213
"cancel": "Cancel",
1314
"continue": "Continue",
1415
"create": "Create",
@@ -411,6 +412,8 @@
411412
"tokens": "Tokens",
412413
"messages": "Messages",
413414
"noCourses": "No courses",
415+
"noDiscussions": "No discussions found for this student.",
416+
"student": "Student",
414417
"showActiveCourses": "Show active courses",
415418
"isSavedOptOut": "The course discussions will be saved anonymously, if you grant permission to save. Saved discussions can be used for research purposes.",
416419
"isSavedNotOptOut": "The course discussions will be saved anonymously.\n\nSaved discussions can be used for research purposes",

src/client/locales/fi.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"edit": "Muokkaa",
1010
"delete": "Poista",
1111
"update": "Päivitä",
12+
"back": "Takaisin",
1213
"cancel": "Peruuta",
1314
"continue": "Jatka",
1415
"create": "Luo",
@@ -414,6 +415,8 @@
414415
"tokens": "Tokenit",
415416
"messages": "Viestit",
416417
"noCourses": "Ei näytettäviä kursseja",
418+
"noDiscussions": "Tälle opiskelijalle ei löydy keskusteluja.",
419+
"student": "Opiskelija",
417420
"showActiveCourses": "Näytä aktiiviset kurssit",
418421
"isSavedOptOut": "Kurssilla käymäsi keskustelut tallennetaan anonyymisti, jos annat tallennusluvan.\nTallennettuja keskusteluja voidaan käyttää tutkimuksessa.",
419422
"isSavedNotOptOut": "Kurssilla käymäsi keskustelut tallennetaan anonyymisti.\n\nTallennettuja keskusteluja voidaan käyttää tutkimuksessa",

src/client/locales/sv.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"edit": "Redigera",
1010
"delete": "Redera",
1111
"update": "Updattera",
12+
"back": "Tillbaka",
1213
"cancel": "Avboka",
1314
"continue": "Fortsätt",
1415
"create": "Skapa",
@@ -371,6 +372,8 @@
371372
"tokens": "Tokens",
372373
"messages": "Meddelanden",
373374
"noCourses": "Inga kurser att visa",
375+
"noDiscussions": "Inga diskussioner hittades för den här studenten.",
376+
"student": "Student",
374377
"percentageUsed": "Andel använda polletter",
375378
"howToActive": "Välj de kurser jag undervisar om du vill aktivera kurschatten",
376379
"coursePage": "Kurssidan",

src/server/routes/course.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ courseRouter.get('/:id/discussions/:user_id', checkDiscussionAccess, async (req,
314314
courseId: id,
315315
userId,
316316
},
317+
order: [['createdAt', 'ASC']],
317318
})
318319

319320
res.send(discussions.map((d) => d))

src/shared/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ export type Discussion = {
5656
courseId: string
5757
response: string
5858
metadata: {
59-
model: ValidModelName
60-
messages: {
59+
generationInfo: { model: ValidModelName }
60+
chatMessages: {
6161
role: string
62-
content: string
62+
content: string | { type: string; image_url?: unknown }[]
6363
}[]
6464
}
6565
createdAt: string

0 commit comments

Comments
 (0)