From 75b5ce64e1878e9015e47b611496650342291f6c Mon Sep 17 00:00:00 2001 From: Alasdair Wilson Date: Tue, 15 Oct 2024 13:36:21 +0100 Subject: [PATCH 1/8] new user comment table --- components/EventCommentThreads.tsx | 287 +++++++++++++++++++++++++++-- components/MuiTheme.tsx | 80 +++++++- components/content/Paragraph.tsx | 2 +- lib/hooks/useUsersList.ts | 49 +++++ 4 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 lib/hooks/useUsersList.ts diff --git a/components/EventCommentThreads.tsx b/components/EventCommentThreads.tsx index 8c59e381..cdf10104 100644 --- a/components/EventCommentThreads.tsx +++ b/components/EventCommentThreads.tsx @@ -1,8 +1,50 @@ -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 "flowbite-react" 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, +} from "@mui/material" +import Stack from "./ui/Stack" +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 @@ -11,26 +53,237 @@ type Props = { const EventCommentThreads: React.FC = ({ material, event }) => { 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) + + console.log("us", users) + + 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 noSectionKey is collapsed + initialExpandedState[noSectionKey] = false + + setExpandedSections(initialExpandedState) + } + }, [commentThreads]) // Only run when commentThreads change + + 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) => { + console.log("toggleSection", sectionName, expandedSections) + 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 : ""} - -
  • - ) - })} -
