diff --git a/package.json b/package.json index 8ca6caed..7f42db45 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "yup": "^1.4.0" }, "scripts": { - "start": "react-scripts start", + "start": "HOST=0.0.0.0 react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" diff --git a/public/assets/icons/Check-icon.png b/public/assets/icons/Check-icon.png new file mode 100644 index 00000000..c4d5504e Binary files /dev/null and b/public/assets/icons/Check-icon.png differ diff --git a/src/App.tsx b/src/App.tsx index 27736ba3..95cf86e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import RoleEditor, { loadAvailableRole } from "./pages/Roles/RoleEditor"; import Roles, { loadRoles } from "./pages/Roles/Roles"; import Assignment from "./pages/Assignments/Assignment"; import AssignmentEditor from "./pages/Assignments/AssignmentEditor"; +import AssignmentEditPage from "./pages/Assignments/AssignmentEditPage"; import { loadAssignment } from "pages/Assignments/AssignmentUtil"; import ErrorPage from "./router/ErrorPage"; import ProtectedRoute from "./router/ProtectedRoute"; @@ -35,6 +36,7 @@ import EditProfile from "pages/Profile/Edit"; import Reviews from "pages/Reviews/reviews"; import Email_the_author from "./pages/Email_the_author/email_the_author"; import CreateTeams from "pages/Assignments/CreateTeams"; +import StudentTasks from "pages/StudentTasks/StudentTasks"; import AssignReviewer from "pages/Assignments/AssignReviewer"; import ViewSubmissions from "pages/Assignments/ViewSubmissions"; import ViewScores from "pages/Assignments/ViewScores"; @@ -99,13 +101,12 @@ function App() { element: , loader: loadAssignment, }, - { - path: "edit/:id", - element: , - loader: loadAssignment, - }, ], }, + { + path: "assignments/edit/:id", + element: } leastPrivilegeRole={ROLE.TA} />, + }, { path: "users", element: } leastPrivilegeRole={ROLE.TA} />, @@ -198,6 +199,14 @@ function App() { path: "email_the_author", element: , }, + { + path: "student_tasks", + element: } />, + }, + { + path: "student_tasks/:assignmentId", + element: } />, + }, // Fixed the missing comma and added an opening curly brace { path: "courses", diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index a9e5391b..beabe119 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -18,7 +18,6 @@ import { import GlobalFilter from "./GlobalFilter"; import Pagination from "./Pagination"; import RowSelectCheckBox from "./RowSelectCheckBox"; - import { FaSearch } from "react-icons/fa"; interface TableProps { data: Record[]; @@ -151,14 +150,21 @@ import { handleSelectionChange?.(selectedData); }, [flatRows]); - const toggleGlobalFilter = () => { - setIsGlobalFilterVisible(!isGlobalFilterVisible); - }; const firstRenderRef = useRef(true); return ( <> + @@ -166,10 +172,6 @@ import { )} - - - {isGlobalFilterVisible ? " Hide" : " Show"} - @@ -210,12 +212,18 @@ import { {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + + {row.getVisibleCells().map((cell) => { + const selected = !!row.original.isSelected; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} {row.getIsExpanded() && renderSubComponent && ( diff --git a/src/hooks/useAPI.ts b/src/hooks/useAPI.ts index cb112bfe..e58e5c8e 100644 --- a/src/hooks/useAPI.ts +++ b/src/hooks/useAPI.ts @@ -32,7 +32,10 @@ const useAPI = () => { let errorMessage = ""; axios(requestConfig) - .then((response) => setData(response)) + .then((response) => { + setData(response); + setIsLoading(false); + }) .catch((err) => { if (err.response) { const errors = err.response.data; @@ -51,8 +54,8 @@ const useAPI = () => { } if (errorMessage) setError(errorMessage); + setIsLoading(false); }); - setIsLoading(false); }, []); return { data, setData, isLoading, error, sendRequest }; diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index 5b278dd8..b051f40c 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -143,15 +143,14 @@ const Header: React.FC = () => { )} - - Assignments - + {auth.user.role === ROLE.STUDENT.valueOf() && ( + + Assignments + + )} Profile - - Student View - Grades View diff --git a/src/pages/Assignments/Assignment.tsx b/src/pages/Assignments/Assignment.tsx index b178162a..340977ca 100644 --- a/src/pages/Assignments/Assignment.tsx +++ b/src/pages/Assignments/Assignment.tsx @@ -1,23 +1,19 @@ -import { Button, Col, Container, Row } from "react-bootstrap"; -import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { Container, Row, Col, Button } from "react-bootstrap"; +import { Outlet, useNavigate, useLocation } from "react-router-dom"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { assignmentColumns as ASSIGNMENT_COLUMNS } from "./AssignmentColumns"; -import { BsFileText } from "react-icons/bs"; -import DeleteAssignment from "./AssignmentDelete"; import { IAssignmentResponse } from "../../utils/interfaces"; import { RootState } from "../../store/store"; import { Row as TRow } from "@tanstack/react-table"; -import Table from "components/Table/Table"; import { alertActions } from "store/slices/alertSlice"; import useAPI from "hooks/useAPI"; - +import Table from "components/Table/Table"; +import { assignmentColumns } from "./AssignmentColumns"; +import AssignmentDelete from "./AssignmentDelete"; +import { BsPlusSquareFill } from "react-icons/bs"; const Assignments = () => { const { error, isLoading, data: assignmentResponse, sendRequest: fetchAssignments } = useAPI(); - const { data: coursesResponse, sendRequest: fetchCourses } = useAPI(); - - const auth = useSelector( (state: RootState) => state.authentication, (prev, next) => prev.isAuthenticated === next.isAuthenticated @@ -31,45 +27,22 @@ const Assignments = () => { data?: IAssignmentResponse; }>({ visible: false }); - - const fetchData = useCallback(async () => { - try { - const [assignments, courses] = await Promise.all([ - fetchAssignments({ url: `/assignments` }), - fetchCourses({ url: '/courses' }), - ]); - // Handle the responses as needed - } catch (err) { - // Handle any errors that occur during the fetch - console.error("Error fetching data:", err); - } - }, [fetchAssignments, fetchCourses]); - useEffect(() => { if (!showDeleteConfirmation.visible) { - fetchData(); + fetchAssignments({ url: `/assignments` }); } - }, [fetchData, showDeleteConfirmation.visible, auth.user.id]); + }, [fetchAssignments, location, showDeleteConfirmation.visible, auth.user.id]); - let mergedData: Array = []; - - if (assignmentResponse && coursesResponse) { - mergedData = assignmentResponse.data.map((assignment: any) => { - const course = coursesResponse.data.find((c: any) => c.id === assignment.course_id); - return { ...assignment, courseName: course ? course.name : 'Unknown' }; - }); - } - - - - // Error alert useEffect(() => { if (error) { dispatch(alertActions.showAlert({ variant: "danger", message: error })); } }, [error, dispatch]); - const onDeleteAssignmentHandler = useCallback(() => setShowDeleteConfirmation({ visible: false }), []); + const onDeleteAssignmentHandler = useCallback( + () => setShowDeleteConfirmation({ visible: false }), + [] + ); const onEditHandle = useCallback( (row: TRow) => navigate(`edit/${row.original.id}`), @@ -77,18 +50,19 @@ const Assignments = () => { ); const onDeleteHandle = useCallback( - (row: TRow) => setShowDeleteConfirmation({ visible: true, data: row.original }), + (row: TRow) => + setShowDeleteConfirmation({ visible: true, data: row.original }), [] ); const tableColumns = useMemo( - () => ASSIGNMENT_COLUMNS(onEditHandle, onDeleteHandle), + () => assignmentColumns(onEditHandle, onDeleteHandle), [onDeleteHandle, onEditHandle] ); const tableData = useMemo( - () => (isLoading || !mergedData?.length ? [] : mergedData), - [mergedData, isLoading] + () => (isLoading || !assignmentResponse?.data ? [] : assignmentResponse.data), + [assignmentResponse?.data, isLoading] ); return ( @@ -102,23 +76,26 @@ const Assignments = () => {
- + - {showDeleteConfirmation.visible && ( - + )} @@ -128,4 +105,4 @@ const Assignments = () => { ); }; -export default Assignments; \ No newline at end of file +export default Assignments; diff --git a/src/pages/Assignments/AssignmentEditPage.tsx b/src/pages/Assignments/AssignmentEditPage.tsx new file mode 100644 index 00000000..6afd2881 --- /dev/null +++ b/src/pages/Assignments/AssignmentEditPage.tsx @@ -0,0 +1,513 @@ +import { Container, Row, Col } from "react-bootstrap"; +import { useParams, useNavigate } from "react-router-dom"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "../../store/store"; +import { alertActions } from "store/slices/alertSlice"; +import useAPI from "hooks/useAPI"; + +// Import tab components +import GeneralTab from "./tabs/GeneralTab"; +import TopicsTab from "./tabs/TopicsTab"; +import RubricsTab from "./tabs/RubricsTab"; +import ReviewStrategyTab from "./tabs/ReviewStrategyTab"; +import DueDatesTab from "./tabs/DueDatesTab"; +import EtcTab from "./tabs/EtcTab"; + +interface TopicSettings { + allowTopicSuggestions: boolean; + enableBidding: boolean; + enableAuthorsReview: boolean; + allowReviewerChoice: boolean; + allowBookmarks: boolean; + allowBiddingForReviewers: boolean; +} + +interface TopicData { + id: string; + databaseId: number; + name: string; + url?: string; + description?: string; + category?: string; + assignedTeams: any[]; + waitlistedTeams: any[]; + questionnaire: string; + numSlots: number; + availableSlots: number; + bookmarks: any[]; + partnerAd?: any; + createdAt?: string; + updatedAt?: string; +} + +const AssignmentEditPage = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const auth = useSelector((state: RootState) => state.authentication); + + const [activeTab, setActiveTab] = useState("topics"); + const [assignmentName, setAssignmentName] = useState(""); + + // Topic settings state + const [topicSettings, setTopicSettings] = useState({ + allowTopicSuggestions: false, + enableBidding: false, + enableAuthorsReview: true, + allowReviewerChoice: true, + allowBookmarks: false, + allowBiddingForReviewers: false, + }); + + // Topics data state + const [topicsData, setTopicsData] = useState([]); + const [topicsLoading, setTopicsLoading] = useState(false); + const [topicsError, setTopicsError] = useState(null); + + // Fetch assignment data + const { data: assignmentResponse, error: assignmentError, sendRequest: fetchAssignment } = useAPI(); + const { data: topicsResponse, error: topicsApiError, sendRequest: fetchTopics } = useAPI(); + const { data: updateResponse, error: updateError, sendRequest: updateAssignment } = useAPI(); + const { data: deleteResponse, error: deleteError, sendRequest: deleteTopic } = useAPI(); + const { data: createResponse, error: createError, sendRequest: createTopic } = useAPI(); + const { data: updateTopicResponse, error: updateTopicError, sendRequest: updateTopic } = useAPI(); + const { data: dropTeamResponse, error: dropTeamError, sendRequest: dropTeamRequest } = useAPI(); + + useEffect(() => { + if (id) { + fetchAssignment({ url: `/assignments/${id}` }); + } + }, [id, fetchAssignment]); + + useEffect(() => { + if (assignmentResponse?.data) { + setAssignmentName(assignmentResponse.data.name || ""); + // Load allow_bookmarks setting from backend + if (assignmentResponse.data.allow_bookmarks !== undefined) { + setTopicSettings(prev => ({ ...prev, allowBookmarks: assignmentResponse.data.allow_bookmarks })); + } + } + }, [assignmentResponse]); + + useEffect(() => { + if (assignmentError) { + dispatch(alertActions.showAlert({ variant: "danger", message: assignmentError })); + } + }, [assignmentError, dispatch]); + + useEffect(() => { + if (updateResponse) { + dispatch(alertActions.showAlert({ variant: "success", message: "Bookmark setting saved successfully" })); + } + }, [updateResponse, dispatch]); + + useEffect(() => { + if (updateError) { + dispatch(alertActions.showAlert({ variant: "danger", message: updateError })); + } + }, [updateError, dispatch]); + + useEffect(() => { + if (deleteResponse) { + dispatch(alertActions.showAlert({ variant: "success", message: "Topic deleted successfully" })); + // Refresh topics data + if (id) { + fetchTopics({ url: `/project_topics?assignment_id=${id}` }); + } + } + }, [deleteResponse, dispatch, id, fetchTopics]); + + useEffect(() => { + if (deleteError) { + dispatch(alertActions.showAlert({ variant: "danger", message: deleteError })); + } + }, [deleteError, dispatch]); + + useEffect(() => { + if (createResponse) { + dispatch(alertActions.showAlert({ variant: "success", message: "Topic created successfully" })); + // Refresh topics data + if (id) { + fetchTopics({ url: `/project_topics?assignment_id=${id}` }); + } + } + }, [createResponse, dispatch, id, fetchTopics]); + + useEffect(() => { + if (createError) { + dispatch(alertActions.showAlert({ variant: "danger", message: createError })); + } + }, [createError, dispatch]); + + useEffect(() => { + if (updateTopicResponse) { + dispatch(alertActions.showAlert({ variant: "success", message: "Topic updated successfully" })); + // Refresh topics data + if (id) { + fetchTopics({ url: `/project_topics?assignment_id=${id}` }); + } + } + }, [updateTopicResponse, dispatch, id, fetchTopics]); + + useEffect(() => { + if (updateTopicError) { + dispatch(alertActions.showAlert({ variant: "danger", message: updateTopicError })); + } + }, [updateTopicError, dispatch]); + + useEffect(() => { + if (dropTeamResponse) { + dispatch(alertActions.showAlert({ variant: "success", message: "Team removed from topic successfully" })); + if (id) { + fetchTopics({ url: `/project_topics?assignment_id=${id}` }); + } + } + }, [dropTeamResponse, dispatch, id, fetchTopics]); + + useEffect(() => { + if (dropTeamError) { + dispatch(alertActions.showAlert({ variant: "danger", message: dropTeamError })); + } + }, [dropTeamError, dispatch]); + + // Load topics for this assignment + useEffect(() => { + if (id) { + setTopicsLoading(true); + setTopicsError(null); + fetchTopics({ url: `/project_topics?assignment_id=${id}` }); + } + }, [id, fetchTopics]); + + // Process topics response + useEffect(() => { + if (topicsResponse?.data) { + const transformedTopics: TopicData[] = (topicsResponse.data || []).map((topic: any) => ({ + id: topic.topic_identifier?.toString?.() || topic.topic_identifier || topic.id?.toString?.() || String(topic.id), + databaseId: Number(topic.id), + name: topic.topic_name, + url: topic.link, + description: topic.description, + category: topic.category, + assignedTeams: topic.confirmed_teams || [], + waitlistedTeams: topic.waitlisted_teams || [], + questionnaire: "Default rubric", + numSlots: topic.max_choosers, + availableSlots: topic.available_slots || 0, + bookmarks: [], + partnerAd: undefined, + createdAt: topic.created_at, + updatedAt: topic.updated_at, + })); + setTopicsData(transformedTopics); + setTopicsLoading(false); + } + }, [topicsResponse]); + + // Handle topics API errors + useEffect(() => { + if (topicsApiError) { + setTopicsError(topicsApiError); + setTopicsLoading(false); + } + }, [topicsApiError]); + + const handleTopicSettingChange = useCallback((setting: string, value: boolean) => { + setTopicSettings((prev) => ({ ...prev, [setting]: value })); + + // Save allow_bookmarks setting to backend immediately + if (setting === 'allowBookmarks' && id) { + updateAssignment({ + url: `/assignments/${id}`, + method: 'PATCH', + data: { + assignment: { + allow_bookmarks: value + } + } + }); + } + }, [id, updateAssignment]); + + const handleDropTeam = useCallback((topicId: string, teamId: string) => { + if (!topicId || !teamId) return; + dropTeamRequest({ + url: `/signed_up_teams/drop_team_from_topic`, + method: 'DELETE', + params: { + topic_id: topicId, + team_id: teamId, + }, + }); + }, [dropTeamRequest]); + + const handleDeleteTopic = useCallback((topicIdentifier: string) => { + console.log(`Delete topic ${topicIdentifier}`); + if (id) { + deleteTopic({ + url: `/project_topics`, + method: 'DELETE', + params: { + assignment_id: Number(id), + 'topic_ids[]': [topicIdentifier] + } + }); + } + }, [id, deleteTopic]); + + const handleEditTopic = useCallback((dbId: string, updatedData: any) => { + console.log(`Edit topic DB id ${dbId}`, updatedData); + updateTopic({ + url: `/project_topics/${dbId}`, + method: 'PATCH', + data: { + project_topic: { + topic_identifier: updatedData.topic_identifier, + topic_name: updatedData.topic_name, + category: updatedData.category, + max_choosers: updatedData.max_choosers, + assignment_id: id, + description: updatedData.description, + link: updatedData.link + } + } + }); + }, [id, updateTopic]); + + const handleCreateTopic = useCallback((topicData: any) => { + console.log(`Create topic`, topicData); + if (id) { + createTopic({ + url: `/project_topics`, + method: 'POST', + data: { + project_topic: { + topic_identifier: topicData.topic_identifier || topicData.id, + topic_name: topicData.topic_name || topicData.name, + category: topicData.category, + max_choosers: topicData.max_choosers ?? topicData.numSlots, + assignment_id: id, + description: topicData.description, + link: topicData.link + }, + micropayment: topicData.micropayment ?? 0 + } + }); + } + }, [id, createTopic]); + + const handleApplyPartnerAd = useCallback((topicId: string, applicationText: string) => { + console.log(`Applying to partner ad for topic ${topicId}: ${applicationText}`); + // TODO: Implement partner ad application logic + }, []); + + const renderTabContent = () => { + switch (activeTab) { + + case "general": + return ; + + case "topics": + return ( + id && fetchTopics({ url: `/project_topics?assignment_id=${id}` })} + /> + ); + + case "rubrics": + return ; + + case "review-strategy": + return ; + + case "due-dates": + return ; + + case "etc": + return ; + + default: + return null; + } + }; + + return ( +
+ + +
+

