diff --git a/components/EventCommentThreads.tsx b/components/EventCommentThreads.tsx index 8c59e381..4e4cc36c 100644 --- a/components/EventCommentThreads.tsx +++ b/components/EventCommentThreads.tsx @@ -1,8 +1,53 @@ -import React from "react" +import React, { useState, useEffect, useMemo } from "react" import { Material, sectionSplit } from "lib/material" import { EventFull } from "lib/types" +import Avatar from "@mui/material/Avatar" import Link from "next/link" import useCommentThreads from "lib/hooks/useCommentThreads" +import { useTheme } from "next-themes" +import useUsersList from "lib/hooks/useUsersList" +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Tooltip, +} from "@mui/material" +import LinkIcon from "@mui/icons-material/Link" +import Stack from "./ui/Stack" +import Thread from "./content/Thread" +import ExpandMoreIcon from "@mui/icons-material/ExpandMore" +import ExpandLessIcon from "@mui/icons-material/ExpandLess" +import { CommentThread } from "pages/api/commentThread/[commentThreadId]" + +const noSectionKey = "No Section found (material changed?)" + +// group threads by section +const groupThreadsBySection = (threads: CommentThread[], material: Material) => { + const grouped: { [key: string]: CommentThread[] } = {} + threads.forEach((thread: CommentThread) => { + const { section } = sectionSplit(thread.section, material) + if (section && "name" in section) { + if (!grouped[section.name]) { + grouped[section.name] = [] + } + grouped[section.name].push(thread) + } else { + if (!grouped[noSectionKey]) { + grouped[noSectionKey] = [] + } + grouped[noSectionKey].push(thread) + } + }) + return grouped +} type Props = { event: EventFull @@ -10,27 +55,302 @@ type Props = { } const EventCommentThreads: React.FC = ({ material, event }) => { + const [activeThreadId, setActiveThreadId] = useState(undefined) const { commentThreads, error: threadsError, isLoading: threadsIsLoading } = useCommentThreads(event.id) - if (threadsIsLoading) return
loading...
- if (!commentThreads) return
failed to load
+ const [expandedSections, setExpandedSections] = useState<{ [key: string]: boolean }>({}) + const { theme: currentTheme } = useTheme() + + // Memoize the emails array to prevent unnecessary re-renders + const emails = useMemo(() => { + return commentThreads?.map((thread) => thread.createdByEmail) || [] + }, [commentThreads]) + + const { users, error: usersError } = useUsersList(emails) + + useEffect(() => { + if (commentThreads) { + const unresolvedSectionNames = Object.keys(groupedUnresolvedThreads) + const resolvedSectionNames = Object.keys(groupedResolvedThreads) + + const initialExpandedState: { [key: string]: boolean } = {} + + unresolvedSectionNames.forEach((sectionName) => { + if (sectionName !== noSectionKey) { + initialExpandedState[sectionName] = true + } + }) + + resolvedSectionNames.forEach((sectionName) => { + initialExpandedState[sectionName] = true + }) + + // ensure if the section was not found then that section is collapsed + initialExpandedState[noSectionKey] = false + + setExpandedSections(initialExpandedState) + } + }, [commentThreads]) + + if (threadsIsLoading) return
Loading...
+ if (!commentThreads) return
Failed to load
const unresolvedThreads = commentThreads.filter((thread) => !thread.resolved) + const resolvedThreads = commentThreads.filter((thread) => thread.resolved) + + const groupedUnresolvedThreads: { [key: string]: typeof unresolvedThreads } = groupThreadsBySection( + unresolvedThreads, + material + ) + const groupedResolvedThreads: { [key: string]: typeof resolvedThreads } = groupThreadsBySection( + resolvedThreads, + material + ) + + const toggleSection = (sectionName: string) => { + setExpandedSections((prevState) => ({ + ...prevState, + [sectionName]: !prevState[sectionName], + })) + } + return (
-
    - {unresolvedThreads.map((thread) => { - const { theme, course, section, url } = sectionSplit(thread.section, material) - const urlWithAnchor = url + `#comment-thread-${thread.id}` - return ( -
  • - - {thread.section}: {thread.createdByEmail}:{" "} - {thread.Comment.length > 0 ? thread.Comment[0].markdown : ""} - -
  • - ) - })} -
+ + } data-cy="unresolved-threads-expand"> + Unresolved Comment Threads + + + + + + + + + Section + + + + + + {Object.keys(groupedUnresolvedThreads).map((sectionName) => { + const isExpanded = expandedSections[sectionName] || false + const firstThread = groupedUnresolvedThreads[sectionName][0] + const { url } = sectionSplit(firstThread.section, material) + + return ( + + toggleSection(sectionName)} + style={{ cursor: "pointer" }} + data-cy={`section:${sectionName}:unresolved`} + > + (theme.palette.mode === "dark" ? "#000000" : "#e5e7eb"), + }} + > + + {isExpanded ? : } {sectionName} + { + e.stopPropagation() // Prevent row click event from firing + window.open(url, "_blank") // Open the link in a new tab + }} + style={{ marginLeft: 8, cursor: "pointer" }} // Adjust styling as needed + /> + + + + + {isExpanded && ( + <> + + + Created By + + + Comment + + + Expand + + + {groupedUnresolvedThreads[sectionName].map((thread) => { + const urlWithAnchor = url + `#comment-thread-${thread.id}` + + const commentText = + thread.Comment && + thread.Comment.length > 0 && + typeof thread.Comment[0].markdown === "string" + ? thread.Comment[0].markdown + : "[Invalid Content]" + + return ( + + + + + <>{thread.createdByEmail} + + + + + + + {commentText} + + + + + + + active ? setActiveThreadId(thread.id) : setActiveThreadId(undefined) + } + finaliseThread={() => {}} + onDelete={() => {}} + /> + + + ) + })} + + )} + + ) + })} + +
+
+
+
+ + + } data-cy="resolved-threads-expand"> + Resolved Comment Threads + + + + + + + + + Section Name + + + + + + {Object.keys(groupedResolvedThreads).map((sectionName) => { + const isExpanded = expandedSections[sectionName] || false + const firstThread = groupedResolvedThreads[sectionName][0] + const { url } = sectionSplit(firstThread.section, material) + + return ( + + toggleSection(sectionName)} + style={{ cursor: "pointer" }} + data-cy={`section:${sectionName}:resolved`} + > + (theme.palette.mode === "dark" ? "#000000" : "#e5e7eb"), + }} + > + + {isExpanded ? : } {sectionName} + { + e.stopPropagation() // Prevent row click event from firing + window.open(url, "_blank") // Open the link in a new tab + }} + style={{ marginLeft: 8, cursor: "pointer" }} // Adjust styling as needed + /> + + + + + {isExpanded && ( + <> + + + Created By + + + Comment + + + Expand + + + {groupedResolvedThreads[sectionName].map((thread) => { + const urlWithAnchor = url + `#comment-thread-${thread.id}` + + const commentText = + thread.Comment && + thread.Comment.length > 0 && + typeof thread.Comment[0].markdown === "string" + ? thread.Comment[0].markdown + : "[Invalid Content]" + + return ( + + + + + <>{thread.createdByEmail} + + + + + + + {commentText} + + + + + + + active ? setActiveThreadId(thread.id) : setActiveThreadId(undefined) + } + finaliseThread={() => {}} + onDelete={() => {}} + /> + + + ) + })} + + )} + + ) + })} + +
+
+
+
) } diff --git a/components/EventProblems.tsx b/components/EventProblems.tsx index e6df8e0b..374fc366 100644 --- a/components/EventProblems.tsx +++ b/components/EventProblems.tsx @@ -1,7 +1,8 @@ import React from "react" import { Course, Material, Section, Theme, eventItemSplit } from "lib/material" import { EventFull, Event, Problem } from "lib/types" -import { Avatar, Table } from "flowbite-react" +import { Table } from "flowbite-react" +import Avatar from "@mui/material/Avatar" import { useProblems } from "lib/hooks/useProblems" import useUsersOnEvent from "lib/hooks/useUsersOnEvent" import Tooltip from "@mui/material/Tooltip" @@ -36,7 +37,7 @@ const EventProblems: React.FC = ({ material, event }) => { `}>
- +
diff --git a/components/EventUsers.tsx b/components/EventUsers.tsx index 3bd43050..24c15700 100644 --- a/components/EventUsers.tsx +++ b/components/EventUsers.tsx @@ -4,7 +4,8 @@ import { Material } from "lib/material" import { EventFull, Event, Problem } from "lib/types" import useSWR, { Fetcher } from "swr" import Title from "components/ui/Title" -import { Avatar, Button, Card, Timeline } from "flowbite-react" +import { Button, Card, Timeline } from "flowbite-react" +import Avatar from "@mui/material/Avatar" import { ListGroup } from "flowbite-react" import { basePath } from "lib/basePath" import { useFieldArray, useForm } from "react-hook-form" @@ -88,7 +89,7 @@ const EventUsers: React.FC = ({ event }) => {
  • - +

    {user.user.name}

    diff --git a/components/MuiTheme.tsx b/components/MuiTheme.tsx index adffc360..cd3fc778 100644 --- a/components/MuiTheme.tsx +++ b/components/MuiTheme.tsx @@ -21,7 +21,42 @@ export const LightTheme = createTheme({ root: { backgroundColor: "#f8fafc", // Similar to bg-slate-50 borderColor: "#e5e7eb", // border-gray-200 - borderRadius: "8", // rounded-lg + borderRadius: "8px", // rounded-lg + }, + }, + }, + MuiTable: { + styleOverrides: { + root: { + backgroundColor: "#f8fafc", // Table background similar to paper + }, + }, + }, + MuiTableCell: { + styleOverrides: { + root: { + borderColor: "#e5e7eb", // border-gray-200 for light borders + padding: "6px 12px", // Reduce padding to align with Tailwind-style compactness + }, + head: { + backgroundColor: "#f1f5f9", // Header bg like bg-slate-100 + color: "#374151", // Header text color like text-gray-700 + }, + }, + }, + MuiTableHead: { + styleOverrides: { + root: { + backgroundColor: "#f1f5f9", // bg-slate-100 + }, + }, + }, + MuiTableRow: { + styleOverrides: { + root: { + "&:nth-of-type(even)": { + backgroundColor: "#f9fafb", // Alternating row color like bg-slate-50 + }, }, }, }, @@ -53,5 +88,48 @@ export const DarkTheme = createTheme({ }, }, }, + MuiAccordion: { + styleOverrides: { + root: { + backgroundColor: "#111827", // dark:bg-slate-900 for dark mode + }, + }, + }, + MuiTable: { + styleOverrides: { + root: { + backgroundColor: "#374151", // dark:bg-slate-800 for table background + }, + }, + }, + MuiTableCell: { + styleOverrides: { + root: { + borderColor: "#4b5563", // dark:border-gray-700 for dark mode borders + padding: "4px 8px", // Reduce padding for compactness + color: "#e5e7eb", // light gray text color like text-gray-300 + }, + head: { + backgroundColor: "#4b5563", // dark:bg-gray-700 for header background + color: "#e5e7eb", // Header text color like text-gray-300 + }, + }, + }, + MuiTableHead: { + styleOverrides: { + root: { + backgroundColor: "#4b5563", // dark:bg-gray-700 for header background + }, + }, + }, + MuiTableRow: { + styleOverrides: { + root: { + "&:nth-of-type(even)": { + backgroundColor: "#1f2937", // Alternating row color like dark:bg-slate-800 + }, + }, + }, + }, }, }) diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 28c51f2c..f6f1ed2c 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,4 +1,5 @@ -import { Avatar, Button, Dropdown, Tooltip } from "flowbite-react" +import { Button, Dropdown, Tooltip } from "flowbite-react" +import Avatar from "@mui/material/Avatar" import { Course, Material, Section, Theme, getExcludes, Excludes } from "lib/material" import { Event, EventFull } from "lib/types" import { signIn, signOut, useSession } from "next-auth/react" @@ -439,15 +440,12 @@ const Navbar: React.FC = ({ {session ? ( ) : ( - + )} } diff --git a/components/content/Comment.tsx b/components/content/Comment.tsx index 5537f6a0..3909c655 100644 --- a/components/content/Comment.tsx +++ b/components/content/Comment.tsx @@ -1,4 +1,5 @@ -import { Avatar, Button, Card, Dropdown, Tabs, Tooltip } from "flowbite-react" +import { Button, Card, Dropdown, Tabs, Tooltip } from "flowbite-react" +import Avatar from "@mui/material/Avatar" import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react" import { Markdown } from "./Content" import { Comment } from "pages/api/comment/[commentId]" @@ -117,7 +118,7 @@ const CommentView = ({ } > - +
    )} diff --git a/components/content/Paragraph.tsx b/components/content/Paragraph.tsx index e64d2343..ffd46fe7 100644 --- a/components/content/Paragraph.tsx +++ b/components/content/Paragraph.tsx @@ -27,7 +27,10 @@ const Paragraph: React.FC = ({ content, section }) => { const [activeEvent, setActiveEvent] = useActiveEvent() const { commentThreads, error, isLoading, mutate } = useCommentThreads(activeEvent?.id) const [activeThreadId, setActiveThreadId] = useState(undefined) - const [tempThread, setTempThread] = useState(undefined) + const [tempThread, setTempThread] = useState<{ + thread: CommentThread + initialAnchor?: { top: number; left: number } + } | null>(null) const [tempActive, setTempActive] = useState(false) const email = useSession().data?.user?.email @@ -59,8 +62,10 @@ const Paragraph: React.FC = ({ content, section }) => { const handleCreateThread = (text: string) => { if (!activeEvent) return + const textRefStart = contentText.indexOf(text) const textRefEnd = textRefStart + text.length + const buttonRect = ref?.current?.getBoundingClientRect() const newThread: CommentThread = createEmptyThread( activeEvent.id, section, @@ -69,7 +74,10 @@ const Paragraph: React.FC = ({ content, section }) => { text, email as string ) - setTempThread(newThread) + setTempThread({ + thread: newThread, + initialAnchor: buttonRect ? { top: buttonRect.top + window.scrollY, left: buttonRect.left + 355 } : undefined, + }) setTempActive(true) } @@ -120,7 +128,7 @@ const Paragraph: React.FC = ({ content, section }) => { postCommentThread(newThread).then((thread) => { const newThreads = commentThreads ? [...commentThreads, thread] : [thread] mutate(newThreads) - setTempThread(undefined) + setTempThread(null) setActiveThreadId(thread.id) }) } @@ -136,7 +144,7 @@ const Paragraph: React.FC = ({ content, section }) => { return ( <>
    -

    {content}

    +
    {content}
    {activeEvent && (
    @@ -154,12 +162,13 @@ const Paragraph: React.FC = ({ content, section }) => { ))} {tempThread && ( handleDeleteThread(tempThread)} + onDelete={() => handleDeleteThread(tempThread.thread)} + initialAnchor={tempThread.initialAnchor} /> )}
    diff --git a/components/content/Thread.tsx b/components/content/Thread.tsx index ab2aead7..7dba0521 100644 --- a/components/content/Thread.tsx +++ b/components/content/Thread.tsx @@ -1,4 +1,5 @@ -import { Avatar, Button, ButtonProps, Card, Checkbox, Dropdown, Label, Spinner, Tooltip } from "flowbite-react" +import { Button, ButtonProps, Card, Checkbox, Dropdown, Label, Spinner, Tooltip } from "flowbite-react" +import Avatar from "@mui/material/Avatar" import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react" import { Markdown } from "./Content" import { CommentThread, Comment } from "pages/api/commentThread" @@ -15,7 +16,7 @@ import putCommentThread from "lib/actions/putCommentThread" import useActiveEvent from "lib/hooks/useActiveEvents" import useEvent from "lib/hooks/useEvent" import useProfile from "lib/hooks/useProfile" - +import { createPortal } from "react-dom" import Stack from "components/ui/Stack" import { GoIssueClosed } from "react-icons/go" @@ -56,6 +57,7 @@ interface ThreadProps { setActive: (active: boolean) => void onDelete: () => void finaliseThread: (thread: CommentThread, comment: Comment) => void + initialAnchor?: { top: number; left: number } } function useResolveThread(thread: number | CommentThread) { @@ -84,7 +86,7 @@ function useResolveThread(thread: number | CommentThread) { return { commentThread, threadId, commentThreadIsLoading, isLoading, isPlaceholder, mutate } } -const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadProps) => { +const Thread = ({ thread, active, setActive, onDelete, finaliseThread, initialAnchor }: ThreadProps) => { const { commentThread, threadId, commentThreadIsLoading, isLoading, isPlaceholder, mutate } = useResolveThread(thread) const { user, isLoading: userIsLoading, error: userError } = useUser(commentThread?.createdByEmail) const { userProfile, isLoading: profileLoading, error: profileError } = useProfile() @@ -97,6 +99,38 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP mutate: mutateEvent, } = useEvent(activeEvent?.id) + const popupRef = useRef(null) // Reference for the popup element (the comment thread) + const triggerRef = useRef(null) // Reference for the trigger (Thread component) in paragraph + + const [popupPosition, setPopupPosition] = useState( + initialAnchor || { top: 0, left: 0 } // Use initialAnchor if provided + ) + + const calculatePosition = () => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect() + const popupWidth = 355 + const viewportWidth = window.innerWidth + + let leftPosition = rect.left + 35 + if (leftPosition + popupWidth > viewportWidth) { + // If the popup overflows the viewport, adjust to the left + leftPosition = rect.left - popupWidth - 10 + } + + setPopupPosition({ + top: rect.top + window.scrollY, + left: leftPosition, + }) + } + } + + useEffect(() => { + if (active) { + calculatePosition() + } + }, [active]) // Recalculate popup position when the thread is opened + const sortedComments = useMemo(() => { if (!commentThread) return [] return commentThread.Comment.sort((a, b) => a.index - b.index) @@ -138,6 +172,7 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP } const handleOpen = () => { + calculatePosition() setActive(!active) } @@ -175,9 +210,136 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP }) } + const renderPopup = () => { + if (!active) return null + + return createPortal( +
    +
    + + {user?.name} + {commentThread?.createdByEmail} + + {commentThread?.created + ? new Date(commentThread?.created).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) + : ""} + + + } + > + + + {commentThread?.resolved === true && ( + + )} + + +
    +
    +

    {}

    + + setActive(false)} dataCy={`Thread:${threadId}:CloseButton`}> + + + {!isPlaceholder && ( + ( + + + + )} + label={undefined} + className="not-prose" + > + + Visibility + + + Delete + + + )} + +
    + {isPlaceholder && + sortedComments.map((comment) => ( + + + + ))} + {!isPlaceholder && + sortedComments.map((comment) => ( + + + + ))} + {!isPlaceholder && ( + + {canResolve && ( + + + {commentThread?.resolved ? ( + + ) : ( + + )} + + + )} + {!threadEditing && ( + + + + + + )} + + )} +
    , + document.body // Render the popup in the body + ) + } + return (
    -
    +
    {commentThread?.resolved ? ( @@ -186,125 +348,7 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP )}
    - {active && ( -
    -
    - - {user?.name} - {commentThread?.createdByEmail} - - {commentThread?.created - ? new Date(commentThread?.created).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) - : ""} - - - } - > - - - {commentThread?.resolved === true && ( - - )} - - -
    -
    -

    {}

    - - - - - {!isPlaceholder && ( - ( - - - - )} - label={undefined} - className="not-prose" - > - - Visibility - - - Delete - - - )} - -
    - {isPlaceholder && - sortedComments.map((comment) => ( - - - - ))} - {!isPlaceholder && - sortedComments.map((comment) => ( - - - - ))} - {!isPlaceholder && ( - - {canResolve && ( - - - {commentThread?.resolved ? ( - - ) : ( - - )} - - - )} - {!threadEditing && ( - - - - - - )} - - )} -
    - )} + {renderPopup()} {/* Render the popup using a portal */}
    ) } diff --git a/cypress/component/CommentThread.cy.tsx b/cypress/component/CommentThread.cy.tsx index ace674c1..68df150a 100644 --- a/cypress/component/CommentThread.cy.tsx +++ b/cypress/component/CommentThread.cy.tsx @@ -4,8 +4,8 @@ import { User } from "pages/api/user/[email]" import React from "react" import { Event } from "pages/api/event/[eventId]" import { Comment } from "pages/api/comment/[commentId]" -import auth, { SessionProvider, useSession } from "next-auth/react" -import { useSWRConfig } from "swr" +import { getContainerEl } from "cypress/react" +import ReactDom from "react-dom" describe("CommentThread component", () => { context("with non-owner student", () => { @@ -82,18 +82,18 @@ describe("CommentThread component", () => { } beforeEach(() => { - // note: if you want to stub the console log, add this line: - // cy.stub(console, 'log').as('consoleLog') - cy.intercept(`/api/commentThread/${threadId}`, { commentThread: thread }).as("thread") + cy.intercept(`/api/commentThread/${threadId}`, { commentThread: thread }).as("initialThread") cy.intercept(`/api/user/${createdByEmail}`, { user: createdByUser }).as("createdByUser") cy.intercept(`/api/user/${currentUserEmail}`, { user: currentUser }).as("currentUser") cy.stub(localStorage, "getItem").returns("1") cy.intercept(`/api/event/1`, { event }).as("event") + const setActiveSpy = cy.spy().as("setActive") const onDeleteSpy = cy.spy().as("onDelete") const now = new Date() const tenMinutesFromNow = new Date() tenMinutesFromNow.setTime(now.getTime() + 10 * 60 * 1000) + cy.mount( { }) it("should open and close the thread", () => { - cy.get('[data-cy="Thread:1:OpenCloseButton"]').click() + cy.get('[data-cy="Thread:1:CloseButton"]').click() cy.get("@setActive").should("be.calledWith", false) }) @@ -235,6 +235,7 @@ describe("CommentThread component", () => { beforeEach(() => { cy.intercept(`/api/commentThread/${threadId}`, { commentThread: thread }).as("thread") cy.intercept(`/api/user/${currentUserEmail}`, { user: currentUser }).as("currentUser") + cy.intercept("DELETE", `/api/comment/1`, { comment: thread.Comment[0] }).as("comment1") cy.stub(localStorage, "getItem").returns("1") cy.intercept(`/api/event/1`, { event }).as("event") const setActiveSpy = cy.spy().as("setActive") @@ -262,6 +263,29 @@ describe("CommentThread component", () => { cy.wait("@thread") // GET /api/commentThread/1 cy.get('[data-cy="Thread:1:IsResolved"]').should("be.visible") }) + + it("should be able to delete Comment", () => { + cy.get('[data-cy="Comment:1:Delete"]').should("be.visible") + cy.get('[data-cy="Comment:1:Delete"]').click() + cy.wait("@comment1") // DELETE /api/comment/1 + }) + + it("should be able to edit Comment, textarea should become active and save button should appear", () => { + cy.get('[data-cy="Comment:1:Edit"]').should("be.visible").click() + cy.get('[data-cy="Comment:1:Editing"]').should("be.visible") + cy.get("textarea") // Select the textarea + .should("be.visible") + .and("not.be.disabled") + .and("not.have.attr", "readonly") + cy.get('[data-cy="Comment:1:Save"]').should("be.visible") + }) + + it("should be able to delete Thread", () => { + cy.get('[data-cy="Thread:1:Dropdown"]').should("be.visible").click() + cy.get('[data-cy="Thread:1:Delete"]').should("be.visible") + cy.get('[data-cy="Thread:1:Delete"]').click() + cy.get("@onDelete").should("be.called") + }) }) context("with non-owner instructor", () => { @@ -367,5 +391,12 @@ describe("CommentThread component", () => { cy.wait("@thread") // GET /api/commentThread/1 cy.get('[data-cy="Thread:1:IsResolved"]').should("be.visible") }) + + it("should be able to delete Thread", () => { + cy.get('[data-cy="Thread:1:Dropdown"]').should("be.visible").click() + cy.get('[data-cy="Thread:1:Delete"]').should("be.visible") + cy.get('[data-cy="Thread:1:Delete"]').click() + cy.get("@onDelete").should("be.called") + }) }) }) diff --git a/cypress/component/EventCommentThreads.cy.tsx b/cypress/component/EventCommentThreads.cy.tsx index f028114f..b65ccffa 100644 --- a/cypress/component/EventCommentThreads.cy.tsx +++ b/cypress/component/EventCommentThreads.cy.tsx @@ -1,4 +1,5 @@ import EventCommentThreads from "components/EventCommentThreads" +import { data } from "cypress/types/jquery" import { Material } from "lib/material" import { EventFull } from "lib/types" import { CommentThread } from "pages/api/commentThread/[commentThreadId]" @@ -63,7 +64,7 @@ describe("EventCommentThreads component", () => { }, ], } - + // These are threads that will be associated with unresolved sections (No section) const threads: CommentThread[] = [ { id: 1, @@ -113,15 +114,102 @@ describe("EventCommentThreads component", () => { }, ], }, + { + id: 3, + eventId: 1, + groupId: null, + section: "test.theme1.course1.section1", + problemTag: "", + textRef: "", + textRefStart: 0, + textRefEnd: 0, + createdByEmail: "student@gmail.com", + created: new Date(), + resolved: false, + instructorOnly: false, + Comment: [ + { + id: 1, + threadId: 1, + createdByEmail: "student@gmail.com", + created: new Date(), + index: 0, + markdown: "This is an unresolved thread in a verified section", + }, + ], + }, + { + id: 4, + eventId: 1, + groupId: null, + section: "test.theme1.course1.section1", + problemTag: "", + textRef: "", + textRefStart: 0, + textRefEnd: 0, + createdByEmail: "student@gmail.com", + created: new Date(), + resolved: true, + instructorOnly: false, + Comment: [ + { + id: 1, + threadId: 1, + createdByEmail: "student@gmail.com", + created: new Date(), + index: 0, + markdown: "This is a resolved thread in a verified section", + }, + ], + }, ] cy.intercept("/api/commentThread?eventId=1", { commentThreads: threads }).as("commentThreads") cy.mount() cy.wait("@commentThreads") }) + context("For threads associated with a missing section", () => { + it("shows unresolved threads by default", () => { + cy.get('[data-cy="section:No Section found (material changed?):unresolved"]').click() + cy.contains("thankyou").should("not.be.visible") + cy.contains("this is not workin!").should("be.visible") + }) + + it("can hide unresolved threads", () => { + cy.get('[data-cy="section:No Section found (material changed?):unresolved"]').should("be.visible") + cy.get('[data-cy="unresolved-threads-expand"]').click() + cy.get('[data-cy="section:No Section found (material changed?):unresolved"]').should("not.be.visible") + }) + + it("shows resolved threads when clicked", () => { + cy.get('[data-cy="resolved-threads-expand"]').click() + cy.get('[data-cy="section:No Section found (material changed?):resolved"]').should("be.visible") + cy.get('[data-cy="section:No Section found (material changed?):resolved"]').click() + cy.contains("thankyou").should("be.visible") + }) + }) + context("For threads in 'Section 1'", () => { + it("shows unresolved threads by default, can hide", () => { + cy.contains("This is an unresolved thread in a verified section").should("be.visible") + cy.contains("This is a resolved thread in a verified section").should("not.be.visible") + cy.get('[data-cy="unresolved-threads-expand"]').should("be.visible").click() + cy.contains("This is an unresolved thread in a verified section").should("not.be.visible") + }) + + it("can hide sections", () => { + cy.contains("This is an unresolved thread in a verified section").should("be.visible") + cy.get('[data-cy="section:Section 1:unresolved"]').click() + cy.wait(100) + cy.contains("This is an unresolved thread in a verified section").should("not.exist") + }) - it("shows unresolved threads", () => { - cy.contains("thankyou").should("not.exist") - cy.contains("this is not workin!").should("exist") + it("shows resolved threads when clicked", () => { + cy.get('[data-cy="section:Section 1:resolved"]').should("not.be.visible") + cy.get('[data-cy="resolved-threads-expand"]').click() + cy.get('[data-cy="section:No Section found (material changed?):resolved"]').should("be.visible") + cy.contains("This is a resolved thread in a verified section").should("be.visible") + cy.get('[data-cy="section:Section 1:resolved"]').should("be.visible").click() + cy.contains("This is a resolved thread in a verified section").should("not.exist") + }) }) }) diff --git a/lib/hooks/useUsersList.ts b/lib/hooks/useUsersList.ts new file mode 100644 index 00000000..86c98498 --- /dev/null +++ b/lib/hooks/useUsersList.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react" +import useSWR from "swr" +import { basePath } from "lib/basePath" +import { User, UserPublic } from "pages/api/user/[email]" + +// Single user fetcher for individual email +const fetchUser = async (email: string): Promise => { + const response = await fetch(`${basePath}/api/user/${email}`) + const data = await response.json() + return data.user +} + +const useUsersList = (emails: string[]) => { + const [users, setUsers] = useState<{ [email: string]: User | UserPublic | undefined }>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(undefined) + + useEffect(() => { + const uniqueEmails = Array.from(new Set(emails)) // Avoid duplicate requests for the same email + setLoading(true) + + const fetchUsers = async () => { + try { + const fetchedUsers: { [email: string]: User | UserPublic | undefined } = {} + await Promise.all( + uniqueEmails.map(async (email) => { + const user = await fetchUser(email) + if (user) { + fetchedUsers[email] = user + } + }) + ) + setUsers(fetchedUsers) + setLoading(false) + } catch (err) { + setError("Failed to load users") + setLoading(false) + } + } + + if (uniqueEmails.length > 0) { + fetchUsers() + } + }, [emails]) + + return { users, loading, error } +} + +export default useUsersList diff --git a/pages/event/[eventId].tsx b/pages/event/[eventId].tsx index c6c34758..5a24dbf1 100644 --- a/pages/event/[eventId].tsx +++ b/pages/event/[eventId].tsx @@ -8,7 +8,8 @@ import NavDiagram from "components/navdiagram/NavDiagram" import Title from "components/ui/Title" import type { Event, EventFull } from "lib/types" import { basePath } from "lib/basePath" -import { Avatar, Button, Card, Tabs } from "flowbite-react" +import { Button, Card, Tabs } from "flowbite-react" +import Avatar from "@mui/material/Avatar" import EventProblems from "components/EventProblems" import { useForm, Controller, useFieldArray } from "react-hook-form" import { useSession } from "next-auth/react" @@ -145,7 +146,7 @@ const Event: NextPage = ({ material, event, pageInfo }) => { {eventUser.map((user, index) => (
    - +

    {user.user?.name}