+ + }> + 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" }}> + (theme.palette.mode === "dark" ? "#000000" : "#e5e7eb"), + }} + > + + {isExpanded ? : } {sectionName} + + + + + {isExpanded && ( + <> + + + Created By + + + Comment + + + {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} + + + + ) + })} + + )} + + ) + })} + +
+
+
+
+ + + }> + 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" }}> + (theme.palette.mode === "dark" ? "#000000" : "#e5e7eb"), + }} + > + + {isExpanded ? : } {sectionName} + + + + + {isExpanded && ( + <> + + + Created By + + + Comment + + + {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} + + + + ) + })} + + )} + + ) + })} + +
+
+
+
) } 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/content/Paragraph.tsx b/components/content/Paragraph.tsx index e64d2343..375f6ed5 100644 --- a/components/content/Paragraph.tsx +++ b/components/content/Paragraph.tsx @@ -136,7 +136,7 @@ const Paragraph: React.FC = ({ content, section }) => { return ( <>
-

{content}

+
{content}
{activeEvent && (
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 From 9167a570fedbc7a5a3d27ac3a2244a1afdd0af91 Mon Sep 17 00:00:00 2001 From: Alasdair Wilson Date: Tue, 15 Oct 2024 15:46:00 +0100 Subject: [PATCH 2/8] Refactor EventCommentThreads component to add expand functionality and display user avatars --- components/EventCommentThreads.tsx | 65 ++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/components/EventCommentThreads.tsx b/components/EventCommentThreads.tsx index cdf10104..3799ed5f 100644 --- a/components/EventCommentThreads.tsx +++ b/components/EventCommentThreads.tsx @@ -18,8 +18,11 @@ import { TableHead, TableRow, Paper, + Tooltip, } from "@mui/material" import Stack from "./ui/Stack" +import Thread from "./content/Thread" +import { FaLink } from "react-icons/fa" import ExpandMoreIcon from "@mui/icons-material/ExpandMore" import ExpandLessIcon from "@mui/icons-material/ExpandLess" import { CommentThread } from "pages/api/commentThread/[commentThreadId]" @@ -52,6 +55,7 @@ type Props = { } const EventCommentThreads: React.FC = ({ material, event }) => { + const [activeThreadId, setActiveThreadId] = useState(undefined) const { commentThreads, error: threadsError, isLoading: threadsIsLoading } = useCommentThreads(event.id) const [expandedSections, setExpandedSections] = useState<{ [key: string]: boolean }>({}) const { theme: currentTheme } = useTheme() @@ -160,6 +164,9 @@ const EventCommentThreads: React.FC = ({ material, event }) => { Comment + + Expand + {groupedUnresolvedThreads[sectionName].map((thread) => { const urlWithAnchor = url + `#comment-thread-${thread.id}` @@ -173,7 +180,7 @@ const EventCommentThreads: React.FC = ({ material, event }) => { return ( - + = ({ material, event }) => { <>{thread.createdByEmail} - + - {commentText} + + + {commentText} + + + + + active ? setActiveThreadId(thread.id) : setActiveThreadId(undefined) + } + finaliseThread={() => {}} + onDelete={() => {}} + /> + ) })} @@ -249,6 +272,9 @@ const EventCommentThreads: React.FC = ({ material, event }) => { Comment + + Expand + {groupedResolvedThreads[sectionName].map((thread) => { const urlWithAnchor = url + `#comment-thread-${thread.id}` @@ -262,15 +288,38 @@ const EventCommentThreads: React.FC = ({ material, event }) => { return ( - - {/* */} - {thread.createdByEmail} + + + + <>{thread.createdByEmail} + - + - {commentText} + + + {commentText} + + + + + active ? setActiveThreadId(thread.id) : setActiveThreadId(undefined) + } + finaliseThread={() => {}} + onDelete={() => {}} + /> + ) })} From 4e06b7bed23bcc24aaf31bab51db37d93ea7960e Mon Sep 17 00:00:00 2001 From: Alasdair Wilson Date: Tue, 15 Oct 2024 15:46:13 +0100 Subject: [PATCH 3/8] Refactor Thread component to add popup functionality for user comments rather than putting the thread in the dom direct --- components/content/Thread.tsx | 282 +++++++++++++++++++--------------- 1 file changed, 161 insertions(+), 121 deletions(-) diff --git a/components/content/Thread.tsx b/components/content/Thread.tsx index ab2aead7..13a3b108 100644 --- a/components/content/Thread.tsx +++ b/components/content/Thread.tsx @@ -15,7 +15,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" @@ -97,6 +97,36 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP mutate: mutateEvent, } = useEvent(activeEvent?.id) + const popupRef = useRef(null) // Reference for the popup element + const triggerRef = useRef(null) // Reference for the trigger (Thread component) + + const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }) + console.log("Popup position", popupPosition) + const calculatePosition = () => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect() + const popupWidth = 355 // Adjust this value based on your actual popup width + const viewportWidth = window.innerWidth + + let leftPosition = rect.right + 10 // Position to the right of the thread + 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, // Account for vertical scroll + left: leftPosition, + }) + } + } + + useEffect(() => { + if (active) { + calculatePosition() // Calculate the position when the popup is active + } + }, [active]) // Recalculate when the active state changes + const sortedComments = useMemo(() => { if (!commentThread) return [] return commentThread.Comment.sort((a, b) => a.index - b.index) @@ -138,6 +168,7 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP } const handleOpen = () => { + calculatePosition() setActive(!active) } @@ -175,9 +206,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)}> + + + {!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 root of the document + ) + } + return (
-
+
{commentThread?.resolved ? ( @@ -186,125 +344,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 */}
) } From aa2d42312a85736e46f19280af538a7bb2146342 Mon Sep 17 00:00:00 2001 From: Alasdair Wilson Date: Wed, 16 Oct 2024 11:14:45 +0100 Subject: [PATCH 4/8] Refactor EventCommentThreads component to add expand functionality and display user avatars --- components/EventCommentThreads.tsx | 19 +++++++++++++++---- components/content/Thread.tsx | 1 - 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/components/EventCommentThreads.tsx b/components/EventCommentThreads.tsx index 3799ed5f..aa706d4a 100644 --- a/components/EventCommentThreads.tsx +++ b/components/EventCommentThreads.tsx @@ -20,9 +20,9 @@ import { Paper, Tooltip, } from "@mui/material" +import LinkIcon from "@mui/icons-material/Link" import Stack from "./ui/Stack" import Thread from "./content/Thread" -import { FaLink } from "react-icons/fa" import ExpandMoreIcon from "@mui/icons-material/ExpandMore" import ExpandLessIcon from "@mui/icons-material/ExpandLess" import { CommentThread } from "pages/api/commentThread/[commentThreadId]" @@ -67,8 +67,6 @@ const EventCommentThreads: React.FC = ({ material, event }) => { const { users, error: usersError } = useUsersList(emails) - console.log("us", users) - useEffect(() => { if (commentThreads) { const unresolvedSectionNames = Object.keys(groupedUnresolvedThreads) @@ -109,7 +107,6 @@ const EventCommentThreads: React.FC = ({ material, event }) => { ) const toggleSection = (sectionName: string) => { - console.log("toggleSection", sectionName, expandedSections) setExpandedSections((prevState) => ({ ...prevState, [sectionName]: !prevState[sectionName], @@ -151,6 +148,13 @@ const EventCommentThreads: React.FC = ({ material, event }) => { > {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 + /> @@ -259,6 +263,13 @@ const EventCommentThreads: React.FC = ({ material, event }) => { > {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 + /> diff --git a/components/content/Thread.tsx b/components/content/Thread.tsx index 13a3b108..3f370a3c 100644 --- a/components/content/Thread.tsx +++ b/components/content/Thread.tsx @@ -101,7 +101,6 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP const triggerRef = useRef(null) // Reference for the trigger (Thread component) const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }) - console.log("Popup position", popupPosition) const calculatePosition = () => { if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect() From 2fbcdc44aceafad9ac9d2106802ceb6ae0f55b4f Mon Sep 17 00:00:00 2001 From: Alasdair Wilson Date: Thu, 28 Nov 2024 10:52:59 +0000 Subject: [PATCH 5/8] Fix the test but need to reimplement portal positioning fix to see what breaks the aftereach in cypress --- components/content/Paragraph.tsx | 21 +++++++++---- components/content/Thread.tsx | 16 ++++++---- cypress/component/CommentThread.cy.tsx | 43 ++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/components/content/Paragraph.tsx b/components/content/Paragraph.tsx index 375f6ed5..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) }) } @@ -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 3f370a3c..6eb94813 100644 --- a/components/content/Thread.tsx +++ b/components/content/Thread.tsx @@ -56,6 +56,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 +85,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,10 +98,13 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP mutate: mutateEvent, } = useEvent(activeEvent?.id) - const popupRef = useRef(null) // Reference for the popup element - const triggerRef = useRef(null) // Reference for the trigger (Thread component) + 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 [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }) const calculatePosition = () => { if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect() @@ -122,7 +126,7 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP useEffect(() => { if (active) { - calculatePosition() // Calculate the position when the popup is active + calculatePosition() } }, [active]) // Recalculate when the active state changes @@ -243,7 +247,7 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread }: ThreadP

{}

- setActive(false)}> + setActive(false)} dataCy={`Thread:${threadId}:CloseButton`}> {!isPlaceholder && ( 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") + }) }) }) From cbe932cc3a23ca54671751971c937352bd398e0c Mon Sep 17 00:00:00 2001 From: Alasdair Wilson Date: Thu, 28 Nov 2024 11:01:47 +0000 Subject: [PATCH 6/8] fix: rect.right is too large so use left as reference to render portal. --- components/content/Thread.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/content/Thread.tsx b/components/content/Thread.tsx index 6eb94813..10c342b6 100644 --- a/components/content/Thread.tsx +++ b/components/content/Thread.tsx @@ -108,17 +108,17 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread, initialAn const calculatePosition = () => { if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect() - const popupWidth = 355 // Adjust this value based on your actual popup width + const popupWidth = 355 const viewportWidth = window.innerWidth - let leftPosition = rect.right + 10 // Position to the right of the thread + 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, // Account for vertical scroll + top: rect.top + window.scrollY, left: leftPosition, }) } @@ -128,7 +128,7 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread, initialAn if (active) { calculatePosition() } - }, [active]) // Recalculate when the active state changes + }, [active]) // Recalculate popup position when the thread is opened const sortedComments = useMemo(() => { if (!commentThread) return [] @@ -332,7 +332,7 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread, initialAn )}
, - document.body // Render the popup in the root of the document + document.body // Render the popup in the body ) } From 4ffa7b046d3059d93c6c5f30204a146daacffdd6 Mon Sep 17 00:00:00 2001 From: Alasdair Wilson Date: Thu, 28 Nov 2024 12:17:43 +0000 Subject: [PATCH 7/8] added: A new set of eventcommentthreads tests for the new table --- components/EventCommentThreads.tsx | 16 +++- cypress/component/EventCommentThreads.cy.tsx | 96 +++++++++++++++++++- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/components/EventCommentThreads.tsx b/components/EventCommentThreads.tsx index aa706d4a..80ef840d 100644 --- a/components/EventCommentThreads.tsx +++ b/components/EventCommentThreads.tsx @@ -116,7 +116,7 @@ const EventCommentThreads: React.FC = ({ material, event }) => { return (
- }> + } data-cy="unresolved-threads-expand"> Unresolved Comment Threads @@ -139,7 +139,11 @@ const EventCommentThreads: React.FC = ({ material, event }) => { return ( - toggleSection(sectionName)} style={{ cursor: "pointer" }}> + toggleSection(sectionName)} + style={{ cursor: "pointer" }} + data-cy={`section:${sectionName}:unresolved`} + > = ({ material, event }) => { - }> + } data-cy="resolved-threads-expand"> Resolved Comment Threads @@ -254,7 +258,11 @@ const EventCommentThreads: React.FC = ({ material, event }) => { return ( - toggleSection(sectionName)} style={{ cursor: "pointer" }}> + toggleSection(sectionName)} + style={{ cursor: "pointer" }} + data-cy={`section:${sectionName}:resolved`} + > { }, ], } - + // 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") + }) }) }) From abc5a5f457330501bfefff76fd3b17a984738109 Mon Sep 17 00:00:00 2001 From: Alasdair Wilson Date: Tue, 7 Jan 2025 10:37:01 +0000 Subject: [PATCH 8/8] refactor: replace flowbite Avatar with MUI Avatar --- components/EventCommentThreads.tsx | 17 ++++++++--------- components/EventProblems.tsx | 5 +++-- components/EventUsers.tsx | 5 +++-- components/Navbar.tsx | 12 +++++------- components/content/Comment.tsx | 5 +++-- components/content/Thread.tsx | 5 +++-- pages/event/[eventId].tsx | 5 +++-- 7 files changed, 28 insertions(+), 26 deletions(-) diff --git a/components/EventCommentThreads.tsx b/components/EventCommentThreads.tsx index 80ef840d..4e4cc36c 100644 --- a/components/EventCommentThreads.tsx +++ b/components/EventCommentThreads.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo } from "react" import { Material, sectionSplit } from "lib/material" import { EventFull } from "lib/types" -import { Avatar } from "flowbite-react" +import Avatar from "@mui/material/Avatar" import Link from "next/link" import useCommentThreads from "lib/hooks/useCommentThreads" import { useTheme } from "next-themes" @@ -84,12 +84,12 @@ const EventCommentThreads: React.FC = ({ material, event }) => { initialExpandedState[sectionName] = true }) - // ensure noSectionKey is collapsed + // ensure if the section was not found then that section is collapsed initialExpandedState[noSectionKey] = false setExpandedSections(initialExpandedState) } - }, [commentThreads]) // Only run when commentThreads change + }, [commentThreads]) if (threadsIsLoading) return
Loading...
if (!commentThreads) return
Failed to load
@@ -192,9 +192,9 @@ const EventCommentThreads: React.FC = ({ material, event }) => { <>{thread.createdByEmail} @@ -311,9 +311,8 @@ const EventCommentThreads: React.FC = ({ material, event }) => { <>{thread.createdByEmail} 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/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/Thread.tsx b/components/content/Thread.tsx index 10c342b6..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" @@ -234,7 +235,7 @@ const Thread = ({ thread, active, setActive, onDelete, finaliseThread, initialAn } > - + {commentThread?.resolved === true && ( = ({ material, event, pageInfo }) => { {eventUser.map((user, index) => (
    - +

    {user.user?.name}