Editing Assignment: {assignmentName}

+ +
+ + +
+
+ + + + + + +
+
+ + {/* Tab Content */} + {renderTabContent()} + + + ); +}; + +export default AssignmentEditPage; diff --git a/src/pages/Assignments/AssignmentEditor.tsx b/src/pages/Assignments/AssignmentEditor.tsx index bde49592..93828375 100644 --- a/src/pages/Assignments/AssignmentEditor.tsx +++ b/src/pages/Assignments/AssignmentEditor.tsx @@ -1,27 +1,20 @@ import * as Yup from "yup"; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faUser } from '@fortawesome/free-solid-svg-icons'; -import { faUserCheck } from '@fortawesome/free-solid-svg-icons'; -import { faClock } from '@fortawesome/free-solid-svg-icons'; -import { faFileAlt } from '@fortawesome/free-solid-svg-icons'; -import { faChartBar } from '@fortawesome/free-solid-svg-icons'; -import { Button, FormSelect, Modal } from "react-bootstrap"; +// FontAwesome icons removed (unused) +import { Button, Modal } from "react-bootstrap"; import { Form, Formik, FormikHelpers } from "formik"; import { IAssignmentFormValues, transformAssignmentRequest } from "./AssignmentUtil"; import { IEditor } from "../../utils/interfaces"; import React, { useEffect } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch } from "react-redux"; import { useLoaderData, useLocation, useNavigate } from "react-router-dom"; import FormInput from "components/Form/FormInput"; import { HttpMethod } from "utils/httpMethods"; -import { RootState } from "../../store/store"; import { alertActions } from "store/slices/alertSlice"; import useAPI from "hooks/useAPI"; import FormCheckbox from "components/Form/FormCheckBox"; import { Tabs, Tab } from 'react-bootstrap'; import '../../custom.scss'; -import { faUsers } from '@fortawesome/free-solid-svg-icons'; -import { faClipboardList } from '@fortawesome/free-solid-svg-icons'; +import EtcTab from './tabs/EtcTab'; const initialValues: IAssignmentFormValues = { name: "", @@ -44,10 +37,7 @@ const validationSchema = Yup.object({ const AssignmentEditor: React.FC = ({ mode }) => { const { data: assignmentResponse, error: assignmentError, sendRequest } = useAPI(); - const auth = useSelector( - (state: RootState) => state.authentication, - (prev, next) => prev.isAuthenticated === next.isAuthenticated - ); + // authentication state not required in this editor const assignmentData: any = useLoaderData(); const dispatch = useDispatch(); const navigate = useNavigate(); @@ -172,38 +162,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { -
-
navigate(`participants`)}> - - Add Participant -
-
navigate(`/assignments/edit/${assignmentData.id}/createteams`)}> - - Create Teams -
- -
navigate(`/assignments/edit/${assignmentData.id}/assignreviewer`)}> - - Assign Reviewer -
-
navigate(`/assignments/edit/${assignmentData.id}/viewsubmissions`)}> - - View Submissions -
-
navigate(`/assignments/edit/${assignmentData.id}/viewscores`)}> - - View Scores -
-
navigate(`/assignments/edit/${assignmentData.id}/viewreports`)}> - - View Reports -
-
navigate(`/assignments/edit/${assignmentData.id}/viewdelayedjobs`)}> - - View Delayed Jobs -
-
- +
@@ -211,4 +170,4 @@ const AssignmentEditor: React.FC = ({ mode }) => { ); }; -export default AssignmentEditor; \ No newline at end of file +export default AssignmentEditor; diff --git a/src/pages/Assignments/TopicDelete.tsx b/src/pages/Assignments/TopicDelete.tsx new file mode 100644 index 00000000..5e26f3e0 --- /dev/null +++ b/src/pages/Assignments/TopicDelete.tsx @@ -0,0 +1,77 @@ +import { Button, Modal } from "react-bootstrap"; +import React, { useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import useAPI from "hooks/useAPI"; +import { alertActions } from "store/slices/alertSlice"; + +interface DeleteTopicsProps { + assignmentId: string; + topicIds: string[]; // topic_identifier values + topicNames?: string[]; // optional display names + onClose: () => void; + onDeleted?: () => void; +} + +const DeleteTopics: React.FC = ({ assignmentId, topicIds, topicNames = [], onClose, onDeleted }) => { + const { data: deleteResp, error: deleteError, sendRequest: deleteTopics } = useAPI(); + const [show, setShow] = useState(true); + const dispatch = useDispatch(); + + const deleteHandler = () => { + deleteTopics({ + url: `/project_topics`, + method: 'DELETE', + params: { + assignment_id: Number(assignmentId), + 'topic_ids[]': topicIds, + } + }); + }; + + useEffect(() => { + if (deleteError) { + dispatch(alertActions.showAlert({ variant: "danger", message: deleteError })); + } + }, [deleteError, dispatch]); + + useEffect(() => { + if (deleteResp?.status && deleteResp.status >= 200 && deleteResp.status < 300) { + setShow(false); + const label = topicIds.length === 1 ? (topicNames[0] || topicIds[0]) : `${topicIds.length} topics`; + dispatch(alertActions.showAlert({ variant: "success", message: `Deleted ${label} successfully.` })); + onClose(); + onDeleted && onDeleted(); + } + }, [deleteResp?.status, dispatch, onClose, topicIds, topicNames]); + + const closeHandler = () => { + setShow(false); + onClose(); + }; + + const title = topicIds.length === 1 ? 'Delete Topic' : 'Delete Topics'; + const body = topicIds.length === 1 + ? <>Are you sure you want to delete topic {topicNames[0] || topicIds[0]}? + : <>Are you sure you want to delete {topicIds.length} selected topics?; + + return ( + + + {title} + + +

{body}

+
+ + + + +
+ ); +}; + +export default DeleteTopics; diff --git a/src/pages/Assignments/components/TopicsTable.tsx b/src/pages/Assignments/components/TopicsTable.tsx new file mode 100644 index 00000000..91fa4a0a --- /dev/null +++ b/src/pages/Assignments/components/TopicsTable.tsx @@ -0,0 +1,237 @@ +import React, { useMemo } from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import Table from "components/Table/Table"; +import { Badge, Button, Spinner } from "react-bootstrap"; +import { BsBookmark, BsBookmarkFill } from "react-icons/bs"; + +export interface TeamMember { id: string; name?: string } +export interface Team { teamId: string; members: TeamMember[] } + +export interface TopicRow { + id: string; + databaseId?: number; + name: string; + url?: string; + description?: string; + availableSlots: number; + waitlistCount: number; + assignedTeams?: Team[]; + waitlistedTeams?: Team[]; + isTaken?: boolean; + isBookmarked?: boolean; + isSelected?: boolean; + isWaitlisted?: boolean; +} + +type Mode = "student" | "instructor"; + +interface TopicsTableProps { + data: TopicRow[]; + mode: Mode; + showPaginationThreshold?: number; + // Student actions + onBookmarkToggle?: (topicId: string) => void; + onSelectTopic?: (topicId: string) => void; + isSigningUp?: boolean; + selectedTopicId?: string | null; + showBookmarks?: boolean; // Control whether to show bookmarks column in student view + // Instructor actions: renderer receives row to render custom actions (edit/delete/drop etc.) + renderInstructorActions?: (row: TopicRow) => React.ReactNode; + // Selection support (instructor bulk actions) + selectable?: boolean; + selectAll?: boolean; + isRowSelected?: (id: string) => boolean; + onToggleAll?: () => void; + onToggleRow?: (id: string) => void; + // Extra columns (e.g., Questionnaire, Num. slots) + extraColumns?: ColumnDef[]; + // Expandable row details renderer + renderDetails?: (row: TopicRow) => React.ReactNode; + // Optional sizing passthrough for underlying Table + tableSize?: { span: number; offset: number }; +} + +const TopicsTable: React.FC = ({ + data, + mode, + showPaginationThreshold = 10, + onBookmarkToggle, + onSelectTopic, + isSigningUp, + selectedTopicId, + showBookmarks = true, + renderInstructorActions, + selectable = false, + selectAll = false, + isRowSelected, + onToggleAll, + onToggleRow, + extraColumns = [], + renderDetails, + tableSize, +}) => { + const baseColumns: ColumnDef[] = useMemo(() => [ + { + accessorKey: "id", + header: "Topic ID", + cell: ({ row }) => {row.original.id}, + }, + { + accessorKey: "name", + header: "Topic Names", + cell: ({ row }) => ( + + {row.original.name} + {mode === "student" && row.original.isWaitlisted && ( + Waitlisted + )} + + ), + }, + ], [mode]); + + const studentColumns: ColumnDef[] = useMemo(() => { + return [ + ...baseColumns, + { + id: "availableSlots", + header: "Available Slots", + cell: ({ row }) => ( + + {row.original.availableSlots} + + ), + }, + { + id: "waitlistCount", + header: "Num. of Waitlist", + cell: ({ row }) => ( + + {row.original.waitlistCount} + + ), + }, + ...(showBookmarks ? [{ + id: "bookmark", + header: "Bookmarks", + cell: ({ row }) => ( +
+ +
+ ), + enableSorting: false, + enableColumnFilter: false, + } as ColumnDef] : []), + { + id: "select", + header: "Select", + cell: ({ row }) => { + const t = row.original; + const disabled = !!isSigningUp; + const isThisSigning = !!isSigningUp && selectedTopicId === t.id; + const ariaLabel = t.isSelected + ? (t.isWaitlisted ? "Leave waitlist" : "Deselect topic") + : (t.isTaken ? "Join waitlist" : "Select topic"); + return ( +
+ +
+ ); + }, + enableSorting: false, + enableColumnFilter: false, + }, + ]; + }, [baseColumns, isSigningUp, onBookmarkToggle, onSelectTopic, selectedTopicId, showBookmarks]); + + const instructorColumns: ColumnDef[] = useMemo(() => { + return [ + // Optional selection column for bulk operations + ...(selectable + ? [{ + id: "select", + header: () => ( + onToggleAll?.()} + /> + ), + cell: ({ row }) => ( + onToggleRow?.(row.original.id)} + /> + ), + enableSorting: false, + enableColumnFilter: false, + } as ColumnDef] : []), + ...baseColumns, + ...extraColumns, + { + id: "actions", + header: "Actions", + cell: ({ row }) => ( +
+ {renderInstructorActions?.(row.original)} +
+ ), + enableSorting: false, + enableColumnFilter: false, + }, + ]; + }, [baseColumns, renderInstructorActions, selectable, selectAll, isRowSelected, onToggleAll, onToggleRow, extraColumns]); + + const columns = mode === "student" ? studentColumns : instructorColumns; + + return ( +
[]} + showGlobalFilter={false} + showColumnFilter={true} + showPagination={true} + renderSubComponent={renderDetails ? ({ row }) => renderDetails(row.original as TopicRow) : undefined} + getRowCanExpand={renderDetails ? (row) => { + const r = row.original as TopicRow; + return !!((r.assignedTeams && r.assignedTeams.length) || (r.waitlistedTeams && r.waitlistedTeams.length)); + } : undefined} + tableSize={tableSize} + /> + ); +}; + +export default TopicsTable; diff --git a/src/pages/Assignments/tabs/DueDatesTab.tsx b/src/pages/Assignments/tabs/DueDatesTab.tsx new file mode 100644 index 00000000..646d4469 --- /dev/null +++ b/src/pages/Assignments/tabs/DueDatesTab.tsx @@ -0,0 +1,16 @@ +import { Col, Row } from "react-bootstrap"; + +const DueDatesTab = () => { + return ( + + +
+

Due Dates Section

+

This section will be implemented later.

+
+ + + ); +}; + +export default DueDatesTab; diff --git a/src/pages/Assignments/tabs/EtcTab.tsx b/src/pages/Assignments/tabs/EtcTab.tsx new file mode 100644 index 00000000..d27f85e9 --- /dev/null +++ b/src/pages/Assignments/tabs/EtcTab.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faUser, + faUserCheck, + faClock, + faFileAlt, + faChartBar, + faUsers, + faClipboardList +} from '@fortawesome/free-solid-svg-icons'; + +interface EtcTabProps { + assignmentId?: number; +} + +const EtcTab: React.FC = ({ assignmentId }) => { + const navigate = useNavigate(); + + return ( +
+
+

Assignment Actions

+
+
navigate(`participants`)}> + + Add Participant +
+
navigate(`/assignments/edit/${assignmentId}/createteams`)}> + + Create Teams +
+
navigate(`/assignments/edit/${assignmentId}/assignreviewer`)}> + + Assign Reviewer +
+
navigate(`/assignments/edit/${assignmentId}/viewsubmissions`)}> + + View Submissions +
+
navigate(`/assignments/edit/${assignmentId}/viewscores`)}> + + View Scores +
+
navigate(`/assignments/edit/${assignmentId}/viewreports`)}> + + View Reports +
+
navigate(`/assignments/edit/${assignmentId}/viewdelayedjobs`)}> + + View Delayed Jobs +
+
+
+
+ ); +}; + +export default EtcTab; diff --git a/src/pages/Assignments/tabs/GeneralTab.tsx b/src/pages/Assignments/tabs/GeneralTab.tsx new file mode 100644 index 00000000..02b91447 --- /dev/null +++ b/src/pages/Assignments/tabs/GeneralTab.tsx @@ -0,0 +1,183 @@ +import { Col, Row } from "react-bootstrap"; + +const GeneralTab = () => { + return ( + +
+ {/* This form is a direct conversion of your HTML. + It assumes a and
component are wrapped + around this GeneralTab component by its parent. + */} + +
+ {/* Column 1: Text & Number Inputs + This maps to your components + */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Column 2: Checkboxes + This maps to your components + */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Footer / Buttons + This maps to your + */} +
+ + +
+ + + + ); +}; + +export default GeneralTab; diff --git a/src/pages/Assignments/tabs/ReviewStrategyTab.tsx b/src/pages/Assignments/tabs/ReviewStrategyTab.tsx new file mode 100644 index 00000000..06cb34e3 --- /dev/null +++ b/src/pages/Assignments/tabs/ReviewStrategyTab.tsx @@ -0,0 +1,16 @@ +import { Col, Row } from "react-bootstrap"; + +const ReviewStrategyTab = () => { + return ( + +
+
+

Review Strategy Section

+

This section will be implemented later.

+
+ + + ); +}; + +export default ReviewStrategyTab; diff --git a/src/pages/Assignments/tabs/RubricsTab.tsx b/src/pages/Assignments/tabs/RubricsTab.tsx new file mode 100644 index 00000000..78c1b49a --- /dev/null +++ b/src/pages/Assignments/tabs/RubricsTab.tsx @@ -0,0 +1,16 @@ +import { Col, Row } from "react-bootstrap"; + +const RubricsTab = () => { + return ( + + +
+

Rubrics Section

+

This section will be implemented later.

+
+ + + ); +}; + +export default RubricsTab; diff --git a/src/pages/Assignments/tabs/TopicsTab.tsx b/src/pages/Assignments/tabs/TopicsTab.tsx new file mode 100644 index 00000000..a40cb853 --- /dev/null +++ b/src/pages/Assignments/tabs/TopicsTab.tsx @@ -0,0 +1,807 @@ +import React, { useState } from "react"; +import { Col, Row, Form, Button, Modal, FloatingLabel, Stack } from "react-bootstrap"; +// Reverting to the standard import path for react-icons/bs +import { BsPersonPlusFill, BsBookmark, BsBookmarkFill } from "react-icons/bs"; +import TopicsTable from "pages/Assignments/components/TopicsTable"; +import DeleteTopics from "../TopicDelete"; + +// --- Interface Modifications --- +// Assuming these interfaces are defined elsewhere and imported +// They are redefined here for clarity based on requirements + +interface TeamMember { + id: string; // User ID + name: string; // User's full name +} + +interface AssignedTeam { + teamId: string; + members: TeamMember[]; +} + +interface WaitlistedTeam { + teamId: string; + members: TeamMember[]; +} + +interface PartnerAd { + text: string; + // link?: string; // Optional: Link to a separate page if not using modal +} + +interface BookmarkData { + id: string; + url: string; + title: string; +} + +// Updated TopicData interface +interface TopicData { + id: string; // topic_identifier for display/selection + databaseId: number; // Database ID for API calls + name: string; // Topic Name + url?: string; // Optional URL for the topic name + description?: string; // Optional short description + category?: string; // Optional category + assignedTeams: AssignedTeam[]; // Teams/Students assigned to this topic + waitlistedTeams: WaitlistedTeam[]; // Teams/Students waitlisted + questionnaire: string; // Associated questionnaire name + numSlots: number; // Total number of slots + availableSlots: number; // Number of available slots + // waitlist: number; // Redundant now, can derive from waitlistedTeams.length + bookmarks: BookmarkData[]; // Array of bookmarks for this topic + partnerAd?: PartnerAd; // Optional partner advertisement details + createdAt?: string; + updatedAt?: string; +} + +// Same as before +interface TopicSettings { + allowTopicSuggestions: boolean; + enableBidding: boolean; + enableAuthorsReview: boolean; + allowReviewerChoice: boolean; + allowBookmarks: boolean; + allowBiddingForReviewers: boolean; +} + +interface TopicsTabProps { + assignmentName?: string; + assignmentId: string; + topicSettings: TopicSettings; + topicsData: TopicData[]; // Ensure the data passed matches the updated TopicData interface + topicsLoading?: boolean; + topicsError?: string | null; + onTopicSettingChange: (setting: string, value: boolean) => void; + // Add handlers for actions like drop team, delete topic, edit topic, create bookmark etc. + onDropTeam: (topicId: string, teamId: string) => void; + onDeleteTopic: (topicId: string) => void; + onEditTopic: (topicId: string, updatedData?: any) => void; + onCreateTopic?: (topicData: any) => void; + // Handler for partner ad application submission + onApplyPartnerAd: (topicId: string, applicationText: string) => void; + onTopicsChanged?: () => void; +} + +// --- Component Implementation --- + +const TopicsTab = ({ + assignmentName = "Assignment", + assignmentId, + topicSettings, + topicsData, + topicsLoading = false, + topicsError = null, + onTopicSettingChange, + onDropTeam, + onDeleteTopic, + onEditTopic, + onCreateTopic, + onApplyPartnerAd, + onTopicsChanged, +}: TopicsTabProps) => { + const [showPartnerAdModal, setShowPartnerAdModal] = useState(false); + const [selectedPartnerAdTopic, setSelectedPartnerAdTopic] = useState(null); + const [partnerAdApplication, setPartnerAdApplication] = useState(""); + + // New topic modal state + const [showNewTopicModal, setShowNewTopicModal] = useState(false); + const [newTopicData, setNewTopicData] = useState({ + topic_name: '', + topic_identifier: '', + category: '', + max_choosers: 1, + description: '', + link: '' + }); + + // Selected topics state + const [selectedTopics, setSelectedTopics] = useState>(new Set()); + const [selectAll, setSelectAll] = useState(false); + + // Import topics modal state + const [showImportModal, setShowImportModal] = useState(false); + const [importData, setImportData] = useState(''); + + // Delete modal state (repo-standard) + const [deleteState, setDeleteState] = useState<{ visible: boolean; ids: string[]; names: string[] }>({ visible: false, ids: [], names: [] }); + + // Edit topic modal state + const [showEditModal, setShowEditModal] = useState(false); + const [editingTopic, setEditingTopic] = useState(null); + const [editTopicData, setEditTopicData] = useState({ + topic_name: '', + topic_identifier: '', + category: '', + max_choosers: 1, + description: '', + link: '' + }); + + // --- Partner Ad Modal Handlers --- + const handleShowPartnerAd = (topic: TopicData) => { + setSelectedPartnerAdTopic(topic); + setPartnerAdApplication(""); // Reset text area + setShowPartnerAdModal(true); + }; + + const handleClosePartnerAd = () => { + setShowPartnerAdModal(false); + setSelectedPartnerAdTopic(null); + }; + + const handleSubmitPartnerAd = () => { + if (selectedPartnerAdTopic) { + onApplyPartnerAd(selectedPartnerAdTopic.id, partnerAdApplication); + // Optional: Show success message or handle response + } + handleClosePartnerAd(); + }; + + // --- New Topic Modal Handlers --- + const handleShowNewTopic = () => { + setNewTopicData({ + topic_name: '', + topic_identifier: '', + category: '', + max_choosers: 1, + description: '', + link: '' + }); + setShowNewTopicModal(true); + }; + + const handleCloseNewTopic = () => { + setShowNewTopicModal(false); + }; + + const handleSubmitNewTopic = () => { + if (onCreateTopic) { + onCreateTopic(newTopicData); + handleCloseNewTopic(); + } + }; + + const handleInputChange = (field: string, value: string | number) => { + setNewTopicData(prev => ({ + ...prev, + [field]: value + })); + }; + + // --- Edit Topic Modal Handlers --- + const handleShowEditTopic = (topic: TopicData) => { + console.log('Edit button clicked for topic:', topic); + setEditingTopic(topic); + setEditTopicData({ + topic_name: topic.name || '', + topic_identifier: topic.id || '', + category: topic.category || '', + max_choosers: topic.numSlots || 1, + description: topic.description || '', + link: topic.url || '' + }); + setShowEditModal(true); + console.log('Edit modal should be opening now'); + }; + + const handleCloseEditTopic = () => { + setShowEditModal(false); + setEditingTopic(null); + }; + + const handleSubmitEditTopic = () => { + console.log('Submitting edit for topic:', editingTopic); + console.log('Edit data:', editTopicData); + if (editingTopic && onEditTopic) { + console.log('Calling onEditTopic with DB id:', editingTopic.databaseId, editTopicData); + onEditTopic(String(editingTopic.databaseId), editTopicData); + handleCloseEditTopic(); + } else { + console.log('Missing editingTopic or onEditTopic:', { editingTopic, onEditTopic }); + } + }; + + const handleEditInputChange = (field: string, value: string | number) => { + setEditTopicData(prev => ({ + ...prev, + [field]: value + })); + }; + + // --- Selection Handlers --- + const handleSelectAll = () => { + if (selectAll) { + setSelectedTopics(new Set()); + setSelectAll(false); + } else { + const allTopicIds = new Set(topicsData.map(topic => topic.id)); + setSelectedTopics(allTopicIds); + setSelectAll(true); + } + }; + + const handleSelectTopic = (topicId: string) => { + const newSelected = new Set(selectedTopics); + if (newSelected.has(topicId)) { + newSelected.delete(topicId); + } else { + newSelected.add(topicId); + } + setSelectedTopics(newSelected); + setSelectAll(newSelected.size === topicsData.length); + }; + + // --- Import Topics Handlers --- + const handleShowImport = () => { + setImportData(''); + setShowImportModal(true); + }; + + const handleCloseImport = () => { + setShowImportModal(false); + }; + + const handleImportTopics = () => { + try { + // Parse CSV or JSON data + const lines = importData.trim().split('\n'); + const topics = lines.map((line, index) => { + const [topic_name, topic_identifier, category, max_choosers, description, link] = line.split(','); + return { + topic_name: topic_name?.trim() || `Imported Topic ${index + 1}`, + topic_identifier: topic_identifier?.trim() || `IMP${index + 1}`, + category: category?.trim() || '', + max_choosers: parseInt(max_choosers?.trim()) || 1, + description: description?.trim() || '', + link: link?.trim() || '' + }; + }); + + // Create each topic + topics.forEach(topic => { + if (onCreateTopic) { + onCreateTopic(topic); + } + }); + + handleCloseImport(); + } catch (error) { + console.error('Error importing topics:', error); + } + }; + + // --- Delete Handlers --- + const handleDeleteSelected = () => { + if (selectedTopics.size === 0) return; + const ids = Array.from(selectedTopics); + const names = ids.map(id => topicsData.find(t => t.id === id)?.name || id); + setDeleteState({ visible: true, ids, names }); + }; + + // --- Back Handler --- + const handleBack = () => { + // Navigate back to assignments list + window.history.back(); + }; + + // --- Render Helper Functions --- + // removed: renderTeamMembers (moved to TopicsTable renderDetails inline rendering) + + return ( + + +

Topics for {assignmentName} assignment

+ + {/* Topic Settings */} +
+ + onTopicSettingChange('allowTopicSuggestions', e.target.checked)} + /> + + onTopicSettingChange('enableBidding', e.target.checked)} + /> + + onTopicSettingChange('enableAuthorsReview', e.target.checked)} + /> + + onTopicSettingChange('allowReviewerChoice', e.target.checked)} + /> + + onTopicSettingChange('allowBookmarks', e.target.checked)} + /> + + onTopicSettingChange('allowBiddingForReviewers', e.target.checked)} + /> + +
+ + + {/* Error Message */} + {topicsError && ( +
+ Error loading topics: { + typeof topicsError === 'string' + ? topicsError + : JSON.stringify(topicsError) + } +
+ )} + + ({ + id: t.id, + databaseId: t.databaseId, + name: t.name, + url: t.url, + description: t.description, + availableSlots: t.availableSlots, + waitlistCount: t.waitlistedTeams?.length || 0, + assignedTeams: t.assignedTeams, + waitlistedTeams: t.waitlistedTeams, + }))} + mode="instructor" + selectable + selectAll={selectAll} + isRowSelected={(id) => selectedTopics.has(id)} + onToggleAll={handleSelectAll} + onToggleRow={handleSelectTopic} + extraColumns={[ + { + id: "questionnaire", + header: "Questionnaire", + cell: ({ row }) => {(topicsData.find(t => t.id === row.original.id)?.questionnaire) || "--Default rubric--"}, + }, + { + id: "numSlots", + header: "Num. of Slots", + cell: ({ row }) => {topicsData.find(t => t.id === row.original.id)?.numSlots ?? 0}, + }, + { + id: "availableSlots", + header: "Available Slots", + cell: ({ row }) => {row.original.availableSlots ?? 0}, + }, + { + id: "waitlisted", + header: "Waitlisted", + cell: ({ row }) => {row.original.waitlistedTeams?.length ?? 0}, + }, + { + id: "bookmarks", + header: "Bookmarks", + cell: ({ row }) => { + const topic = topicsData.find(t => t.id === row.original.id); + const bookmarkCount = topic?.bookmarks?.length || 0; + return ( + + {bookmarkCount === 0 ? "None" : `${bookmarkCount} bookmark${bookmarkCount > 1 ? 's' : ''}`} + + ); + }, + }, + ]} + renderDetails={(row) => ( +
+ {row.assignedTeams && row.assignedTeams.length > 0 && ( +
+ {row.assignedTeams.map((team) => { + const topicDbId = row.databaseId?.toString() ?? row.id; + return ( +
+ + {team.members.map(m => m.name || m.id).join(", ")} + + +
+ ); + })} +
+ )} + {row.waitlistedTeams && row.waitlistedTeams.length > 0 && ( +
+ {row.waitlistedTeams.map((team) => ( +
+ + {team.members.map(m => m.name || m.id).join(", ")} (waitlisted) + +
+ ))} +
+ )} +
+ )} + renderInstructorActions={(topic) => ( + + + + + + )} + /> + + {/* Action Buttons */} +
{/* Added flex-wrap */} + + + + +
+ + + {/* Partner Advertisement Modal */} + + + Partner Advertisement: {selectedPartnerAdTopic?.name} + + +

{selectedPartnerAdTopic?.partnerAd?.text}

+
+ + setPartnerAdApplication(e.target.value)} + /> + +
+ + + + +
+ + {/* New Topic Modal */} + + + Create New Topic + + +
+ +
+ + handleInputChange('topic_name', e.target.value)} + required + /> + + + + + handleInputChange('topic_identifier', e.target.value)} + required + /> + + + + + + + handleInputChange('category', e.target.value)} + /> + + + + + handleInputChange('max_choosers', parseInt(e.target.value) || 1)} + required + /> + + + + + + + handleInputChange('description', e.target.value)} + /> + + + + + + + handleInputChange('link', e.target.value)} + /> + + + + + + + + + + + + {/* Import Topics Modal */} + + + Import Topics + + +
+

Import topics from CSV format. Each line should contain:

+

Topic Name, Topic Identifier, Category, Max Choosers, Description, Link

+

Example: "Database Design, DB001, Technical, 2, Design database schema, https://example.com"

+
+ + setImportData(e.target.value)} + /> + +
+ + + + +
+ + {deleteState.visible && ( + setDeleteState({ visible: false, ids: [], names: [] })} + onDeleted={onTopicsChanged} + /> + )} + + {/* Edit Topic Modal */} + + + Edit Topic + + +
+ +
+ + handleEditInputChange('topic_name', e.target.value)} + required + /> + + + + + handleEditInputChange('topic_identifier', e.target.value)} + required + /> + + + + + + + handleEditInputChange('category', e.target.value)} + /> + + + + + handleEditInputChange('max_choosers', parseInt(e.target.value) || 1)} + required + /> + + + + + + + handleEditInputChange('description', e.target.value)} + /> + + + + + + + handleEditInputChange('link', e.target.value)} + /> + + + + + + + + + + + + ); +}; + +export default TopicsTab; diff --git a/src/pages/StudentTasks/StudentTasks.tsx b/src/pages/StudentTasks/StudentTasks.tsx new file mode 100644 index 00000000..f37cdb22 --- /dev/null +++ b/src/pages/StudentTasks/StudentTasks.tsx @@ -0,0 +1,460 @@ +import React, { useEffect, useCallback, useMemo, useState } from "react"; +import { Container, Spinner, Alert, Row, Col } from "react-bootstrap"; +import { useParams } from "react-router-dom"; +import useAPI from "../../hooks/useAPI"; +import { useSelector } from "react-redux"; +import { RootState } from "../../store/store"; +import TopicsTable, { TopicRow } from "pages/Assignments/components/TopicsTable"; + +interface Topic { + id: string; + databaseId?: number; + name: string; + availableSlots: number; + waitlist: number; + isBookmarked?: boolean; + isSelected?: boolean; + isTaken?: boolean; + isWaitlisted?: boolean; +} + +const StudentTasks: React.FC = () => { + const { assignmentId } = useParams<{ assignmentId?: string }>(); + const { data: topicsResponse, error: topicsError, isLoading: topicsLoading, sendRequest: fetchTopicsAPI } = useAPI(); + const { data: assignmentResponse, sendRequest: fetchAssignment } = useAPI(); + const { data: signUpResponse, error: signUpError, sendRequest: signUpAPI } = useAPI(); + const { data: dropResponse, error: dropError, sendRequest: dropAPI } = useAPI(); + + const auth = useSelector((state: RootState) => state.authentication); + const currentUser = auth.user; + + const [bookmarkedTopics, setBookmarkedTopics] = useState>(new Set()); + // UI-selected topic override for instant icon/row updates + const [uiSelectedTopic, setUiSelectedTopic] = useState(null); + const [isSigningUp, setIsSigningUp] = useState(false); + const [optimisticSlotChanges, setOptimisticSlotChanges] = useState>(new Map()); + const [optimisticSelection, setOptimisticSelection] = useState>(new Map()); + const [pendingDeselections, setPendingDeselections] = useState>(new Set()); + const [lastSignedDbTopicId, setLastSignedDbTopicId] = useState(null); + + const fetchAssignmentData = useCallback(() => { + if (assignmentId) { + fetchAssignment({ url: `/assignments/${assignmentId}`, method: 'GET' }); + } else { + fetchAssignment({ url: `/assignments`, method: 'GET' }); + } + }, [assignmentId, fetchAssignment]); + + const fetchTopics = useCallback((assignmentId: number) => { + if (!assignmentId) return; + fetchTopicsAPI({ url: `/project_topics?assignment_id=${assignmentId}`, method: 'GET' }); + }, [fetchTopicsAPI]); + + useEffect(() => { + fetchAssignmentData(); + }, [fetchAssignmentData]); + + useEffect(() => { + if (assignmentResponse?.data) { + let targetAssignmentId: number; + if (assignmentId) { + targetAssignmentId = parseInt(assignmentId); + } else if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { + targetAssignmentId = assignmentResponse.data[0].id; + } else { + targetAssignmentId = assignmentResponse.data.id; + } + fetchTopics(targetAssignmentId); + } + }, [assignmentResponse, assignmentId, fetchTopics]); + + useEffect(() => { + if (signUpResponse) { + setIsSigningUp(false); + const dbTopicId = (signUpResponse as any)?.data?.signed_up_team?.project_topic_id; + if (dbTopicId) setLastSignedDbTopicId(Number(dbTopicId)); + // Clear optimistic updates since we'll get real data + setOptimisticSlotChanges(new Map()); + if (assignmentResponse?.data) { + let targetAssignmentId: number; + if (assignmentId) { + targetAssignmentId = parseInt(assignmentId); + } else if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { + targetAssignmentId = assignmentResponse.data[0].id; + } else { + targetAssignmentId = assignmentResponse.data.id; + } + fetchTopics(targetAssignmentId); + } + } + }, [signUpResponse, assignmentResponse, assignmentId, fetchTopics]); + + useEffect(() => { + if (signUpError) { + console.error('Error signing up for topic:', signUpError); + setIsSigningUp(false); + // Clear optimistic updates on error to restore actual values + setOptimisticSlotChanges(new Map()); + } + }, [signUpError]); + + useEffect(() => { + if (dropResponse) { + // Clear optimistic updates since we'll get real data + setOptimisticSlotChanges(new Map()); + if (assignmentResponse?.data) { + let targetAssignmentId: number; + if (assignmentId) { + targetAssignmentId = parseInt(assignmentId); + } else if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { + targetAssignmentId = assignmentResponse.data[0].id; + } else { + targetAssignmentId = assignmentResponse.data.id; + } + fetchTopics(targetAssignmentId); + } + } + }, [dropResponse, assignmentResponse, assignmentId, fetchTopics]); + + useEffect(() => { + if (dropError) { + console.error('Error dropping topic:', dropError); + // Clear optimistic updates on error to restore actual values + setOptimisticSlotChanges(new Map()); + setPendingDeselections(new Set()); + } + }, [dropError]); + + const isUserOnTopic = useCallback((topic: any) => { + if (!topic) return false; + const matches = (teams: any[]) => Array.isArray(teams) + ? teams.some((team: any) => + Array.isArray(team.members) && + team.members.some((m: any) => String(m.id) === String(currentUser?.id))) + : false; + return matches(topic.confirmed_teams) || matches(topic.waitlisted_teams); + }, [currentUser?.id]); + + const topics = useMemo(() => { + if (topicsError || !topicsResponse?.data) return []; + const topicsData = Array.isArray(topicsResponse.data) ? topicsResponse.data : []; + return topicsData.map((topic: any) => { + const topicId = topic.topic_identifier || topic.id?.toString() || 'unknown'; + const dbId = Number(topic.id); + const baseSlots = topic.available_slots || 0; + const adjustedSlots = optimisticSlotChanges.has(topicId) + ? optimisticSlotChanges.get(topicId)! + : baseSlots; + // Determine if current user is on a team for this topic (confirmed or waitlisted) + const matches = (teams: any[]) => { + if (!currentUser?.id || !Array.isArray(teams)) return false; + return teams.some((team: any) => + Array.isArray(team.members) && + team.members.some((m: any) => String(m.id) === String(currentUser.id)) + ); + }; + const userWaitlisted = matches(topic.waitlisted_teams); + const userConfirmed = matches(topic.confirmed_teams); + const userOnTopic = userConfirmed || userWaitlisted; + const pendingDrop = pendingDeselections.has(topicId); + + const selectionOverride = optimisticSelection.get(topicId); + const isSelected = pendingDrop + ? false + : selectionOverride === 'selected' + ? true + : selectionOverride === 'deselected' + ? false + : uiSelectedTopic !== null + ? uiSelectedTopic === topicId + : userOnTopic; + return { + id: topicId, + databaseId: isNaN(dbId) ? undefined : dbId, + name: topic.topic_name || 'Unnamed Topic', + availableSlots: adjustedSlots, + waitlist: topic.waitlisted_teams?.length || 0, + isBookmarked: bookmarkedTopics.has(topicId), + isSelected, + isTaken: adjustedSlots <= 0, + isWaitlisted: userWaitlisted + }; + }); + }, [topicsResponse, topicsError, bookmarkedTopics, uiSelectedTopic, optimisticSlotChanges, optimisticSelection, pendingDeselections, currentUser?.id]); + + // Initialize or reconcile selectedTopic from backend data after fetch + useEffect(() => { + if (Array.isArray(topicsResponse?.data)) { + // Priority 1: if we have lastSignedDbTopicId, map it to identifier and select + if (lastSignedDbTopicId) { + const t = topicsResponse.data.find((x: any) => Number(x.id) === Number(lastSignedDbTopicId)); + const key = t?.topic_identifier || t?.id?.toString(); + if (key) setUiSelectedTopic(key); + setLastSignedDbTopicId(null); + return; + } + // Priority 2: use membership lists + if (uiSelectedTopic === null) { + const found = topicsResponse.data.find((topic: any) => { + const topicKey = topic.topic_identifier || topic.id?.toString(); + if (!topicKey || pendingDeselections.has(topicKey)) return false; + return isUserOnTopic(topic); + }); + if (found) { + const key = found.topic_identifier || found.id?.toString(); + if (key) setUiSelectedTopic(key); + } + } + } + if (optimisticSelection.size > 0) { + setOptimisticSelection(new Map()); + } + }, [topicsResponse?.data, currentUser?.id, uiSelectedTopic, lastSignedDbTopicId, optimisticSelection.size, pendingDeselections, isUserOnTopic]); + + useEffect(() => { + if (!Array.isArray(topicsResponse?.data)) return; + setPendingDeselections(prev => { + if (prev.size === 0) return prev; + const next = new Set(prev); + let changed = false; + prev.forEach(topicId => { + const topic = topicsResponse.data.find((t: any) => { + const key = t.topic_identifier || t.id?.toString(); + return key === topicId; + }); + const stillAssigned = topic ? isUserOnTopic(topic) : false; + if (!stillAssigned) { + next.delete(topicId); + changed = true; + } + }); + return changed ? next : prev; + }); + }, [topicsResponse?.data, isUserOnTopic]); + + const assignmentName = useMemo(() => { + if (!assignmentResponse?.data) return 'OSS project & documentation assignment'; + if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { + return assignmentResponse.data[0].name || 'OSS project & documentation assignment'; + } else { + return assignmentResponse.data.name || 'OSS project & documentation assignment'; + } + }, [assignmentResponse]); + + // Check if bookmarks are allowed for this assignment + const allowBookmarks = useMemo(() => { + if (!assignmentResponse?.data) return false; + if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { + return assignmentResponse.data[0].allow_bookmarks || false; + } else { + return assignmentResponse.data.allow_bookmarks || false; + } + }, [assignmentResponse]); + + const userSelectedTopics: Topic[] = useMemo(() => { + return topics.filter(topic => topic.isSelected); + }, [topics]); + + const handleBookmarkToggle = useCallback((topicId: string) => { + setBookmarkedTopics(prev => { + const newSet = new Set(prev); + if (newSet.has(topicId)) { + newSet.delete(topicId); + } else { + newSet.add(topicId); + } + return newSet; + }); + }, []); + + const handleTopicSelect = useCallback(async (topicId: string) => { + if (!currentUser?.id) return; + // Treat as deselect if either local selection matches or backend indicates selection + const topicEntry = topics.find(t => t.id === topicId); + const isCurrentlyOnThisTopic = !!topicEntry?.isSelected; + + if (uiSelectedTopic === topicId || (uiSelectedTopic === null && isCurrentlyOnThisTopic)) { + // Deselecting current topic - optimistically increment available slots when confirmed + if (topicEntry && !topicEntry.isWaitlisted) { + setOptimisticSlotChanges(prev => { + const newMap = new Map(prev); + newMap.set(topicId, topicEntry.availableSlots + 1); + return newMap; + }); + } + setPendingDeselections(prev => { + if (prev.has(topicId)) return prev; + const next = new Set(prev); + next.add(topicId); + return next; + }); + + setUiSelectedTopic(null); + setOptimisticSelection(prev => { + const next = new Map(prev); + next.set(topicId, 'deselected'); + return next; + }); + const dbId = topicEntry?.databaseId || topicsResponse?.data?.find((t: any) => t.topic_identifier === topicId || t.id?.toString() === topicId)?.id; + if (dbId) { + dropAPI({ + url: '/signed_up_teams/drop_topic', + method: 'DELETE', + data: { user_id: currentUser.id, topic_id: dbId } + }); + } + } else { + // Selecting new topic - optimistically decrement available slots + const topic = topics.find(t => t.id === topicId); + if (topic) { + setOptimisticSlotChanges(prev => { + const newMap = new Map(prev); + newMap.set(topicId, Math.max(0, topic.availableSlots - 1)); + + // If there's a previously selected topic, increment its slots + if (uiSelectedTopic) { + const prevTopic = topics.find(t => t.id === uiSelectedTopic); + if (prevTopic) { + newMap.set(uiSelectedTopic, prevTopic.availableSlots + 1); + } + } + + return newMap; + }); + } + + setOptimisticSelection(prev => { + const next = new Map(prev); + next.set(topicId, 'selected'); + if (uiSelectedTopic) { + next.set(uiSelectedTopic, 'deselected'); + } + return next; + }); + setPendingDeselections(prev => { + const next = new Set(prev); + next.delete(topicId); + if (uiSelectedTopic) { + next.add(uiSelectedTopic); + } + return next; + }); + + if (uiSelectedTopic) { + // Drop previous topic first + const prev = topics.find(t => t.id === uiSelectedTopic); + const prevDbId = prev?.databaseId || topicsResponse?.data?.find((t: any) => t.topic_identifier === uiSelectedTopic || t.id?.toString() === uiSelectedTopic)?.id; + if (prevDbId) { + dropAPI({ + url: '/signed_up_teams/drop_topic', + method: 'DELETE', + data: { user_id: currentUser.id, topic_id: prevDbId } + }); + } + } + + setUiSelectedTopic(topicId); + setIsSigningUp(true); + + const topicData = topics.find(t => t.id === topicId); + const dbId = topicData?.databaseId || topicsResponse?.data?.find((t: any) => t.topic_identifier === topicId || t.id?.toString() === topicId)?.id; + if (dbId) { + setTimeout(() => { + signUpAPI({ + url: '/signed_up_teams/sign_up_student', + method: 'POST', + data: { user_id: currentUser.id, topic_id: dbId } + }); + }, 100); + } else { + setIsSigningUp(false); + } + } + }, [currentUser?.id, dropAPI, uiSelectedTopic, signUpAPI, topics, topicsResponse?.data]); + + // Table columns (declare before any conditional returns to satisfy hooks rules) + const topicRows: TopicRow[] = useMemo(() => topics.map(t => ({ + id: t.id, + name: t.name, + availableSlots: t.availableSlots, + waitlistCount: t.waitlist, + isTaken: t.isTaken, + isBookmarked: t.isBookmarked, + isSelected: t.isSelected, + isWaitlisted: t.isWaitlisted, + })), [topics]); + + if (topicsLoading) { + return ( + + + Loading topics... + +

Loading topics...

+
+ ); + } + + if (topicsError) { + return ( + + + Error Loading Topics +

+ {typeof topicsError === 'string' + ? topicsError + : JSON.stringify(topicsError) + } +

+
+
+ ); + } + + // removed duplicate columns definition placed after conditional returns + + return ( + + +
+

Signup Sheet For {assignmentName}

+ + + + + +

+ Your topic(s): {userSelectedTopics.length > 0 + ? userSelectedTopics.map((topic) => topic.isWaitlisted ? `${topic.name} (waitlisted)` : topic.name).join(", ") + : "No topics selected yet"} +

+ + + + + + {topics.length === 0 ? ( + + No Topics Available +

There are no topics available for this assignment yet.

+
+ ) : ( + + )} + + + + ); +}; + +export default StudentTasks;