diff --git a/src/App.tsx b/src/App.tsx index 832e1837..542ba13e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,7 @@ import ProtectedRoute from "./router/ProtectedRoute"; import { ROLE } from "./utils/interfaces"; import AssignReviewer from "./pages/Assignments/AssignReviewer"; import StudentTasks from "./pages/StudentTasks/StudentTasks"; +import AssignedReviews from "./pages/StudentTasks/AssignedReviews"; import StudentTeams from "./pages/Student Teams/StudentTeamView"; import StudentTeamView from "./pages/Student Teams/StudentTeamView"; import NewTeammateAdvertisement from './pages/Student Teams/NewTeammateAdvertisement'; @@ -74,6 +75,7 @@ function App() { { path: "edit-questionnaire", element: } />, + loader: loadQuestionnaire, }, { @@ -295,6 +297,10 @@ function App() { path: "email_the_author", element: , }, + { + path: "student_tasks/reviews", + element: } />, + }, { path: "duties", element: } leastPrivilegeRole={ROLE.TA} />, @@ -311,6 +317,10 @@ function App() { path: "student_tasks/:assignmentId", element: } />, }, + { + path: "student_tasks/:assignmentId/reviews", + element: } />, + }, { path: "assignments/:id/review", element: , diff --git a/src/components/Form/FormSelect.tsx b/src/components/Form/FormSelect.tsx index 3bc53d4d..1f761a02 100644 --- a/src/components/Form/FormSelect.tsx +++ b/src/components/Form/FormSelect.tsx @@ -45,6 +45,7 @@ const FormSelect: React.FC { // Fetch sign up topics for the assignment const topicsResponse = await axios.get( - `${API_BASE_URL}/sign_up_topics`, + `${API_BASE_URL}/project_topics`, { params: { assignment_id: assignmentId }, headers, diff --git a/src/pages/Assignments/AssignReviewer.tsx b/src/pages/Assignments/AssignReviewer.tsx index d73fcb9a..9f95c149 100644 --- a/src/pages/Assignments/AssignReviewer.tsx +++ b/src/pages/Assignments/AssignReviewer.tsx @@ -1,13 +1,14 @@ // src/pages/Assignments/AssignReviewer.tsx import React, { useMemo, useState } from "react"; import { Container, Row, Col, Form, Button } from "react-bootstrap"; -import { useLocation, useParams } from "react-router-dom"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import axiosClient from "../../utils/axios_client"; type Id = number; type ReviewStatus = "Not saved" | "Saved" | "Submitted"; interface Assignment { id: Id; name: string } -interface Team { id: Id; name: string; parent_id: Id; mentor_id?: Id | null } +interface Team { id: Id; name: string; parent_id: Id; mentor_id?: Id | null; quiz_questionnaire_id?: Id | null } interface User { id: Id; name: string | null; full_name: string | null } interface TeamUser { team_id: Id; user_id: Id } interface Participant { id: Id; user_id: Id; parent_id: Id; team_id?: Id | null } @@ -21,7 +22,7 @@ interface ResponseRow { interface IUserView { id: Id; username: string; fullName: string } interface IReviewerAssignment { id: Id; reviewer: IUserView; status: ReviewStatus } -interface ITeamRow { id: Id; name: string; mentor?: IUserView; members: IUserView[]; reviewers: IReviewerAssignment[] } +interface ITeamRow { id: Id; name: string; mentor?: IUserView; members: IUserView[]; reviewers: IReviewerAssignment[]; quiz_questionnaire_id?: Id | null } type Persist = { assignment: Assignment; @@ -292,6 +293,7 @@ export function demo(asgId: Id): Persist { const AssignReviewer: React.FC = () => { const location = useLocation(); const params = useParams(); + const navigate = useNavigate(); const maybeId = parseAssignmentId(location, params); // Hooks must be unconditionally called: @@ -383,7 +385,7 @@ const AssignReviewer: React.FC = () => { }) .filter(Boolean) as IReviewerAssignment[]; - return { id: teamId, name: t?.name ?? `Team #${teamId}`, mentor, members, reviewers }; + return { id: teamId, name: t?.name ?? `Team #${teamId}`, mentor, members, reviewers, quiz_questionnaire_id: t?.quiz_questionnaire_id ?? null }; }); }, [assignmentId, teams, usersById, teamsById, teamMembersByTeam, response_maps, latestResponseByMap, participantsById, tick]); @@ -395,13 +397,16 @@ const AssignReviewer: React.FC = () => { setTimeout(() => setTick(v => v + 1), 0); } - function onAddReviewer(teamId: number) { + async function onAddReviewer(teamId: number) { if (!hasValidId) return; const raw = window.prompt("Enter reviewer user_id to add for this team:"); if (!raw) return; const reviewerUserId = Number(raw); if (!Number.isFinite(reviewerUserId)) { window.alert("Invalid user_id."); return; } + // Track local map id so we can patch it after the backend responds + let localMapId: number | null = null; + mutate(p => { let reviewerPart = p.participants.find(x => x.user_id === reviewerUserId && x.parent_id === assignmentId); if (!reviewerPart) { @@ -412,8 +417,9 @@ const AssignReviewer: React.FC = () => { p.users.push({ id: reviewerUserId, name: `user_${reviewerUserId}`, full_name: `user_${reviewerUserId}` }); } } + localMapId = p.nextMapId++; p.response_maps.push({ - id: p.nextMapId++, + id: localMapId, reviewed_object_id: assignmentId, reviewer_id: reviewerPart.id, reviewer_user_id: reviewerUserId, @@ -421,6 +427,30 @@ const AssignReviewer: React.FC = () => { reviewee_team_id: teamId, }); }); + + // Persist to backend and patch localStorage with the real DB id + try { + const res = await axiosClient.post('/response_maps', { + assignment_id: assignmentId, + reviewer_user_id: reviewerUserId, + reviewee_team_id: teamId, + }); + const realMapId: number = res.data.id; + const realParticipantId: number = res.data.reviewer_id; + + if (localMapId !== null) { + mutate(p => { + const map = p.response_maps.find(m => m.id === localMapId); + if (map) { + map.id = realMapId; + map.reviewer_id = realParticipantId; + } + p.responses.forEach(r => { if (r.map_id === localMapId!) r.map_id = realMapId; }); + }); + } + } catch (err) { + console.warn('Failed to persist response map to backend — local ID will be used:', err); + } } function onDeleteReviewer(_teamId: number, mappingId: number) { @@ -429,6 +459,8 @@ const AssignReviewer: React.FC = () => { p.response_maps = p.response_maps.filter(m => m.id !== mappingId); p.responses = p.responses.filter(r => r.map_id !== mappingId); }); + // Also delete from backend DB + axiosClient.delete(`/response_maps/${mappingId}`).catch(() => {}); } function onUnsubmit(_teamId: number, mappingId: number) { @@ -448,9 +480,20 @@ const AssignReviewer: React.FC = () => { ); p.response_maps = p.response_maps.filter(m => !ids.has(m.id)); p.responses = p.responses.filter(r => !ids.has(r.map_id)); + // Also delete from backend DB + ids.forEach(id => axiosClient.delete(`/response_maps/${id}`).catch(() => {})); }); } + // E2619: navigate to the questionnaire editor pre-filled as Quiz type. + // The editor will call PATCH /teams/:team_id/quiz_questionnaire after saving and then + // redirect back here via the return_to param. + function onCreateQuiz(teamId: Id) { + if (!hasValidId) return; + const returnTo = encodeURIComponent(`/assignments/edit/${assignmentId}/assignreviewer`); + navigate(`/questionnaires/new?type=Quiz&team_id=${teamId}&assignment_id=${assignmentId}&return_to=${returnTo}`); + } + const empty = teams.length === 0 && users.length === 0 && participants.length === 0 && response_maps.length === 0; return ( @@ -554,6 +597,15 @@ const AssignReviewer: React.FC = () => { diff --git a/src/pages/Assignments/AssignmentEditor.tsx b/src/pages/Assignments/AssignmentEditor.tsx index 1bd19dff..c3b30fe3 100644 --- a/src/pages/Assignments/AssignmentEditor.tsx +++ b/src/pages/Assignments/AssignmentEditor.tsx @@ -78,7 +78,6 @@ const initialValues: IAssignmentFormValues = { review_rubric_varies_by_round: false, review_rubric_varies_by_topic: false, review_rubric_varies_by_role: false, - is_role_based: false, has_max_review_limit: false, set_allowed_number_of_reviews_per_reviewer: 0, set_required_number_of_reviews_per_reviewer: 0, @@ -145,10 +144,10 @@ const AssignmentEditor: React.FC = ({ mode }) => { (Object.keys(initialValues) as (keyof IAssignmentFormValues)[]).forEach( (key) => { - const value = merged[key]; - if (value === null || value === undefined) { - merged[key] = initialValues[key]; - } + const value = merged[key]; + if (value === null || value === undefined) { + merged[key] = initialValues[key]; + } } ); @@ -167,7 +166,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { const [roleBasedLocalError, setRoleBasedLocalError] = useState(null); - useEffect(() => { + useEffect(() => { if (assignmentResponse?.data) { setAssignmentName(assignmentResponse.data.name || ""); // Load allow_bookmarks setting from backend @@ -245,7 +244,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { } }, [createResponse, dispatch, id, fetchTopics]); - useEffect(() => { + useEffect(() => { if (createError) { dispatch(alertActions.showAlert({ variant: "danger", message: createError })); } @@ -283,13 +282,13 @@ const AssignmentEditor: React.FC = ({ mode }) => { }, [dropTeamError, dispatch]); // Load topics for this assignment - useEffect(() => { - if (id) { - setTopicsLoading(true); - setTopicsError(null); - fetchTopics({ url: `/project_topics?assignment_id=${id}` }); - } - }, [id, fetchTopics]); + useEffect(() => { + if (id) { + setTopicsLoading(true); + setTopicsError(null); + fetchTopics({ url: `/project_topics?assignment_id=${id}` }); + } + }, [id, fetchTopics]); const refreshAccessibleDuties = useCallback(() => { fetchAccessibleDuties({ url: `/duties/accessible_duties` }); @@ -319,32 +318,32 @@ const AssignmentEditor: React.FC = ({ mode }) => { } }, [assignmentDutiesResponse]); - // Process topics response - useEffect(() => { - if (topicsResponse?.data) { - const transformedTopics: TopicData[] = (topicsResponse.data || []).map((topic: any) => ({ + // 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 + 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); @@ -390,108 +389,108 @@ const AssignmentEditor: React.FC = ({ mode }) => { [id, refreshAssignmentDuties, removeAssignmentDuty] ); const handleTopicSettingChange = useCallback((setting: string, value: boolean) => { - setTopicSettings((prev) => ({ ...prev, [setting]: value })); - - // Save allow_bookmarks setting to backend immediately + setTopicSettings((prev) => ({ ...prev, [setting]: value })); + + // Save allow_bookmarks setting to backend immediately if (setting === 'allowBookmarks' && id) { - updateAssignment({ - url: `/assignments/${id}`, + updateAssignment({ + url: `/assignments/${id}`, method: 'PATCH', - data: { - assignment: { + data: { + assignment: { allow_bookmarks: value } } - }); - } - // Save advertising_for_partners_allowed setting to backend immediately + }); + } + // Save advertising_for_partners_allowed setting to backend immediately if (setting === 'allowAdvertiseForPartners' && id) { - updateAssignment({ - url: `/assignments/${id}`, + updateAssignment({ + url: `/assignments/${id}`, method: 'PATCH', - data: { - assignment: { + data: { + assignment: { advertising_for_partners_allowed: value } } - }); - } + }); + } }, [id, updateAssignment]); const handleDropTeam = useCallback((topicId: string, teamId: string) => { - if (!topicId || !teamId) return; - dropTeamRequest({ - url: `/signed_up_teams/drop_team_from_topic`, + if (!topicId || !teamId) return; + dropTeamRequest({ + url: `/signed_up_teams/drop_team_from_topic`, method: 'DELETE', - params: { - topic_id: topicId, - team_id: teamId, - }, - }); + params: { + topic_id: topicId, + team_id: teamId, + }, + }); }, [dropTeamRequest]); - + const handleDeleteTopic = useCallback((topicIdentifier: string) => { - console.log(`Delete topic ${topicIdentifier}`); - if (id) { - deleteTopic({ - url: `/project_topics`, + console.log(`Delete topic ${topicIdentifier}`); + if (id) { + deleteTopic({ + url: `/project_topics`, method: 'DELETE', - params: { - assignment_id: Number(id), + 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}`, + 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, + 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`, + 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, + 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 handleApplyPartnerAd = useCallback((topicId: string, applicationText: string) => { + console.log(`Applying to partner ad for topic ${topicId}: ${applicationText}`); + // TODO: Implement partner ad application logic + }, []); + // Close the modal if the assignment is updated successfully and navigate to the assignments page @@ -619,10 +618,33 @@ const AssignmentEditor: React.FC = ({ mode }) => { }); // Build dropdown options from the questionnaires - const questionnaireOptions = (assignmentData.questionnaires || []).map((q: any) => ({ - label: q.name, - value: q.id, - })); + const questionnaireOptions = [ + { label: "-- Select --", value: "" }, + ...(assignmentData.questionnaires || []).map((q: any) => ({ + label: q.name, + value: q.id, + })), + ]; + + const teammateReviewOptions = (assignmentData.questionnaires || []) + .filter((q: any) => { + const questionnaireType = String(q.questionnaire_type || ""); + const questionnaireName = String(q.name || ""); + return ( + /teammatereview/i.test(questionnaireType) || + /teammate\s*review/i.test(questionnaireType) || + /teammate\s*review/i.test(questionnaireName) + ); + }) + .map((q: any) => ({ + label: q.name, + value: q.id, + })); + + const teammateReviewOptionsWithBlank = [ + { label: "-- Select --", value: "" }, + ...teammateReviewOptions, + ]; const reviewRounds = assignmentData.number_of_review_rounds; @@ -643,20 +665,20 @@ const AssignmentEditor: React.FC = ({ mode }) => { // Topic settings state - const [topicSettings, setTopicSettings] = useState({ - allowTopicSuggestions: false, - enableBidding: false, - enableAuthorsReview: true, - allowReviewerChoice: true, - allowBookmarks: false, - allowBiddingForReviewers: false, - allowAdvertiseForPartners: false, - }); - - // Topics data state - const [topicsData, setTopicsData] = useState([]); - const [topicsLoading, setTopicsLoading] = useState(false); - const [topicsError, setTopicsError] = useState(null); + const [topicSettings, setTopicSettings] = useState({ + allowTopicSuggestions: false, + enableBidding: false, + enableAuthorsReview: true, + allowReviewerChoice: true, + allowBookmarks: false, + allowBiddingForReviewers: false, + allowAdvertiseForPartners: false, + }); + + // Topics data state + const [topicsData, setTopicsData] = useState([]); + const [topicsLoading, setTopicsLoading] = useState(false); + const [topicsError, setTopicsError] = useState(null); @@ -676,259 +698,259 @@ const AssignmentEditor: React.FC = ({ mode }) => { enableReinitialize={true} > {(formik) => { - return ( -
- - {/* General Tab */} + return ( + + + {/* General Tab */}
- - - - {courses && ( - ({ - label: course.name, - value: course.id, - }))} - /> - )} + + + + {courses && ( + ({ + label: course.name, + value: course.id, + }))} + /> + )}
- + +
+ + +
- - - -
- + - {formik.values.has_teams && ( -
+ {formik.values.has_teams && ( +
- +
-
+
-
- )} + + )} - {formik.values.has_mentors && ( + {formik.values.has_mentors && (
- )} + )} - {formik.values.has_topics && ( + {formik.values.has_topics && (
- )} - + )} + -
- - {/* Topics Tab */} - - + + {/* Topics Tab */} + + id && fetchTopics({ url: `/project_topics?assignment_id=${id}` })} - /> - + /> + - {/* Rubrics Tab */} - + {/* Rubrics Tab */} +
- { - // Determine how many review rounds to show in the Rubrics table. - // For "vary by round", if the count is 0/undefined, still show one round - // so the user can configure at least the first round's rubric. - const baseRounds = - (mode === "update" - ? reviewRounds - : formik.values.number_of_review_rounds) ?? 0; - const rounds = formik.values.review_rubric_varies_by_round +
{ + // Determine how many review rounds to show in the Rubrics table. + // For "vary by round", if the count is 0/undefined, still show one round + // so the user can configure at least the first round's rubric. + const baseRounds = + (mode === "update" + ? reviewRounds + : formik.values.number_of_review_rounds) ?? 0; + const rounds = formik.values.review_rubric_varies_by_round ? (baseRounds || 1) - : baseRounds; - if (formik.values.review_rubric_varies_by_round) { + : baseRounds; + if (formik.values.review_rubric_varies_by_round) { return Array.from({ length: rounds }, (_, i) => ([ - { - id: i + 1, - title: `Review round ${i + 1}:`, - questionnaire_options: questionnaireOptions, - selected_questionnaire: roundSelections[i + 1]?.id, + { + id: i + 1, + title: `Review round ${i + 1}:`, + questionnaire_options: questionnaireOptions, + selected_questionnaire: roundSelections[i + 1]?.id, questionnaire_type: 'dropdown', - }, - { - id: i + 1, - title: `Add tag prompts`, + }, + { + id: i + 1, + title: `Add tag prompts`, questionnaire_type: 'tag_prompts', } ])).flat(); - } - return [ - { - id: 0, - title: "Review rubric:", - questionnaire_options: questionnaireOptions, - selected_questionnaire: roundSelections[1]?.id, + } + return [ + { + id: 0, + title: "Review rubric:", + questionnaire_options: questionnaireOptions, + selected_questionnaire: roundSelections[1]?.id, questionnaire_type: 'dropdown', - }, - { - id: 0, - title: "Add tag prompts", + }, + { + id: 0, + title: "Add tag prompts", questionnaire_type: 'tag_prompts', } - ]; - })(), - { - id: formik.values.number_of_review_rounds ?? 0, - title: "Author feedback:", - questionnaire_options: [{ label: 'Standard author feedback', value: 'Standard author feedback' }], - questionnaire_type: 'dropdown', - }, - { - id: formik.values.number_of_review_rounds ?? 0, - title: "Add tag prompts", - questionnaire_type: 'tag_prompts', - }, - { - id: (formik.values.number_of_review_rounds ?? 0) + 1, - title: "Teammate review:", - questionnaire_options: [{ label: 'Review with Github metrics', value: 'Review with Github metrics' }], - questionnaire_type: 'dropdown', - }, - { - id: (formik.values.number_of_review_rounds ?? 0) + 1, - title: "Add tag prompts", - questionnaire_type: 'tag_prompts', - }, - ]} - columns={[ - { + ]; + })(), + { + id: formik.values.number_of_review_rounds ?? 0, + title: "Author feedback:", + questionnaire_options: [{ label: 'Standard author feedback', value: 'Standard author feedback' }], + questionnaire_type: 'dropdown', + }, + { + id: formik.values.number_of_review_rounds ?? 0, + title: "Add tag prompts", + questionnaire_type: 'tag_prompts', + }, + { + id: (formik.values.number_of_review_rounds ?? 0) + 1, + title: "Teammate review:", + questionnaire_options: teammateReviewOptionsWithBlank, + questionnaire_type: "dropdown", + }, + { + id: (formik.values.number_of_review_rounds ?? 0) + 1, + title: "Add tag prompts", + questionnaire_type: 'tag_prompts', + }, + ]} + columns={[ + { cell: ({ row }) =>
{row.original.title}
, accessorKey: "title", header: "", enableSorting: false, enableColumnFilter: false - }, - { + }, + { cell: ({ row }) =>
{row.original.questionnaire_type === 'dropdown' && - } {row.original.questionnaire_type === 'tag_prompts' &&
}
, accessorKey: "questionnaire", header: "Questionnaire", enableSorting: false, enableColumnFilter: false - }, - { - cell: ({ row }) => { + }, + { + cell: ({ row }) => { if (row.original.questionnaire_type !== 'dropdown') { return
; - } + } - // Use distinct indices in the weights array so that - // different rows (review rubric, author feedback, - // teammate review, etc.) do not overwrite each other. - let weightIndex: number; - if (row.original.title === "Author feedback:") { - weightIndex = 100; // separate slot for author feedback - } else if (row.original.title === "Teammate review:") { - weightIndex = 101; // separate slot for teammate review - } else { - weightIndex = row.original.id; - } + // Use distinct indices in the weights array so that + // different rows (review rubric, author feedback, + // teammate review, etc.) do not overwrite each other. + let weightIndex: number; + if (row.original.title === "Author feedback:") { + weightIndex = 100; // separate slot for author feedback + } else if (row.original.title === "Teammate review:") { + weightIndex = 101; // separate slot for teammate review + } else { + weightIndex = row.original.id; + } - return ( + return (
- - % + + % +
-
- ); - }, + ); + }, accessorKey: `weights`, header: "Weight", enableSorting: false, enableColumnFilter: false - }, - { + }, + { cell: ({ row }) => <>{row.original.questionnaire_type === 'dropdown' && <>
%
}, accessorKey: "notification_limits", header: "Notification Limit", enableSorting: false, enableColumnFilter: false - }, - ]} - /> - - + }, + ]} + /> + + - {/* Review Strategy Tab */} - + {/* Review Strategy Tab */} +
- - -
- {formik.values.has_topics && ( + + + + {formik.values.has_topics && (
- +
+
- - )} + )}
- +
-
+
-
+
-
+
@@ -980,11 +1002,11 @@ const AssignmentEditor: React.FC = ({ mode }) => { /> {duty.name} {isAssigned && (added)} -
+ ); }); })()} - +
)} -
+
- {/* Due dates Tab */} - + {/* Due dates Tab */} +
- +
+
+
- - - + -
+
-
([ - { - id: 2 * i, - deadline_type: `Review ${i + 1}: Submission`, - }, - { - id: 2 * i + 1, - deadline_type: `Review ${i + 1}: Review`, - }, + { + id: 2 * i, + deadline_type: `Review ${i + 1}: Submission`, + }, + { + id: 2 * i + 1, + deadline_type: `Review ${i + 1}: Review`, + }, ])).flat(), ...(formik.values.use_signup_deadline ? [ - { + { id: 'signup_deadline', - deadline_type: "Signup deadline", - }, + deadline_type: "Signup deadline", + }, ] : []), ...(formik.values.use_drop_topic_deadline ? [ - { + { id: 'drop_topic_deadline', - deadline_type: "Drop topic deadline", - }, + deadline_type: "Drop topic deadline", + }, ] : []), ...(formik.values.use_team_formation_deadline ? [ - { + { id: 'team_formation_deadline', - deadline_type: "Team formation deadline", - }, + deadline_type: "Team formation deadline", + }, ] : []), - ]} - columns={[ + ]} + columns={[ { accessorKey: "deadline_type", header: "Deadline type", enableSorting: false, enableColumnFilter: false }, - { - cell: ({ row }) => ( - <> - - - ), + { + cell: ({ row }) => ( + <> + + + ), accessorKey: "date_time", header: "Date & Time", enableSorting: false, enableColumnFilter: false - }, - { + }, + { cell: ({ row }) => <>, accessorKey: `use_date_updater`, header: "Use date updater?", enableSorting: false, enableColumnFilter: false - }, - { + }, + { cell: ({ row }) => <> , accessorKey: "submission_allowed", header: "Submission allowed?", enableSorting: false, enableColumnFilter: false - }, - { + }, + { cell: ({ row }) => <> , accessorKey: "review_allowed", header: "Review allowed?", enableSorting: false, enableColumnFilter: false - }, - { + }, + { cell: ({ row }) => <> , accessorKey: "teammate_allowed", header: "Teammate allowed?", enableSorting: false, enableColumnFilter: false - }, - { + }, + { cell: ({ row }) => <> , accessorKey: "metareview_allowed", header: "Meta-review allowed?", enableSorting: false, enableColumnFilter: false - }, - { + }, + { cell: ({ row }) => <> , accessorKey: "reminder", header: "Reminder (hrs)", enableSorting: false, enableColumnFilter: false - }, - ]} - /> + }, + ]} + /> + -
@@ -1159,14 +1186,14 @@ const AssignmentEditor: React.FC = ({ mode }) => { +
+ - - - + - {/* Calibration Tab */} + {/* Calibration Tab */}

Submit reviews for calibration

@@ -1194,7 +1221,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { } else { return
View - | + | Edit
; } @@ -1203,14 +1230,14 @@ const AssignmentEditor: React.FC = ({ mode }) => { }, { cell: ({ row }) => <> -
Hyperlinks:
+
Hyperlinks:
{ row.original.submitted_content.hyperlinks.map((item: any, index: number) => { return {item}; }) } -
+
Files:
{ @@ -1218,7 +1245,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { return {item}; }) } -
+ , accessorKey: "submitted_content", header: "Submitted items(s)", enableSorting: false, enableColumnFilter: false }, @@ -1263,12 +1290,15 @@ const AssignmentEditor: React.FC = ({ mode }) => {
- {/* Submit button */} + {/* Submit button */}
| - Back + {" "} + | + + Back +
{showDutyEditor && ( { // Build nested attributes for assignment_questionnaires from the per-round form fields to create or update corresponding rows - const assignmentQuestionnaires: { id?: number; questionnaire_id: number; used_in_round: number }[] = []; - const roundCount = values.number_of_review_rounds ?? 0; - for (let i = 1; i <= roundCount; i += 1) { - const questionnaireId = values[`questionnaire_round_${i}`]; - if (questionnaireId) { - const existingId = values[`assignment_questionnaire_id_${i}`]; - assignmentQuestionnaires.push({ - id: existingId, - questionnaire_id: questionnaireId, - used_in_round: i, - }); + const assignmentQuestionnaires: { id?: number; questionnaire_id: number; used_in_round?: number }[] = []; + + // Scan for all questionnaire_round_* fields dynamically (0-based indexing) + // This works even if number_of_review_rounds is not set correctly + Object.keys(values).forEach((key) => { + const match = key.match(/^questionnaire_round_(\d+)$/); + if (match) { + const roundIndex = parseInt(match[1], 10); + const questionnaireId = values[key]; + if (questionnaireId) { + const existingId = values[`assignment_questionnaire_id_${roundIndex}`]; + assignmentQuestionnaires.push({ + id: existingId ? parseInt(String(existingId), 10) : undefined, + questionnaire_id: parseInt(String(questionnaireId), 10), // Ensure number type + used_in_round: roundIndex + 1, // Convert 0-based index to 1-based round number + }); + } } + }); + + // Add quiz questionnaire if selected and required + if (values.require_quiz && values.selected_quiz_questionnaire) { + assignmentQuestionnaires.push({ + id: values.assignment_questionnaire_quiz_id + ? parseInt(String(values.assignment_questionnaire_quiz_id), 10) + : undefined, + questionnaire_id: parseInt(String(values.selected_quiz_questionnaire), 10) + // No used_in_round for quiz + }); } const assignment: IAssignmentRequest = { @@ -163,17 +192,8 @@ export const transformAssignmentRequest = (values: IAssignmentFormValues) => { reminder: values.reminder ?? [], // Misc flags from other tabs - allow_tag_prompts: values.allow_tag_prompts ?? false, - has_quizzes: values.has_quizzes ?? false, - calibration_for_training: values.calibration_for_training ?? false, available_to_students: values.available_to_students ?? false, - allow_topic_suggestion_from_students: values.allow_topic_suggestion_from_students ?? false, - enable_bidding_for_topics: values.enable_bidding_for_topics ?? false, - enable_bidding_for_reviews: values.enable_bidding_for_reviews ?? false, - enable_authors_to_review_other_topics: values.enable_authors_to_review_other_topics ?? false, - allow_reviewer_to_choose_topic_to_review: values.allow_reviewer_to_choose_topic_to_review ?? false, - allow_participants_to_create_bookmarks: values.allow_participants_to_create_bookmarks ?? false, - staggered_deadline_assignment: values.staggered_deadline_assignment ?? false, + allow_suggestions: values.allow_suggestions ?? false, // Per-round rubric configuration vary_by_round: values.review_rubric_varies_by_round, @@ -184,6 +204,19 @@ export const transformAssignmentRequest = (values: IAssignmentFormValues) => { return JSON.stringify({ assignment }); }; +/** + * Deserializes the raw JSON string returned by `GET /assignments/:id` into the + * shape expected by the assignment editor form ({@link IAssignmentFormValues}). + * + * Also maps existing `DueDate` records back to the `date_time` structure used by + * the date-picker components, and maps per-round `assignment_questionnaires` rows + * back to the dynamic `questionnaire_round_*` / `assignment_questionnaire_id_*` + * form fields so that editing an existing assignment pre-fills every dropdown. + * + * @param assignmentResponse - Raw JSON string from the axios response. + * @returns An {@link IAssignmentFormValues} object ready to be passed as Formik + * `initialValues`. + */ export const transformAssignmentResponse = (assignmentResponse: string) => { const assignment: IAssignmentResponse = JSON.parse(assignmentResponse); @@ -250,9 +283,41 @@ export const transformAssignmentResponse = (assignmentResponse: string) => { due_dates: assignment.due_dates, assignment_questionnaires: assignment.assignment_questionnaires, }; + + // Map existing assignment_questionnaires back to individual form fields (questionnaire_round_*, assignment_questionnaire_id_*) + // so that when submitting, the IDs are included and Rails updates existing records instead of creating new ones + const assignmentQuestionnaires: any[] = assignment.assignment_questionnaires || []; + assignmentQuestionnaires.forEach((aq: any) => { + if (typeof aq.used_in_round === "number" && aq.used_in_round > 0) { + const roundIndex = aq.used_in_round - 1; // Convert 1-based round to 0-based index + assignmentValues[`questionnaire_round_${roundIndex}`] = aq.questionnaire_id; + assignmentValues[`assignment_questionnaire_id_${roundIndex}`] = aq.id; + } else { + // Quiz questionnaire has no used_in_round — restore to the quiz select field + const qType = String(aq.questionnaire?.questionnaire_type || ""); + if (!aq.used_in_round || /quizquestionnaire|quiz/i.test(qType)) { + assignmentValues.selected_quiz_questionnaire = aq.questionnaire_id ?? aq.questionnaire?.id; + assignmentValues.assignment_questionnaire_quiz_id = aq.id; + } + } + }); + return assignmentValues; }; +/** + * React Router loader for the assignment editor route. + * + * When an `:id` param is present the existing assignment is fetched from + * `GET /assignments/:id` (using {@link transformAssignmentResponse} as the + * axios `transformResponse`) and merged with a full questionnaire list so that + * rubric dropdowns are populated. When no id is provided (create mode) an + * empty object is returned alongside the questionnaire list. + * + * @param context - The React Router loader context containing `params`. + * @returns An object combining the (optional) assignment data with the + * `questionnaires` array. + */ export async function loadAssignment({ params }: any) { let assignmentData = {}; let questionnaires = []; // fetch questionnaire list for dropdown window selections in Rubrics tab diff --git a/src/pages/Assignments/tabs/GeneralTab.tsx b/src/pages/Assignments/tabs/GeneralTab.tsx index d7dab31e..713f754f 100644 --- a/src/pages/Assignments/tabs/GeneralTab.tsx +++ b/src/pages/Assignments/tabs/GeneralTab.tsx @@ -1,183 +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; +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/Questionnaires/Questionnaire.tsx b/src/pages/Questionnaires/Questionnaire.tsx index 982d8989..fa872d1d 100644 --- a/src/pages/Questionnaires/Questionnaire.tsx +++ b/src/pages/Questionnaires/Questionnaire.tsx @@ -48,7 +48,7 @@ const Questionnaires = () => { const onDeleteQuestionnaireHandler = useCallback(() => setShowDeleteConfirmation({ visible: false }), []); const onEditHandle = useCallback( - (row: TRow) => navigate(`edit/${row.original.id}`), + (row: TRow) => navigate(`/questionnaires/edit/${row.original.id}`), [navigate] ); diff --git a/src/pages/Questionnaires/QuestionnaireColumns.tsx b/src/pages/Questionnaires/QuestionnaireColumns.tsx index b566ae9c..2eb83b06 100644 --- a/src/pages/Questionnaires/QuestionnaireColumns.tsx +++ b/src/pages/Questionnaires/QuestionnaireColumns.tsx @@ -1,107 +1,109 @@ -import { BsPencilFill, BsPersonXFill } from "react-icons/bs"; -import { Row, createColumnHelper } from "@tanstack/react-table"; -import { BsArrowDownUp, BsArrowDown, BsArrowUp } from "react-icons/bs"; - - -import { Button, OverlayTrigger, Tooltip } from "react-bootstrap"; -import { QuestionnaireResponse as IQuestionnaire } from "./QuestionnaireUtils"; - -type Fn = (row: Row) => void; -const columnHelper = createColumnHelper(); - - -export const questionnaireColumns = (handleEdit: Fn, handleDelete: Fn) => [ - columnHelper.accessor("id", { - header: "ID", - }), - columnHelper.accessor("name", { - header: "Name", - size: 150, - }), - columnHelper.accessor("private", { - header: "Private", - size:100, - cell: (info) => - info.getValue() ? ( - Private - ) : null, - }), - columnHelper.accessor("questionnaire_type", { - header: "Type", - }), - columnHelper.accessor("created_at", { - header: "Created At", - cell: (info) => { - const dateValue = info.getValue(); - if (!dateValue) return ""; - return new Date(dateValue).toISOString().split("T")[0]; // shows YYYY-MM-DD - }, -}), -columnHelper.accessor("updated_at", { - header: "Updated At", - cell: (info) => { - const dateValue = info.getValue(); - if (!dateValue) return ""; - return new Date(dateValue).toISOString().split("T")[0]; - }, -}), - - columnHelper.accessor("instructor_id", { - header: "Instructor ID", - }), - columnHelper.accessor("instructor.name", { - header: "Instructor Name", - size:200 - }), - columnHelper.accessor("instructor.email", { - header: "Instructor Email", - size:300 - }), - columnHelper.display({ - id: "actions", - header: "Actions", - cell: ({ row }) => ( - <> - Edit Questionnaire}> - - - - Delete Questionnaire}> - - - - ), - }), -]; +import { BsPencilFill, BsPersonXFill } from "react-icons/bs"; +import { Row, createColumnHelper } from "@tanstack/react-table"; +import { BsArrowDownUp, BsArrowDown, BsArrowUp } from "react-icons/bs"; + + +import { Button, OverlayTrigger, Tooltip } from "react-bootstrap"; +import { QuestionnaireResponse as IQuestionnaire } from "./QuestionnaireUtils"; + +type Fn = (row: Row) => void; +const columnHelper = createColumnHelper(); + + +export const questionnaireColumns = (handleEdit: Fn, handleDelete: Fn) => [ + columnHelper.accessor("id", { + header: "ID", + }), + columnHelper.accessor("name", { + header: "Name", + size: 150, + }), + columnHelper.accessor("private", { + header: "Private", + size:100, + cell: (info) => + info.getValue() ? ( + Private + ) : null, + }), + columnHelper.accessor("questionnaire_type", { + header: "Type", + }), + columnHelper.accessor("created_at", { + header: "Created At", + cell: (info) => { + const dateValue = info.getValue(); + if (!dateValue) return ""; + return new Date(dateValue).toISOString().split("T")[0]; // shows YYYY-MM-DD + }, +}), +columnHelper.accessor("updated_at", { + header: "Updated At", + cell: (info) => { + const dateValue = info.getValue(); + if (!dateValue) return ""; + return new Date(dateValue).toISOString().split("T")[0]; + }, +}), + + columnHelper.accessor("instructor_id", { + header: "Instructor ID", + }), + columnHelper.accessor((row) => row.instructor?.name ?? "", { + id: "instructor.name", + header: "Instructor Name", + size:200 + }), + columnHelper.accessor((row) => row.instructor?.email ?? "", { + id: "instructor.email", + header: "Instructor Email", + size:300 + }), + columnHelper.display({ + id: "actions", + header: "Actions", + cell: ({ row }) => ( + <> + Edit Questionnaire}> + + + + Delete Questionnaire}> + + + + ), + }), +]; diff --git a/src/pages/Questionnaires/QuestionnaireEditor.tsx b/src/pages/Questionnaires/QuestionnaireEditor.tsx index 16097f7c..9611b39e 100644 --- a/src/pages/Questionnaires/QuestionnaireEditor.tsx +++ b/src/pages/Questionnaires/QuestionnaireEditor.tsx @@ -1,126 +1,149 @@ -import axiosClient from "../../utils/axios_client"; -import { IEditor } from "../../utils/interfaces"; -import { QuestionnaireFormValues , transformQuestionnaireRequest } from "./QuestionnaireUtils"; -import React, { useEffect, useState } from "react"; -import { useLoaderData, useLocation, useNavigate, useSearchParams } from "react-router-dom"; -import { Col, Container, Modal, Row } from 'react-bootstrap'; -import QuestionnaireForm from "./QuestionnaireForm"; -import { useSelector} from "react-redux"; -import { RootState } from "../../store/store"; - - -const QuestionnaireEditor: React.FC = ({ mode }) => { - const token = localStorage.getItem("token"); - const questionnaire :any = useLoaderData(); - const [searchParams] = useSearchParams(); - const location = useLocation(); - const navigate = useNavigate(); - const type = searchParams.get("type"); - - const [fetchedItems, setFetchedItems] = useState([]); - - - useEffect(() => { - const fetchItemsForQuestionnaire = async () => { - if (mode === "update" && questionnaire?.id) { - try { - const response = await axiosClient.get(`/questionnaires/${questionnaire.id}/items`, { - headers: { Authorization: `Bearer ${token}` }, - }); - setFetchedItems(response.data); - } catch (err) { - console.error("Error fetching questionnaire items:", err); - } - } - }; - - fetchItemsForQuestionnaire(); -}, [mode, questionnaire?.id, token]); - - - - const auth = useSelector( - (state: RootState) => state.authentication, - (prev, next) => prev.isAuthenticated === next.isAuthenticated - ); - - // Can view the decoded type in browser console - console.log("Type:", type); - - - // the form values to the browser console. - const onSubmit = async (values: QuestionnaireFormValues) => { - values.instructor_id = auth.user.id; - //values.instructor = auth.user.name; - console.log("Submit:", values); - const payload = transformQuestionnaireRequest(values); - const endpoint = mode === "create" - ? "/questionnaires" - : `/questionnaires/${values.id}`; - - try { - const response = await axiosClient[mode === "create" ? "post" : "put"]( - endpoint, - payload, - { headers: { Authorization: `Bearer ${token}` } } - ); - - console.log("Saved Questionnaire:", response.data); - navigate("/questionnaires"); - } catch (error) { - console.error("Error submitting form:", error); - } -}; - - - - // initial form values - const initialValues: QuestionnaireFormValues = { - id: questionnaire?.id ?? undefined, - name: questionnaire?.name ?? "", - questionnaire_type: questionnaire?.questionnaire_type ?? type ?? "", - private: questionnaire?.private ?? false, - min_question_score: questionnaire?.min_question_score ?? 0, - max_question_score: questionnaire?.max_question_score ?? 10, - items: fetchedItems.length > 0 ? fetchedItems.map(item => ({ - id: item.id, - txt: item.txt, - question_type: item.question_type, - weight: item.weight, - alternatives: item.alternatives, - min_label: item.min_label, - max_label: item.max_label, - textarea_width: item.textarea_width, - textarea_height: item.textarea_height, - textbox_width: item.textbox_width, - col_names: item.col_names, - row_names: item.row_names, - seq: item.seq, - break_before: item.break_before, - _destroy: item._destroy || false, - })) : questionnaire?.items ?? [], - }; - - return ( - - -
-

{mode === "update" - ? `Update Questionnaire: ${questionnaire.name}` - : `Create ${type} Questionnaire`}

- -
- - - - - - - - ); -}; - -export default QuestionnaireEditor; +import axiosClient from "../../utils/axios_client"; +import { IEditor } from "../../utils/interfaces"; +import { QuestionnaireFormValues , transformQuestionnaireRequest } from "./QuestionnaireUtils"; +import React, { useEffect, useState } from "react"; +import { useLoaderData, useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { Col, Container, Modal, Row } from 'react-bootstrap'; +import QuestionnaireForm from "./QuestionnaireForm"; +import { useSelector} from "react-redux"; +import { RootState } from "../../store/store"; + + +const QuestionnaireEditor: React.FC = ({ mode }) => { + const token = localStorage.getItem("token"); + const questionnaire :any = useLoaderData(); + const [searchParams] = useSearchParams(); + const location = useLocation(); + const navigate = useNavigate(); + const type = searchParams.get("type"); + + const [fetchedItems, setFetchedItems] = useState([]); + + + useEffect(() => { + const fetchItemsForQuestionnaire = async () => { + if (mode === "update" && questionnaire?.id) { + try { + const response = await axiosClient.get(`/questionnaires/${questionnaire.id}/items`, { + headers: { Authorization: `Bearer ${token}` }, + }); + setFetchedItems(response.data); + } catch (err) { + console.error("Error fetching questionnaire items:", err); + } + } + }; + + fetchItemsForQuestionnaire(); +}, [mode, questionnaire?.id, token]); + + + + const auth = useSelector( + (state: RootState) => state.authentication, + (prev, next) => prev.isAuthenticated === next.isAuthenticated + ); + + // Can view the decoded type in browser console + console.log("Type:", type); + + + // the form values to the browser console. + const onSubmit = async (values: QuestionnaireFormValues) => { + // Skip instructor_id for student-created quizzes (team_id present) + const isTeamQuiz = !!searchParams.get("team_id"); + if (!isTeamQuiz) { + values.instructor_id = auth.user.id; + } + //values.instructor = auth.user.name; + console.log("Submit:", values); + const payload = transformQuestionnaireRequest(values); + const endpoint = mode === "create" + ? "/questionnaires" + : `/questionnaires/${values.id}`; + + try { + const response = await axiosClient[mode === "create" ? "post" : "put"]( + endpoint, + payload, + { headers: { Authorization: `Bearer ${token}` } } + ); + + console.log("Saved Questionnaire:", response.data); + + // Link quiz to team and navigate back if created from AssignReviewer + const teamId = searchParams.get("team_id"); + const returnTo = searchParams.get("return_to"); + if (mode === "create" && teamId && response.data?.id) { + try { + await axiosClient.patch( + `/teams/${teamId}/quiz_questionnaire`, + { questionnaire_id: response.data.id }, + { headers: { Authorization: `Bearer ${token}` } } + ); + } catch (linkErr) { + console.error("Failed to link quiz questionnaire to team:", linkErr); + } + navigate(returnTo ? decodeURIComponent(returnTo) : "/questionnaires"); + return; + } + + navigate("/questionnaires"); + } catch (error) { + console.error("Error submitting form:", error); + } +}; + + + + // initial form values + const initialValues: QuestionnaireFormValues = { + id: questionnaire?.id ?? undefined, + name: questionnaire?.name ?? "", + questionnaire_type: questionnaire?.questionnaire_type ?? type ?? "", + private: questionnaire?.private ?? false, + min_question_score: questionnaire?.min_question_score ?? 0, + max_question_score: questionnaire?.max_question_score ?? 10, + items: fetchedItems.length > 0 ? fetchedItems.map(item => ({ + id: item.id, + txt: item.txt, + question_type: item.question_type, + weight: item.weight, + alternatives: item.alternatives, + min_label: item.min_label, + max_label: item.max_label, + textarea_width: item.textarea_width, + textarea_height: item.textarea_height, + textbox_width: item.textbox_width, + col_names: item.col_names, + row_names: item.row_names, + seq: item.seq, + break_before: item.break_before, + _destroy: item._destroy || false, + correct_answer: item.correct_answer ?? "", + })) : questionnaire?.items ?? [], + }; + + return ( + + + +

{mode === "update" + ? `Update Questionnaire: ${questionnaire.name}` + : `Create ${type} Questionnaire`}

+ +
+ + + + + + + + ); +}; + +export default QuestionnaireEditor; diff --git a/src/pages/Questionnaires/QuestionnaireForm.tsx b/src/pages/Questionnaires/QuestionnaireForm.tsx index 5c911d53..f0e3deb9 100644 --- a/src/pages/Questionnaires/QuestionnaireForm.tsx +++ b/src/pages/Questionnaires/QuestionnaireForm.tsx @@ -1,193 +1,193 @@ - import React, { useEffect, useState } from "react"; - import { Formik, Field, Form, ErrorMessage } from "formik"; - import { Button } from 'react-bootstrap'; - import QuestionnaireItemsFieldArray from "./QuestionnaireItemsFieldArray"; - import * as Yup from "yup"; - import useAPI from "hooks/useAPI"; - - - const QuestionnaireForm = ({ initialValues, onSubmit }: any) => { - - const { data: itemTypes, sendRequest: fetchItemTypes } = useAPI(); - - - useEffect(() => { - - fetchItemTypes({ url: "/item_types" }); - console.log(itemTypes?.data); - }, [fetchItemTypes]); - - - const itemFields = Yup.object().shape({ - txt: Yup.string().required("Item text is required"), - question_type: Yup.string().required("Item type is required"), - weight: Yup.number() - .typeError("Score must be a number") - .positive("Score must be a positive number") - .nullable() - .notRequired(), - - alternatives: Yup.string().when("question_type", ([questionType], schema) => { - if (questionType === "dropdown" || questionType === "multiple_choice") { - return schema - .required("Options are required") - .test( - "min-2-options", - "Enter at least two options, separated by commas.", - (value) => { - if (!value) return false; - const options = value - .split(",") - .map((opt) => opt.trim()) - .filter((opt) => opt !== ""); - return options.length >= 2; - } - ); - } - return schema.notRequired(); - }), - - min_label: Yup.string().when("question_type", ([question_type], schema) => { - return question_type === "scale" - ? schema.required("Minimum label is required") - : schema.notRequired(); - }), - - max_label: Yup.string().when("question_type", ([question_type], schema) => { - return question_type === "scale" - ? schema.required("Maximum label is required") - : schema.notRequired(); - }), - }); - - const validationSchema = Yup.object().shape({ - name: Yup.string().required("Name is required"), - questionnaire_type: Yup.string().required("Questionnaire type is required"), - private: Yup.boolean(), - min_question_score: Yup.number().required("Minimum item score is required"), - max_question_score: Yup.number().required("Maximum item score is required"), - items: Yup.array().of(itemFields).min(1, "At least one item is required"), - }); - - - return ( -
- - {({ values, handleChange, errors, touched }) => ( - - {values.questionnaire_type === "Teammate Review" && ( -
-
- - -
- - {values.relatesToRole && ( -
- - Select Duty - - - - {["Project Management", "Code Review", "Testing", "Documentation"].map( - (duty) => ( - - ) - )} - - -
- )} -
- )} - Name - - - - - - - - -
-
- - Private - -
- - - - -   ← Min     Item Score     Max →  - - - -
- - - {/* Allows users to input a variable number of questions / items */} - t.name) as string[]) ?? []} /> - -
- - - )} -
-
- ); - }; - - export default QuestionnaireForm; + import React, { useEffect, useState } from "react"; + import { Formik, Field, Form, ErrorMessage } from "formik"; + import { Button } from 'react-bootstrap'; + import QuestionnaireItemsFieldArray from "./QuestionnaireItemsFieldArray"; + import * as Yup from "yup"; + import useAPI from "hooks/useAPI"; + + + const QuestionnaireForm = ({ initialValues, onSubmit }: any) => { + + const { data: itemTypes, sendRequest: fetchItemTypes } = useAPI(); + + + useEffect(() => { + + fetchItemTypes({ url: "/item_types" }); + console.log(itemTypes?.data); + }, [fetchItemTypes]); + + + const itemFields = Yup.object().shape({ + txt: Yup.string().required("Item text is required"), + question_type: Yup.string().required("Item type is required"), + weight: Yup.number() + .typeError("Score must be a number") + .positive("Score must be a positive number") + .nullable() + .notRequired(), + + alternatives: Yup.string().when("question_type", ([questionType], schema) => { + if (questionType === "dropdown" || questionType === "multiple_choice") { + return schema + .required("Options are required") + .test( + "min-2-options", + "Enter at least two options, separated by commas.", + (value) => { + if (!value) return false; + const options = value + .split(",") + .map((opt) => opt.trim()) + .filter((opt) => opt !== ""); + return options.length >= 2; + } + ); + } + return schema.notRequired(); + }), + + min_label: Yup.string().when("question_type", ([question_type], schema) => { + return question_type === "scale" + ? schema.required("Minimum label is required") + : schema.notRequired(); + }), + + max_label: Yup.string().when("question_type", ([question_type], schema) => { + return question_type === "scale" + ? schema.required("Maximum label is required") + : schema.notRequired(); + }), + }); + + const validationSchema = Yup.object().shape({ + name: Yup.string().required("Name is required"), + questionnaire_type: Yup.string().required("Questionnaire type is required"), + private: Yup.boolean(), + min_question_score: Yup.number().required("Minimum item score is required"), + max_question_score: Yup.number().required("Maximum item score is required"), + items: Yup.array().of(itemFields).min(1, "At least one item is required"), + }); + + + return ( +
+ + {({ values, handleChange, errors, touched }) => ( +
+ {values.questionnaire_type === "Teammate Review" && ( +
+
+ + +
+ + {values.relatesToRole && ( +
+ + Select Duty + + + + {["Project Management", "Code Review", "Testing", "Documentation"].map( + (duty) => ( + + ) + )} + + +
+ )} +
+ )} + Name + + + + + + + + +
+
+ + Private + +
+ + + + +   ← Min     Item Score     Max →  + + + +
+ + + {/* Allows users to input a variable number of questions / items */} + t.name) as string[]) ?? []} questionnaireType={values.questionnaire_type ?? ""} /> + +
+ + + )} +
+
+ ); + }; + + export default QuestionnaireForm; diff --git a/src/pages/Questionnaires/QuestionnaireItemsFieldArray.tsx b/src/pages/Questionnaires/QuestionnaireItemsFieldArray.tsx index 062887a9..bb16f69f 100644 --- a/src/pages/Questionnaires/QuestionnaireItemsFieldArray.tsx +++ b/src/pages/Questionnaires/QuestionnaireItemsFieldArray.tsx @@ -1,473 +1,589 @@ -import React, { useState } from "react"; -import { - Field, - FieldArray, - FieldArrayRenderProps, - ErrorMessage, -} from "formik"; -import { IoIosRemoveCircleOutline } from "react-icons/io"; -import { - DragDropContext, - Droppable, - Draggable, - DropResult, -} from "react-beautiful-dnd"; -import { OverlayTrigger, Tooltip, Button } from "react-bootstrap"; -import { IItem } from "./QuestionnaireUtils"; - -interface Props { - values: any; - errors: any; - touched: any; - itemTypes: string[]; -} - -const QuestionnaireItemsFieldArray: React.FC = ({ - values, - errors, - touched, - itemTypes -}) => { - const [questionType, setQuestionType] = useState(""); - const [numQuestions, setNumQuestions] = useState(""); - const [showNumbers, setShowNumbers] = useState(false); - - - - return ( - - {({ push, remove, move, form }: FieldArrayRenderProps) => { - const { setFieldValue } = form; - return( - <> - - { - if (!result.destination) return; - move(result.source.index, result.destination.index); - }} - > - - {(provided) => ( -
- {values.items.length > 0 && - values.items.map((item: IItem, index: number) => ({ item, index })).filter(({ item }: { item: IItem }) => !item._destroy).map(({ item, index }: { item: IItem; index: number }) => ( - - {(provided, snapshot) => ( -
- -
- - - - - {showNumbers && ( - {index + 1}. - - )} - - - - {item.question_type} - - - - {item.question_type === "Multiple choice" || - item.question_type === "Dropdown" ? ( - <> - - - -
- Remove Item}> - -
- - ) : item.question_type === "Scale" ? ( - <> - - - -
- Remove Item}> - -
- - ) : item.question_type === "Criterion" ? ( - <> - - - - - -
- Remove Item}> - -
- - ) : item.question_type === "Text field" ? (<> - -
- Remove Item}> - -
- ): item.question_type === "Text area" ? ( - <> - - -
- Remove Item}> - -
- - ) : item.question_type === "Grid" ?( - <> - - -
- Remove Item}> - -
- - - - ) - : ( - <> -
-Remove Item}> - - -
- - )} -
- - - -
- )} -
- ))} - {provided.placeholder} -
- )} -
-
- - -
- - - - ) => - setNumQuestions(Number(e.target.value)) - } - className="form-control" - maxLength={3} - style={{ width: "60px" }} -/> - -
items
- -
- setShowNumbers(e.target.checked)} - /> - Show item numbers - -
-
- - )}} -
- ); -}; - -export default QuestionnaireItemsFieldArray; +import React, { useState } from "react"; +import { + Field, + FieldArray, + FieldArrayRenderProps, + ErrorMessage, +} from "formik"; +import { IoIosRemoveCircleOutline } from "react-icons/io"; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from "react-beautiful-dnd"; +import { OverlayTrigger, Tooltip, Button } from "react-bootstrap"; +import { IItem } from "./QuestionnaireUtils"; + +/** + * Question types for which a correct-answer field is rendered inside a Quiz questionnaire. + * These match the `question_type` strings stored by the frontend (spaced, display-friendly + * names) rather than the CamelCase variants used in the backend STI hierarchy. + */ +const QUIZ_ITEM_TYPES = ["Text field", "Multiple choice", "Multiple choice checkbox", "Scale", "Checkbox"]; + +interface Props { + values: any; + errors: any; + touched: any; + itemTypes: string[]; + questionnaireType: string; +} + +/** + * A Formik `` that renders the list of questionnaire items and the + * "Add items" controls at the bottom. + * + * Each existing (non-destroyed) item is displayed as a draggable row via + * `react-beautiful-dnd`. The exact fields shown per row depend on + * `item.question_type` (e.g. alternatives for Multiple Choice, min/max labels for + * Scale, width/height for Text Area, etc.). + * + * E2619: when `questionnaireType` is `"Quiz"` and the item type is one of + * {@link QUIZ_ITEM_TYPES}, an additional "Correct answer" row is rendered below + * the main item row so instructors can specify the expected answer at authoring + * time. + * + * @param props.values - Current Formik values (needs `values.items`). + * @param props.errors - Formik errors object, used for inline validation messages. + * @param props.touched - Formik touched object. + * @param props.itemTypes - Array of available item type strings used to populate + * the "Select item type" dropdown in the Add row. + * @param props.questionnaireType - The questionnaire's type string; controls + * whether the correct-answer section and certain item types are shown. + */ +const QuestionnaireItemsFieldArray: React.FC = ({ + values, + errors, + touched, + itemTypes, + questionnaireType +}) => { + const [questionType, setQuestionType] = useState(""); + const [numQuestions, setNumQuestions] = useState(""); + const [showNumbers, setShowNumbers] = useState(false); + + + + return ( + + {({ push, remove, move, form }: FieldArrayRenderProps) => { + const { setFieldValue } = form; + return( + <> + + { + if (!result.destination) return; + move(result.source.index, result.destination.index); + }} + > + + {(provided) => ( +
+ {values.items.length > 0 && + values.items.map((item: IItem, index: number) => ({ item, index })).filter(({ item }: { item: IItem }) => !item._destroy).map(({ item, index }: { item: IItem; index: number }) => ( + + {(provided, snapshot) => ( +
+ +
+ + + + + {showNumbers && ( + {index + 1}. + + )} + + + + {item.question_type} + + + + {item.question_type === "Multiple choice" || + item.question_type === "Dropdown" ? ( + <> + + + +
+ Remove Item}> + +
+ + ) : item.question_type === "Scale" ? ( + <> + + + +
+ Remove Item}> + +
+ + ) : item.question_type === "Criterion" ? ( + <> + + + + + +
+ Remove Item}> + +
+ + ) : item.question_type === "Text field" ? (<> + +
+ Remove Item}> + +
+ ): item.question_type === "Text area" ? ( + <> + + +
+ Remove Item}> + +
+ + ) : item.question_type === "Grid" ?( + <> + + +
+ Remove Item}> + +
+ + + + ) + : ( + <> +
+Remove Item}> + + +
+ + )} +
+ + {questionnaireType === "Quiz" && QUIZ_ITEM_TYPES.includes(item.question_type) && ( +
+ + Correct answer + + + {/* Checkbox: toggle correct/incorrect */} + {item.question_type === "Checkbox" && ( +
+ ) => + setFieldValue(`items[${index}].correct_answer`, String(e.target.checked)) + } + /> + +
+ )} + + {/* Scale: numeric value within the scale range */} + {item.question_type === "Scale" && ( + + )} + + {/* Multiple choice: select one of the alternatives */} + {(item.question_type === "Multiple choice" || item.question_type === "Multiple choice checkbox") && ( + + + {(item.alternatives || "") + .split(",") + .map((opt: string) => opt.trim()) + .filter((opt: string) => opt !== "") + .map((opt: string) => ( + + ))} + + )} + + {/* Text field: plain text input */} + {item.question_type === "Text field" && ( + + )} + + +
+ )} + + + +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ + +
+ + + + ) => + setNumQuestions(Number(e.target.value)) + } + className="form-control" + maxLength={3} + style={{ width: "60px" }} +/> + +
items
+ +
+ setShowNumbers(e.target.checked)} + /> + Show item numbers + +
+
+ + )}} +
+ ); +}; + +export default QuestionnaireItemsFieldArray; diff --git a/src/pages/Questionnaires/QuestionnaireUtils.tsx b/src/pages/Questionnaires/QuestionnaireUtils.tsx index e8a2bfa4..449c2266 100644 --- a/src/pages/Questionnaires/QuestionnaireUtils.tsx +++ b/src/pages/Questionnaires/QuestionnaireUtils.tsx @@ -1,145 +1,187 @@ -import axiosClient from "../../utils/axios_client"; -import { IInstructor } from "../../utils/interfaces"; - -export type QuestionnaireType = - | "Author feedback" - | "Teammate Review" - | "Survey" - | "Assignment survey" - | "Global survey" - | "Course survey" - | "Bookmark rating" - | "Quiz"; - - -export const QuestionnaireTypes: QuestionnaireType[] = [ - "Author feedback", - "Teammate Review", - "Survey", - "Assignment survey", - "Global survey", - "Course survey", - "Bookmark rating", - "Quiz", -]; - - -export interface IItem { - id?: number; - txt: string; - weight?: number; - seq: number; - question_type: string; - size?: number | string; - alternatives?: string; - min_label?: number; - max_label?: number; - break_before?: boolean; - questionnaire_id?: number; - _destroy?: boolean; - type?: string; - -} - - -export interface QuestionnaireFormValues { - id?: number; - name: string; - questionnaire_type:string; - private:boolean; - created_at?: string; - updated_at?: string; - min_question_score: number; - max_question_score: number; - instructor_id?: number; - instructor?: IInstructor; - items?: IItem[]; -} - -export interface QuestionnaireResponse { - id?: number; - name: string; - private:boolean; - created_at: string; - updated_at: string; - questionnaire_type:string; - min_question_score: number; - max_question_score: number; - instructor_id: number; - instructor: IInstructor; - items?: IItem[]; -} - -export interface QuestionnaireRequest { - id?: number; - name: string; - private:boolean; - questionnaire_type:string; - min_question_score: number; - max_question_score: number; - instructor_id?: number; - instructor?: IInstructor; - items_attributes: IItem[]; -} - -export function getQuestionnaireTypes(quest: QuestionnaireResponse[]): string[] { - return Array.from( - new Set( - quest - .map((q) => q.questionnaire_type) - .filter((type): type is string => type !== null) - ) - ); -} - - -export const transformQuestionnaireRequest = (values: QuestionnaireFormValues) => { - console.log("Original Form Values:", values); - const questionnaire: QuestionnaireRequest = { - id: values.id, - name: values.name, - questionnaire_type: values.questionnaire_type.replace(/\s+/g, ""), - private: values.private, - min_question_score: values.min_question_score, - max_question_score: values.max_question_score, - instructor_id: values.instructor_id, - items_attributes: values.items - ? values.items.map((item, index) => ({ - ...item, - seq: index + 1, - break_before: item.break_before ?? false, - })) - : [], - }; - console.log("Transformed Questionnaire Request:", questionnaire); - return { questionnaire }; -}; - -export const transformQuestionnaireResponse = (data: any): QuestionnaireFormValues => { - return { - id: data.id, - name: data.name, - private: data.private, - questionnaire_type: data.questionnaire_type, - min_question_score: data.min_question_score, - max_question_score: data.max_question_score, - instructor_id: data.instructor_id, - instructor: data.instructor, - created_at: data.created_at, - updated_at: data.updated_at, - items: data.items, - }; -}; - - -export async function loadQuestionnaire({ params }: any) { - if (params.id) { - const response = await axiosClient.get(`/questionnaires/${params.id}`); - return transformQuestionnaireResponse(response.data); - } else { - const response = await axiosClient.get(`/questionnaires`); - return response.data.map((q: any) => transformQuestionnaireResponse(q)); - } -} - - +import axiosClient from "../../utils/axios_client"; +import { IInstructor } from "../../utils/interfaces"; + +export type QuestionnaireType = + | "Author feedback" + | "Teammate Review" + | "Survey" + | "Assignment survey" + | "Global survey" + | "Course survey" + | "Bookmark rating" + | "Quiz"; + + +export const QuestionnaireTypes: QuestionnaireType[] = [ + "Author feedback", + "Teammate Review", + "Survey", + "Assignment survey", + "Global survey", + "Course survey", + "Bookmark rating", + "Quiz", +]; + + +export interface IItem { + id?: number; + txt: string; + weight?: number; + seq: number; + question_type: string; + size?: number | string; + alternatives?: string; + min_label?: number; + max_label?: number; + break_before?: boolean; + questionnaire_id?: number; + _destroy?: boolean; + type?: string; + correct_answer?: string; + textarea_width?: number; + textarea_height?: number; + textbox_width?: number; + col_names?: string; + row_names?: string; +} + + +export interface QuestionnaireFormValues { + id?: number; + name: string; + questionnaire_type:string; + private:boolean; + created_at?: string; + updated_at?: string; + min_question_score: number; + max_question_score: number; + instructor_id?: number; + instructor?: IInstructor; + items?: IItem[]; +} + +export interface QuestionnaireResponse { + id?: number; + name: string; + private:boolean; + created_at: string; + updated_at: string; + questionnaire_type:string; + min_question_score: number; + max_question_score: number; + instructor_id: number; + instructor: IInstructor; + items?: IItem[]; +} + +export interface QuestionnaireRequest { + id?: number; + name: string; + private:boolean; + questionnaire_type:string; + min_question_score: number; + max_question_score: number; + instructor_id?: number; + instructor?: IInstructor; + items_attributes: IItem[]; +} + +/** + * Extracts the unique, non-null questionnaire type strings from an array of + * questionnaire response objects. + * + * @param quest - Array of {@link QuestionnaireResponse} objects from the API. + * @returns A de-duplicated array of questionnaire type strings. + */ +export function getQuestionnaireTypes(quest: QuestionnaireResponse[]): string[] { + return Array.from( + new Set( + quest + .map((q) => q.questionnaire_type) + .filter((type): type is string => type !== null) + ) + ); +} + + +/** + * Transforms {@link QuestionnaireFormValues} into the request shape expected by + * the Rails questionnaires API. + * + * Strips whitespace from `questionnaire_type` (e.g. `"Quiz Questionnaire"` → + * `"QuizQuestionnaire"`), re-sequences items by their current array index, and + * ensures every item has `break_before` set. + * + * @param values - The current Formik form values from the questionnaire editor. + * @returns An object `{ questionnaire: QuestionnaireRequest }` ready to be + * passed as the request body. + */ +export const transformQuestionnaireRequest = (values: QuestionnaireFormValues) => { + console.log("Original Form Values:", values); + const questionnaire: QuestionnaireRequest = { + id: values.id, + name: values.name, + questionnaire_type: values.questionnaire_type.replace(/\s+/g, ""), + private: values.private, + min_question_score: values.min_question_score, + max_question_score: values.max_question_score, + instructor_id: values.instructor_id, + items_attributes: values.items + ? values.items.map((item, index) => ({ + ...item, + seq: index + 1, + break_before: item.break_before ?? false, + })) + : [], + }; + console.log("Transformed Questionnaire Request:", questionnaire); + return { questionnaire }; +}; + +/** + * Transforms a raw questionnaire API response into {@link QuestionnaireFormValues} + * for use as Formik initial values. + * + * @param data - Raw object returned by `GET /questionnaires/:id`. + * @returns A {@link QuestionnaireFormValues} instance ready to hydrate the form. + */ +export const transformQuestionnaireResponse = (data: any): QuestionnaireFormValues => { + return { + id: data.id, + name: data.name, + private: data.private, + questionnaire_type: data.questionnaire_type, + min_question_score: data.min_question_score, + max_question_score: data.max_question_score, + instructor_id: data.instructor_id, + instructor: data.instructor, + created_at: data.created_at, + updated_at: data.updated_at, + items: data.items, + }; +}; + + +/** + * React Router loader for the questionnaire editor route. + * + * When a `:id` param is present, fetches and transforms the single questionnaire + * at `GET /questionnaires/:id`. Otherwise fetches the full list from + * `GET /questionnaires` and transforms every entry. + * + * @param context - The React Router loader context containing `params`. + * @returns A single {@link QuestionnaireFormValues} in edit mode, or an array + * of them in list mode. + */ +export async function loadQuestionnaire({ params }: any) { + if (params.id) { + const response = await axiosClient.get(`/questionnaires/${params.id}`); + return transformQuestionnaireResponse(response.data); + } else { + const response = await axiosClient.get(`/questionnaires`); + return response.data.map((q: any) => transformQuestionnaireResponse(q)); + } +} + + diff --git a/src/pages/Student Teams/TeammateReview.tsx b/src/pages/Student Teams/TeammateReview.tsx index c1c90df0..0379f994 100644 --- a/src/pages/Student Teams/TeammateReview.tsx +++ b/src/pages/Student Teams/TeammateReview.tsx @@ -1,87 +1,850 @@ -import React, { useState, FormEvent } from 'react'; -import { Form, Button, FormControl } from 'react-bootstrap'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, Badge, Button, Form, Spinner, Table as BsTable } from 'react-bootstrap'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store/store'; +import useAPI from '../../hooks/useAPI'; +import axiosClient from '../../utils/axios_client'; + +type QuestionnaireType = 'Review' | 'Teammate Review' | 'Quiz'; + +type NormalizedItemType = + | 'SectionHeader' + | 'TableHeader' + | 'ColumnHeader' + | 'Criterion' + | 'TextField' + | 'TextArea' + | 'Dropdown' + | 'MultipleChoice' + | 'Scale' + | 'Checkbox' + | 'Grid' + | 'UploadFile' + | 'Unknown'; + +const isTeammateQuestionnaire = (questionnaire: any) => { + const questionnaireType = String(questionnaire?.questionnaire_type || ''); + const questionnaireName = String(questionnaire?.name || ''); + return ( + /teammatereview/i.test(questionnaireType) + || /teammate\s*review/i.test(questionnaireType) + || /teammate\s*review/i.test(questionnaireName) + ); +}; + +const parseMaybeNumber = (value: any): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +const normalizeItemType = (item: any): NormalizedItemType => { + const rawType = String(item?.question_type || item?.item_type || item?.type || '') + .replace(/[_\s-]+/g, '') + .toLowerCase(); + + if (rawType === 'sectionheader' || rawType === 'section_header') return 'SectionHeader'; + if (rawType === 'tableheader' || rawType === 'table_header') return 'TableHeader'; + if (rawType === 'columnheader' || rawType === 'column_header') return 'ColumnHeader'; + if (rawType === 'criterion' || rawType === 'scoredquestion') return 'Criterion'; + if (rawType === 'textfield') return 'TextField'; + if (rawType === 'textarea') return 'TextArea'; + if (rawType === 'dropdown') return 'Dropdown'; + if (rawType === 'multiplechoice' || rawType === 'multiplechoiceradio' || rawType === 'multiplechoicecheckbox') return 'MultipleChoice'; + if (rawType === 'scale') return 'Scale'; + if (rawType === 'checkbox') return 'Checkbox'; + if (rawType === 'grid') return 'Grid'; + if (rawType === 'uploadfile' || rawType === 'upload' || rawType === 'file') return 'UploadFile'; + return 'Unknown'; +}; + +const parseGridNames = (value: any): string[] => { + if (Array.isArray(value)) return value.map(String).filter(Boolean); + if (typeof value === 'string' && value.trim()) { + return value.split(/\||,|;/).map(s => s.trim()).filter(Boolean); + } + return []; +}; + +const parseAlternatives = (item: any): string[] => { + const rawAlternatives = item?.alternatives ?? item?.options ?? item?.choices; + if (Array.isArray(rawAlternatives)) { + return rawAlternatives.map((choice) => String(choice).trim()).filter(Boolean); + } + if (typeof rawAlternatives === 'string') { + return rawAlternatives + .split(/\r?\n|\||,|;/) + .map((choice) => choice.trim()) + .filter(Boolean); + } + return []; +}; + +const getScoreBounds = (item: any, questionnaire: any): { min: number; max: number } => { + const min = + parseMaybeNumber(item?.min_item_score) + ?? parseMaybeNumber(item?.min_question_score) + ?? parseMaybeNumber(item?.min_label) + ?? parseMaybeNumber(questionnaire?.min_question_score) + ?? 0; + + const max = + parseMaybeNumber(item?.max_item_score) + ?? parseMaybeNumber(item?.max_question_score) + ?? parseMaybeNumber(item?.max_label) + ?? parseMaybeNumber(questionnaire?.max_question_score) + ?? 10; + + return min <= max ? { min, max } : { min: max, max: min }; +}; const TeammateReview = () => { - const [lateCount, setLateCount] = useState(0); // State to track the number of times a teammate was late - const [comments, setComments] = useState(''); // State for storing user comments - - // Inline CSS styles for component styling - const styles = { - container: { - fontFamily: 'Arial, sans-serif', - maxWidth: '1000px', - margin: '0 auto', - padding: '20px', - fontSize: '0.85rem', - }, - header: { - marginBottom: '20px', - fontSize: '2rem', - }, - formLabel: { - fontSize: '0.85rem', - fontWeight: 'bold', - marginBottom: '10px', - }, - formControl: { - fontSize: '0.85rem', - borderColor: 'black', - borderRadius: '3px', - }, - submitButton: { - backgroundColor: 'transparent', - borderColor: '#000', - borderStyle: 'solid', - borderRadius: '0px', - color: '#000', - fontSize: '0.85rem', - padding: '2px 5px', - marginTop: '20px', - }, - starRating: { - cursor: 'pointer', - fontSize: '1.5rem', // Larger font size for better clickability and visibility - }, + const location = useLocation(); + const navigate = useNavigate(); + const { data: assignmentResponse, sendRequest: fetchAssignment, isLoading: assignmentLoading } = useAPI(); + const { data: itemsResponse, sendRequest: fetchItems, isLoading: itemsLoading } = useAPI(); + const auth = useSelector((state: RootState) => state.authentication); + const currentUser = auth.user; + + const query = useMemo(() => new URLSearchParams(location.search), [location.search]); + const assignmentId = Number(query.get('assignment_id')); + const questionnaireIdFromUrl = Number(query.get('questionnaire_id')); + const questionnaireTypeFromUrl = query.get('questionnaire_type') as QuestionnaireType | null; + // E2619: URL param set by AssignedReviews when navigating here from the quiz gate; + // after quiz submission the page redirects here to the actual review. + const redirectAfter = query.get('redirect_after'); + // E2619: true when this page is being used for a quiz rather than a peer review. + const isQuizMode = questionnaireTypeFromUrl === 'Quiz'; + const questionnaireNameFromUrl = query.get('questionnaire_name'); + const teamName = query.get('team_name') || ''; + const revieweeTeamId = Number(query.get('reviewee_team_id')) || null; + const [mapId, setMapId] = useState(() => Number(query.get('map_id'))); + + // Reset all form state when the URL changes (e.g. after quiz redirect navigates to review) + useEffect(() => { + setMapId(Number(query.get('map_id'))); + setAnswers({}); + setComments({}); + setMultiSelections({}); + setBooleanSelections({}); + setFileSelections({}); + setIsSubmitting(false); + setIsSaving(false); + setSubmitError(null); + setSubmitSuccess(false); + setQuizScore(null); + setSaveMessage(null); + setDraftResponseId(null); + setDraftIsSubmitted(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.search]); + + const [answers, setAnswers] = useState>({}); + const [comments, setComments] = useState>({}); + const [multiSelections, setMultiSelections] = useState>({}); + const [booleanSelections, setBooleanSelections] = useState>({}); + const [fileSelections, setFileSelections] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [submitSuccess, setSubmitSuccess] = useState(false); + // E2619: stores the total_score returned by the backend after quiz submission. + const [quizScore, setQuizScore] = useState(null); + const [saveMessage, setSaveMessage] = useState(null); + const [draftResponseId, setDraftResponseId] = useState(null); + const [draftIsSubmitted, setDraftIsSubmitted] = useState(false); + const [draftLoading, setDraftLoading] = useState(false); + + useEffect(() => { + if (assignmentId) { + fetchAssignment({ url: `/assignments/${assignmentId}`, method: 'GET' }); + } + }, [assignmentId, fetchAssignment]); + + const questionnaires = useMemo(() => { + const assignmentData = assignmentResponse?.data; + return Array.isArray(assignmentData?.questionnaires) ? assignmentData.questionnaires : []; + }, [assignmentResponse?.data]); + + const resolvedQuestionnaire = useMemo(() => { + // E2619: exclude quiz-type questionnaires when resolving the review questionnaire + // so that the quiz questionnaire is never accidentally used for the peer review form. + const isQuizQ = (q: any) => /^quiz/i.test(String(q?.questionnaire_type || '')); + if (questionnaireIdFromUrl && !isQuizMode) { + // In review mode, ignore quiz questionnaires even if the id matches. + const byId = questionnaires.find((q: any) => Number(q.id) === questionnaireIdFromUrl && !isQuizQ(q)); + if (byId) return byId; + } + if (isQuizMode) { + // E2619: quiz questionnaires are team-owned and NOT in the assignment's + // assignment_questionnaires list. Never fall through to the review questionnaire + // in quiz mode — that would load review items instead of quiz items. + if (questionnaireIdFromUrl) { + const byId = questionnaires.find((q: any) => Number(q.id) === questionnaireIdFromUrl); + if (byId) return byId; + } + return null; + } + const teammateQ = questionnaires.find((q: any) => isTeammateQuestionnaire(q)); + const normalQ = questionnaires.find((q: any) => !isTeammateQuestionnaire(q) && !isQuizQ(q)); + if (questionnaireTypeFromUrl === 'Teammate Review') return teammateQ || normalQ; + if (questionnaireTypeFromUrl === 'Review') return normalQ || teammateQ; + return normalQ || teammateQ; + }, [questionnaires, questionnaireIdFromUrl, questionnaireTypeFromUrl, isQuizMode]); + + const resolvedQuestionnaireType: QuestionnaireType = useMemo(() => { + if (isQuizMode) return 'Quiz'; + if (resolvedQuestionnaire) return isTeammateQuestionnaire(resolvedQuestionnaire) ? 'Teammate Review' : 'Review'; + if (questionnaireTypeFromUrl === 'Teammate Review') return 'Teammate Review'; + return 'Review'; + }, [resolvedQuestionnaire, questionnaireTypeFromUrl, isQuizMode]); + + const resolvedQuestionnaireName = resolvedQuestionnaire?.name + || questionnaireNameFromUrl + || `${resolvedQuestionnaireType} Questionnaire`; + + // Fetch questionnaire items once we know the questionnaire id. + // E2619: in quiz mode always use questionnaireIdFromUrl directly — the quiz questionnaire + // is team-owned and not in the assignment's questionnaire list, so resolvedQuestionnaire + // will be null for a quiz. Using resolvedQuestionnaire?.id here would cause items to be + // fetched for the wrong (review) questionnaire once the assignment data loads. + useEffect(() => { + const qId = isQuizMode ? questionnaireIdFromUrl : (resolvedQuestionnaire?.id || questionnaireIdFromUrl); + if (qId) { + fetchItems({ url: `/questionnaires/${qId}/items`, method: 'GET' }); + } + }, [isQuizMode, resolvedQuestionnaire?.id, questionnaireIdFromUrl, fetchItems]); + + const items: any[] = useMemo(() => { + const data = itemsResponse?.data; + if (Array.isArray(data)) return data; + if (Array.isArray(data?.items)) return data.items; + return []; + }, [itemsResponse]); + + // Load existing draft response on mount + useEffect(() => { + if (!mapId) return; + let cancelled = false; + setDraftLoading(true); + axiosClient.get('/responses', { params: { map_id: mapId } }) + .then((res) => { + if (cancelled) return; + const resp = res.data?.response; + if (resp) { + setDraftResponseId(resp.id); + setDraftIsSubmitted(!!resp.is_submitted); + if (resp.is_submitted) { + setSubmitSuccess(true); + } + // Populate form fields from saved answers + const savedScores: any[] = resp.scores || []; + const newAnswers: Record = {}; + const newComments: Record = {}; + const newMulti: Record = {}; + const newBool: Record = {}; + savedScores.forEach((score: any) => { + const key = String(score.item_id); + if (score.answer != null) { + newAnswers[key] = String(score.answer); + } + if (score.comments) { + // For Checkbox with multi-selections stored as pipe-delimited + newComments[key] = score.comments; + } + }); + setAnswers(prev => ({ ...prev, ...newAnswers })); + setComments(prev => ({ ...prev, ...newComments })); + // Restore multi-selections and booleans from saved data once items are available + } + }) + .catch(() => { /* no draft found — that's fine */ }) + .finally(() => { if (!cancelled) setDraftLoading(false); }); + return () => { cancelled = true; }; + }, [mapId]); + + // Once items are loaded and we have saved comments, restore form state from draft + useEffect(() => { + if (items.length === 0 || !draftResponseId) return; + const newMulti: Record = {}; + const newBool: Record = {}; + const answersFromComments: Record = {}; + items.forEach((item: any) => { + const itemId = String(item.id ?? ''); + const itemType = normalizeItemType(item); + const savedComment = comments[itemId]; + if (itemType === 'Checkbox') { + const options = parseAlternatives(item); + if (options.length > 0 && savedComment) { + newMulti[itemId] = savedComment.split('|').filter(Boolean); + } else if (options.length === 0) { + newBool[itemId] = (answers[itemId] === '1'); + } + } + // TextField, TextArea, Dropdown, MultipleChoice store text in comments — restore to answers state + if (['TextField', 'TextArea', 'Dropdown', 'MultipleChoice', 'Unknown'].includes(itemType)) { + if (savedComment && !answers[itemId]) { + answersFromComments[itemId] = savedComment; + } + } + }); + if (Object.keys(newMulti).length > 0) setMultiSelections(prev => ({ ...prev, ...newMulti })); + if (Object.keys(newBool).length > 0) setBooleanSelections(prev => ({ ...prev, ...newBool })); + if (Object.keys(answersFromComments).length > 0) setAnswers(prev => ({ ...prev, ...answersFromComments })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, draftResponseId]); + + /** + * Builds the scores_attributes payload to be sent to PATCH /responses/:id. + * + * Each scorable item is mapped to an object containing: + * - `item_id` – the numeric ID of the questionnaire item + * - `answer` – a numeric value (Criterion/Scale), 0/1 (Checkbox), or null + * (text-based types where the value lives in `comments`) + * - `comments` – the student's free-text input or selected option label; + * for quiz text/radio/checkbox items this column is what + * the backend scores against `item.correct_answer` + * + * Header-type items (SectionHeader, TableHeader, ColumnHeader) are excluded. + * + * @returns An array of score attribute objects ready for the API payload. + */ + const buildScoresAttributes = () => { + const headerTypes: NormalizedItemType[] = ['SectionHeader', 'TableHeader', 'ColumnHeader']; + return items + .filter((item: any) => !headerTypes.includes(normalizeItemType(item))) + .map((item: any) => { + const itemId = String(item.id ?? ''); + const itemType = normalizeItemType(item); + let answerValue: number | null = null; + let commentsValue: string = ''; + + switch (itemType) { + case 'Criterion': + case 'Scale': + answerValue = answers[itemId] ? Number(answers[itemId]) : null; + commentsValue = comments[itemId] ?? ''; + break; + case 'TextArea': + case 'TextField': + case 'Unknown': + commentsValue = answers[itemId] ?? ''; + break; + case 'Dropdown': + case 'MultipleChoice': + commentsValue = answers[itemId] ?? ''; + break; + case 'Checkbox': + if ((parseAlternatives(item)).length > 0) { + const selections = multiSelections[itemId] ?? []; + commentsValue = selections.join('|'); + answerValue = selections.length > 0 ? 1 : 0; + } else { + answerValue = booleanSelections[itemId] ? 1 : 0; + } + break; + case 'Grid': + commentsValue = answers[itemId] ?? ''; + break; + case 'UploadFile': + commentsValue = fileSelections[itemId]?.name ?? ''; + break; + } + + return { + item_id: Number(item.id), + answer: answerValue, + comments: commentsValue, + }; + }); + }; + + /** + * Ensures a Response record exists for the current response map and returns its ID. + * + * If a draft has already been created this session (`draftResponseId` is set), + * it is reused immediately without any API calls. + * + * On first call the function attempts to create a new Response via + * POST /responses. If the backend returns 404 (the ResponseMap row does not + * yet exist), it falls back to creating the ResponseMap on-the-fly via + * POST /response_maps and then retries the Response creation. + * + * @returns A promise that resolves to the numeric Response ID. + * @throws An error if any API call fails and the fallback cannot recover. + */ + const ensureResponseRecord = async (): Promise => { + if (draftResponseId) return draftResponseId; + + let effectiveMapId = mapId; + + try { + const createRes = await axiosClient.post('/responses', { map_id: effectiveMapId }); + const resp = createRes.data?.response; + const id = resp?.id; + if (!id) throw new Error('Failed to create response record'); + setDraftResponseId(id); + return id; + } catch (err: any) { + // Workaround: if the ResponseMap row doesn't exist yet in the DB (404), + // create it on-the-fly using the current user, assignment, and reviewee team, + // then retry creating the Response against the newly created map. + if (err?.response?.status === 404 && assignmentId && currentUser?.id && revieweeTeamId) { + const mapRes = await axiosClient.post('/response_maps', { + assignment_id: assignmentId, + reviewer_user_id: currentUser.id, + reviewee_team_id: revieweeTeamId, + }); + const realMapId = mapRes.data?.id; + if (!realMapId) throw new Error('Failed to create response map'); + effectiveMapId = realMapId; + setMapId(effectiveMapId); + + // Now create the response with the real map id + const createRes2 = await axiosClient.post('/responses', { map_id: effectiveMapId }); + const resp2 = createRes2.data?.response; + const id2 = resp2?.id; + if (!id2) throw new Error('Failed to create response record'); + setDraftResponseId(id2); + return id2; + } + throw err; + } + }; + + /** + * Persists the current answers as a draft without locking the response. + * + * Calls {@link ensureResponseRecord} to obtain (or create) a Response record, + * then PATCHes the scores via {@link buildScoresAttributes}. The response + * remains unlocked (`is_submitted: false`) so the student can return and + * edit their answers later. + * + * Sets `saveMessage` on success or `submitError` on failure. + */ + const handleSaveDraft = async () => { + if (!mapId) { + setSubmitError('No response map ID found.'); + return; + } + setIsSaving(true); + setSubmitError(null); + setSaveMessage(null); + + try { + const responseId = await ensureResponseRecord(); + const scoresAttributes = buildScoresAttributes(); + await axiosClient.patch(`/responses/${responseId}`, { + response: { scores_attributes: scoresAttributes }, + }); + setSaveMessage('Draft saved successfully.'); + } catch (err: any) { + const msg = err?.response?.data?.error || err?.message || 'Save failed'; + setSubmitError(msg); + } finally { + setIsSaving(false); + } }; - // Handles form submission - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - // Redirect to the view page after submitting the review + /** + * Saves the current answers and locks the response for final submission. + * + * Workflow: + * 1. Calls {@link ensureResponseRecord} to obtain (or create) a Response record. + * 2. PATCHes the answers via {@link buildScoresAttributes}. + * 3. Calls PATCH /responses/:id/submit to lock the response and trigger + * server-side scoring. + * + * In quiz mode the `total_score` returned by the backend is stored in + * `quizScore` and displayed to the student before they choose to proceed. + * No automatic redirect is performed — the student must click the + * "Proceed to Review" button that appears in the success banner. + * + * Sets `submitSuccess` and `draftIsSubmitted` on success, or + * `submitError` on failure. + */ + const handleSubmitReview = async () => { + if (!mapId) { + setSubmitError('No response map ID found. Please navigate here from Student Tasks.'); + return; + } + setIsSubmitting(true); + setSubmitError(null); + setSaveMessage(null); + + try { + const responseId = await ensureResponseRecord(); + + // Save answers + const scoresAttributes = buildScoresAttributes(); + await axiosClient.patch(`/responses/${responseId}`, { + response: { scores_attributes: scoresAttributes }, + }); + + // Submit (lock + score) + const submitRes = await axiosClient.patch(`/responses/${responseId}/submit`); + // E2619: capture the quiz score returned by aggregate_questionnaire_score so it + // can be shown in the success banner before the redirect fires. + if (isQuizMode) { + setQuizScore(submitRes.data?.total_score ?? null); + } + + setSubmitSuccess(true); + setDraftIsSubmitted(true); + } catch (err: any) { + const msg = err?.response?.data?.error || err?.message || 'Submission failed'; + setSubmitError(msg); + } finally { + setIsSubmitting(false); + } }; + if (!assignmentId) { + return ( +
+ + Missing required review context. Please open this page from Student Tasks. + +
+ ); + } + + const resolvedQuestionnaireId = resolvedQuestionnaire?.id || questionnaireIdFromUrl; + + const isLoading = assignmentLoading || (resolvedQuestionnaireId ? itemsLoading : false); + return ( -
-

Teammate Review for Final Project

-
- This is a placeholder page and is still in progress. +
+

{resolvedQuestionnaireName}

+
+
+ + {resolvedQuestionnaireType} + +
+ {teamName &&
Reviewing: {teamName}
} +
Assignment #{assignmentId}
-
- - How many times was this person late to meetings? -
- {/* Render stars for late count selection */} - {[...Array(5)].map((_, i) => ( - setLateCount(i + 1)}> - {i < lateCount ? '★' : '☆'} - - ))} + + {isLoading && ( +
+ + Loading questionnaire... +
+ )} + + {!isLoading && items.length === 0 && ( + + No questionnaire items found for this review type. The assignment may not have a{' '} + {resolvedQuestionnaireType} questionnaire configured yet. + + )} + + {draftLoading && ( +
+ Loading saved draft... +
+ )} + + {draftIsSubmitted && ( + + This {isQuizMode ? 'quiz' : 'review'} has already been submitted. The form is read-only. + + )} + + {submitSuccess && isQuizMode && ( + +
+ Quiz submitted! +
+
+ Your score: {quizScore !== null ? quizScore : '\u2014'} +
+ {redirectAfter && ( + + )} +
+ )} + + {items.length > 0 && ( +
+ + {items.map((item: any, idx: number) => { + const itemId = String(item.id ?? idx); + const itemType = normalizeItemType(item); + const itemText = item.txt || item.question || item.description || `Item #${itemId}`; + const options = parseAlternatives(item); + const { min, max } = getScoreBounds(item, resolvedQuestionnaire); + const scoreOptions = Array.from({ length: Math.max(0, max - min + 1) }, (_, offset) => String(min + offset)); + + if (itemType === 'SectionHeader' || itemType === 'TableHeader' || itemType === 'ColumnHeader') { + return ( +
+ {item.txt ?
{item.txt}
: null} + {itemType === 'SectionHeader' &&
} +
+ ); + } + + return ( +
+ {/* Item header row: label on the left, weight on the right */} +
+ {/* Hide the label for single-checkbox items — the name is on the checkbox itself */} + {!(itemType === 'Checkbox' && options.length === 0) && ( + + {itemText} + {itemType === 'TextField' && ( + setAnswers(prev => ({ ...prev, [itemId]: e.target.value }))} + style={{ fontSize: 13, ...(item.textbox_width ? { width: item.textbox_width } : { flex: 1 }) }} + /> + )} + + )} + {item.weight && ( + + Weight: {item.weight} + + )} +
+ + {(itemType === 'Criterion' || itemType === 'Scale') && ( + setAnswers(prev => ({ ...prev, [itemId]: e.target.value }))} + style={{ maxWidth: 180, fontSize: 13 }} + > + + {scoreOptions.map((scoreValue) => ( + + ))} + + )} + + {itemType === 'Criterion' && ( + setComments(prev => ({ ...prev, [itemId]: e.target.value }))} + style={{ fontSize: 13, ...(item.textarea_width ? { width: item.textarea_width } : {}) }} + /> + )} + + {(itemType === 'TextArea' || itemType === 'Unknown') && ( + setAnswers(prev => ({ ...prev, [itemId]: e.target.value }))} + style={{ fontSize: 13, ...(item.textarea_width ? { width: item.textarea_width } : {}) }} + /> + )} + + {itemType === 'TextField' && null} + + {itemType === 'Dropdown' && options.length > 0 && ( + setAnswers(prev => ({ ...prev, [itemId]: e.target.value }))} + style={{ fontSize: 13 }} + > + + {options.map((option) => ( + + ))} + + )} + + {itemType === 'MultipleChoice' && options.length > 0 && ( +
+ {options.map((option) => ( + setAnswers(prev => ({ ...prev, [itemId]: option }))} + /> + ))} +
+ )} + + {(itemType === 'Dropdown' || itemType === 'MultipleChoice') && options.length === 0 && ( + + No options provided for this item. + + )} + + {itemType === 'Checkbox' && options.length > 0 && ( +
+ {options.map((option) => { + const selected = multiSelections[itemId] ?? []; + const isChecked = selected.includes(option); + return ( + {option}} + checked={isChecked} + onChange={(e) => { + setMultiSelections(prev => { + const existing = prev[itemId] ?? []; + const updated = e.target.checked + ? [...existing, option] + : existing.filter((entry) => entry !== option); + return { ...prev, [itemId]: updated }; + }); + }} + /> + ); + })} +
+ )} + + {itemType === 'Checkbox' && options.length === 0 && ( + {itemText}} + checked={booleanSelections[itemId] ?? false} + onChange={(e) => setBooleanSelections(prev => ({ ...prev, [itemId]: e.target.checked }))} + /> + )} + + {itemType === 'Grid' && (() => { + const cols = parseGridNames(item.col_names) || []; + const columnHeaders = cols.length > 0 ? cols : options; + const rows = parseGridNames(item.row_names); + const rowLabels = rows.length > 0 ? rows : ['']; + const currentValue = answers[itemId] ?? ''; + const rowSelections = currentValue ? currentValue.split('|') : rowLabels.map(() => ''); + + return ( +
+ + + {rows.length > 0 && } + {columnHeaders.map((col) => ( + + ))} + + + + {rowLabels.map((rowLabel, rowIdx) => ( + + {rows.length > 0 && } + {columnHeaders.map((col) => ( + + ))} + + ))} + +
{col}
{rowLabel} + {}} + onClick={() => { + const updated = [...rowSelections]; + while (updated.length <= rowIdx) updated.push(''); + updated[rowIdx] = updated[rowIdx] === col ? '' : col; + setAnswers(prev => ({ ...prev, [itemId]: updated.join('|') })); + }} + /> +
+ ); + })()} + + {itemType === 'UploadFile' && ( +
+ { + const selectedFile = e.target.files && e.target.files.length > 0 ? e.target.files[0] : null; + setFileSelections(prev => ({ ...prev, [itemId]: selectedFile })); + }} + /> + {fileSelections[itemId] && ( + + Selected: {fileSelections[itemId]?.name} + + )} +
+ )} +
+ ); + })} + + {submitError && ( + {submitError} + )} + {saveMessage && !submitError && ( + {saveMessage} + )} + {submitSuccess && !isQuizMode && ( + + Review submitted successfully! + + )} + {draftResponseId && !submitSuccess && !draftIsSubmitted && ( +
+ Draft #{draftResponseId} — you can save and come back later. +
+ )} + +
+ + +
- - - Comments - setComments(e.currentTarget.value)} - style={styles.formControl} - /> - - - + {!mapId && ( +
+ Missing map_id — cannot submit without a valid response map. +
+ )} + + + )} ); }; diff --git a/src/pages/StudentTasks/AssignedReviews.tsx b/src/pages/StudentTasks/AssignedReviews.tsx new file mode 100644 index 00000000..cde330dc --- /dev/null +++ b/src/pages/StudentTasks/AssignedReviews.tsx @@ -0,0 +1,452 @@ +import React, { useEffect, useState, useCallback, useMemo } from "react"; +import { Container, Row, Col, Alert, Button, Nav } from "react-bootstrap"; +import { useNavigate, useParams, Link } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { RootState } from "../../store/store"; +import axiosClient from "../../utils/axios_client"; +import useAPI from "../../hooks/useAPI"; + +interface ReviewerPersistParticipant { + id: number; + user_id: number; + parent_id: number; + team_id?: number | null; +} + +interface ReviewerPersistResponseMap { + id: number; + reviewer_id: number; + reviewee_id: number; + reviewed_object_id: number; + reviewee_team_id?: number | null; +} + +interface ReviewerPersistResponse { + id: number; + map_id: number; + is_submitted: boolean | 0 | 1; + created_at?: string | null; + updated_at?: string | null; +} + +interface ReviewerPersistTeam { + id: number; + name: string; +} + +interface ReviewerPersist { + participants?: ReviewerPersistParticipant[]; + response_maps?: ReviewerPersistResponseMap[]; + responses?: ReviewerPersistResponse[]; + teams?: ReviewerPersistTeam[]; +} + +interface AssignedReviewRow { + mapId: number; + responseId?: number; + teamName: string; + revieweeTeamId?: number; + assignmentId?: number; + assignmentName?: string; + status: "Not saved" | "Saved" | "Submitted"; + questionnaireType: "Review" | "Teammate Review"; + questionnaireId?: number; + questionnaireName: string; + // per-team quiz state + quizQuestionnaireId?: number; + quizTaken: boolean; +} + +const AssignedReviews: React.FC = () => { + const navigate = useNavigate(); + const { assignmentId: assignmentIdParam } = useParams<{ assignmentId?: string }>(); + const { data: assignmentResponse, sendRequest: fetchAssignment } = useAPI(); + + const auth = useSelector((state: RootState) => state.authentication); + const currentUser = auth.user; + + const [assignedReviews, setAssignedReviews] = useState([]); + const [tasksByAssignment, setTasksByAssignment] = useState>({}) + + // Fetch require_quiz / quiz_taken per assignment + useEffect(() => { + axiosClient + .get('/student_tasks/list') + .then((res) => { + const tasks = Array.isArray(res.data) ? res.data : []; + const lookup: Record = {}; + tasks.forEach((task: any) => { + if (task.assignment_id != null) { + lookup[Number(task.assignment_id)] = { + require_quiz: Boolean(task.require_quiz), + quiz_taken: Boolean(task.quiz_taken), + has_quiz_questionnaire: Boolean(task.has_quiz_questionnaire), + quiz_questionnaire_id: task.quiz_questionnaire_id != null ? Number(task.quiz_questionnaire_id) : undefined, + }; + } + }); + setTasksByAssignment(lookup); + }) + .catch(() => {}); + }, []); + + // Assignment ID from URL param + const resolvedAssignmentId = useMemo(() => { + if (assignmentIdParam) return parseInt(assignmentIdParam, 10); + return undefined; + }, [assignmentIdParam]); + + useEffect(() => { + if (resolvedAssignmentId) { + fetchAssignment({ url: `/assignments/${resolvedAssignmentId}`, method: "GET" }); + } + }, [resolvedAssignmentId, fetchAssignment]); + + useEffect(() => { + if (!currentUser?.id) { + setAssignedReviews([]); + return; + } + + const assignmentData = Array.isArray(assignmentResponse?.data) + ? assignmentResponse?.data?.[0] + : assignmentResponse?.data; + + const questionnaires = Array.isArray(assignmentData?.questionnaires) + ? assignmentData.questionnaires + : []; + + const isTeammateQuestionnaire = (questionnaire: any) => { + const questionnaireType = String(questionnaire?.questionnaire_type || ""); + const questionnaireName = String(questionnaire?.name || ""); + return ( + /teammatereview/i.test(questionnaireType) || + /teammate\s*review/i.test(questionnaireType) || + /teammate\s*review/i.test(questionnaireName) + ); + }; + + const isQuizQuestionnaire = (questionnaire: any) => { + const questionnaireType = String(questionnaire?.questionnaire_type || ""); + return /^quiz/i.test(questionnaireType); + }; + + const teammateQuestionnaire = questionnaires.find((q: any) => isTeammateQuestionnaire(q)); + const normalReviewQuestionnaire = questionnaires.find( + (q: any) => !isTeammateQuestionnaire(q) && !isQuizQuestionnaire(q) + ); + + const buildRows = ( + maps: { + id: number; + reviewee_id: number; + reviewed_object_id?: number; + assignment_name?: string; + reviewee_team_id?: number | null; + team_name?: string; + latest_response?: any; + }[], + currentUserTeamId?: number + ): AssignedReviewRow[] => + maps.map((map) => { + const teamId = Number(map.reviewee_team_id ?? map.reviewee_id); + const teamName = map.team_name ?? `Team #${teamId}`; + const latestResponse = map.latest_response; + const isTeammateReview = + Boolean(currentUserTeamId) && Number(currentUserTeamId) === Number(teamId); + const mapAssignmentId = (map as any).reviewed_object_id + ? Number((map as any).reviewed_object_id) + : resolvedAssignmentId; + + const selectedQuestionnaire = isTeammateReview + ? teammateQuestionnaire ?? normalReviewQuestionnaire + : normalReviewQuestionnaire ?? teammateQuestionnaire; + + let status: AssignedReviewRow["status"] = "Not saved"; + if (latestResponse) { + const submitted = + typeof latestResponse.is_submitted === "boolean" + ? latestResponse.is_submitted + : Number(latestResponse.is_submitted) === 1; + status = submitted ? "Submitted" : "Saved"; + } + + return { + mapId: Number(map.id), + responseId: latestResponse ? Number(latestResponse.id) : undefined, + teamName, + revieweeTeamId: + (map as any)._revieweeTeamId ?? Number(map.reviewee_team_id ?? map.reviewee_id), + assignmentId: mapAssignmentId, + assignmentName: (map as any).assignment_name, + status, + questionnaireType: isTeammateReview ? "Teammate Review" : "Review", + questionnaireId: selectedQuestionnaire?.id ? Number(selectedQuestionnaire.id) : undefined, + questionnaireName: + selectedQuestionnaire?.name || + (isTeammateReview ? "Teammate Review Questionnaire" : "Review Questionnaire"), + // per-team quiz state from the API + quizQuestionnaireId: (map as any).quiz_questionnaire_id != null + ? Number((map as any).quiz_questionnaire_id) + : undefined, + quizTaken: Boolean((map as any).quiz_taken), + }; + }); + + // 1) Check localStorage (written by AssignReviewer) if scoped to one assignment + if (resolvedAssignmentId) { + try { + const raw = localStorage.getItem(`assignreviewer:${resolvedAssignmentId}`); + if (raw) { + const parsed = JSON.parse(raw) as ReviewerPersist; + const participants = Array.isArray(parsed.participants) ? parsed.participants : []; + const responseMaps = Array.isArray(parsed.response_maps) ? parsed.response_maps : []; + const responses = Array.isArray(parsed.responses) ? parsed.responses : []; + const teams = Array.isArray(parsed.teams) ? parsed.teams : []; + + const participantIds = new Set( + participants + .filter( + (p) => + Number(p.user_id) === Number(currentUser.id) && + Number(p.parent_id) === Number(resolvedAssignmentId) + ) + .map((p) => p.id) + ); + + const currentParticipant = participants.find( + (p) => + Number(p.user_id) === Number(currentUser.id) && + Number(p.parent_id) === Number(resolvedAssignmentId) + ); + const currentUserTeamId = currentParticipant?.team_id + ? Number(currentParticipant.team_id) + : undefined; + + if (participantIds.size > 0) { + const latestByMapId = new Map(); + responses.forEach((response) => { + const mapId = Number(response.map_id); + const timestamp = + new Date(response.updated_at ?? response.created_at ?? "").getTime() || 0; + const previous = latestByMapId.get(mapId); + const previousTs = previous + ? new Date(previous.updated_at ?? previous.created_at ?? "").getTime() || 0 + : -1; + if (!previous || timestamp > previousTs) latestByMapId.set(mapId, response); + }); + + const filtered = responseMaps.filter( + (m) => + Number(m.reviewed_object_id) === Number(resolvedAssignmentId) && + participantIds.has(Number(m.reviewer_id)) + ); + + const withResponse = filtered.map((m) => ({ + ...m, + team_name: teams.find( + (t) => Number(t.id) === Number(m.reviewee_team_id ?? m.reviewee_id) + )?.name, + latest_response: latestByMapId.get(Number(m.id)), + _revieweeTeamId: Number(m.reviewee_team_id ?? m.reviewee_id), + })); + + const rows = buildRows(withResponse, currentUserTeamId); + if (rows.length > 0) { + setAssignedReviews(rows); + } + } + } + } catch { + /* fall through to API */ + } + } + + // 2) Fetch all reviews from backend + let cancelled = false; + axiosClient + .get("/response_maps", { params: { reviewer_user_id: currentUser.id } }) + .then((res) => { + if (cancelled) return; + const maps = Array.isArray(res.data?.response_maps) ? res.data.response_maps : []; + const backendRows = buildRows(maps); + setAssignedReviews((prev) => { + // Backend rows take priority; keep any localStorage-only rows + const byMapId = new Map(); + prev.forEach((r) => byMapId.set(r.mapId, r)); + backendRows.forEach((r) => byMapId.set(r.mapId, r)); + return Array.from(byMapId.values()); + }); + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [currentUser?.id, assignmentResponse?.data]); + + // Opens the review form for a given review assignment + const openReview = useCallback( + (review: AssignedReviewRow) => { + const effectiveAssignmentId = review.assignmentId ?? resolvedAssignmentId; + const params = new URLSearchParams({ + assignment_id: String(effectiveAssignmentId), + map_id: String(review.mapId), + questionnaire_type: review.questionnaireType, + questionnaire_name: review.questionnaireName, + team_name: review.teamName, + }); + + if (review.questionnaireId) { + params.set("questionnaire_id", String(review.questionnaireId)); + } + if (review.responseId) { + params.set("response_id", String(review.responseId)); + } + if (review.revieweeTeamId) { + params.set("reviewee_team_id", String(review.revieweeTeamId)); + } + + navigate(`/response/new?${params.toString()}`); + }, + [navigate, resolvedAssignmentId] + ); + + const signUpPath = resolvedAssignmentId + ? `/student_tasks/${resolvedAssignmentId}` + : "/student_tasks"; + + return ( + + + + +

Assigned Reviews

+ +
+ + + + {assignedReviews.length === 0 ? ( + + No reviews currently assigned to you. + + ) : ( + + + + + + + + + + + + {assignedReviews.map((review) => { + const taskInfo = review.assignmentId != null + ? tasksByAssignment[review.assignmentId] + : undefined; + const requireQuiz = taskInfo?.require_quiz ?? false; + // Use per-row quiz state so each team's quiz is tracked separately + const hasQuizQuestionnaire = review.quizQuestionnaireId != null; + const quizCompleted = review.quizTaken; + const quizQuestionnaireId = review.quizQuestionnaireId; + + // Build the review URL so we can redirect back after the quiz + const buildReviewParams = () => { + const p = new URLSearchParams({ + assignment_id: String(review.assignmentId), + map_id: String(review.mapId), + questionnaire_type: review.questionnaireType, + questionnaire_name: review.questionnaireName, + team_name: review.teamName, + }); + if (review.questionnaireId) p.set('questionnaire_id', String(review.questionnaireId)); + if (review.responseId) p.set('response_id', String(review.responseId)); + if (review.revieweeTeamId) p.set('reviewee_team_id', String(review.revieweeTeamId)); + return `/response/new?${p.toString()}`; + }; + + const handleTakeQuiz = async () => { + if (!review.assignmentId || !currentUser?.id) return; + try { + const res = await axiosClient.post('/quiz_response_maps', { + assignment_id: review.assignmentId, + reviewer_user_id: currentUser.id, + // Required so backend creates the quiz map for the correct team + reviewee_team_id: review.revieweeTeamId, + }); + const quizMapId = res.data?.quiz_map_id; + const quizQId = res.data?.quiz_questionnaire_id ?? quizQuestionnaireId; + if (!quizMapId || !quizQId) return; + const reviewUrl = buildReviewParams(); + const quizParams = new URLSearchParams({ + assignment_id: String(review.assignmentId), + map_id: String(quizMapId), + questionnaire_id: String(quizQId), + questionnaire_type: 'Quiz', + questionnaire_name: 'Quiz', + team_name: review.teamName, + redirect_after: reviewUrl, + }); + navigate(`/response/new?${quizParams.toString()}`); + } catch { + // ignore — button stays visible + } + }; + return ( + + + + + + + + ); + })} + +
AssignmentTeamTypeStatusAction
{review.assignmentName || `Assignment #${review.assignmentId}`}{review.teamName}{review.questionnaireType} + {review.status} + + {requireQuiz && hasQuizQuestionnaire && !quizCompleted ? ( + + ) : ( + + )} +
+ )} + +
+
+ ); +}; + +export default AssignedReviews; diff --git a/src/pages/StudentTasks/StudentTasks.tsx b/src/pages/StudentTasks/StudentTasks.tsx index d1954d9a..4dfffc36 100644 --- a/src/pages/StudentTasks/StudentTasks.tsx +++ b/src/pages/StudentTasks/StudentTasks.tsx @@ -1,460 +1,688 @@ -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; +import React, { useEffect, useCallback, useMemo, useState } from "react"; +import { Container, Spinner, Alert, Row, Col, Button, Nav } from "react-bootstrap"; +import { useNavigate, useParams, Link } 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"; +import axiosClient from "../../utils/axios_client"; + +interface Topic { + id: string; + databaseId?: number; + name: string; + availableSlots: number; + waitlist: number; + isBookmarked?: boolean; + isSelected?: boolean; + isTaken?: boolean; + isWaitlisted?: boolean; +} + +interface ReviewerPersistParticipant { + id: number; + user_id: number; + parent_id: number; + team_id?: number | null; +} + +interface ReviewerPersistResponseMap { + id: number; + reviewer_id: number; + reviewee_id: number; + reviewed_object_id: number; + reviewee_team_id?: number | null; +} + +interface ReviewerPersistResponse { + id: number; + map_id: number; + is_submitted: boolean | 0 | 1; + created_at?: string | null; + updated_at?: string | null; +} + +interface ReviewerPersistTeam { + id: number; + name: string; +} + +interface ReviewerPersist { + participants?: ReviewerPersistParticipant[]; + response_maps?: ReviewerPersistResponseMap[]; + responses?: ReviewerPersistResponse[]; + teams?: ReviewerPersistTeam[]; +} + +interface AssignedReviewRow { + mapId: number; + responseId?: number; + teamName: string; + revieweeTeamId?: number; + assignmentId?: number; + assignmentName?: string; + status: "Not saved" | "Saved" | "Submitted"; + questionnaireType: "Review" | "Teammate Review"; + questionnaireId?: number; + questionnaireName: string; +} + +const StudentTasks: React.FC = () => { + const navigate = useNavigate(); + 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]); + + // Numeric assignment ID from URL param or API response + const resolvedAssignmentId = useMemo(() => { + if (assignmentId) return parseInt(assignmentId, 10); + if (!assignmentResponse?.data) return undefined; + if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { + return Number(assignmentResponse.data[0].id); + } + return Number(assignmentResponse.data.id); + }, [assignmentId, assignmentResponse]); + + const [assignedReviews, setAssignedReviews] = useState([]); + + useEffect(() => { + if (!currentUser?.id) { setAssignedReviews([]); return; } + + const assignmentData = Array.isArray(assignmentResponse?.data) + ? assignmentResponse?.data?.[0] + : assignmentResponse?.data; + + const questionnaires = Array.isArray(assignmentData?.questionnaires) ? assignmentData.questionnaires : []; + const isTeammateQuestionnaire = (questionnaire: any) => { + const questionnaireType = String(questionnaire?.questionnaire_type || ""); + const questionnaireName = String(questionnaire?.name || ""); + return ( + /teammatereview/i.test(questionnaireType) + || /teammate\s*review/i.test(questionnaireType) + || /teammate\s*review/i.test(questionnaireName) + ); + }; + + const teammateQuestionnaire = questionnaires.find((questionnaire: any) => isTeammateQuestionnaire(questionnaire)); + const normalReviewQuestionnaire = questionnaires.find((questionnaire: any) => !isTeammateQuestionnaire(questionnaire)); + + const buildRows = ( + maps: { id: number; reviewee_id: number; reviewed_object_id?: number; assignment_name?: string; reviewee_team_id?: number | null; team_name?: string; latest_response?: any }[], + currentUserTeamId?: number + ): AssignedReviewRow[] => + maps.map((map) => { + const teamId = Number(map.reviewee_team_id ?? map.reviewee_id); + const teamName = map.team_name ?? `Team #${teamId}`; + const latestResponse = map.latest_response; + const isTeammateReview = Boolean(currentUserTeamId) && Number(currentUserTeamId) === Number(teamId); + const mapAssignmentId = (map as any).reviewed_object_id ? Number((map as any).reviewed_object_id) : resolvedAssignmentId; + + const selectedQuestionnaire = isTeammateReview + ? (teammateQuestionnaire ?? normalReviewQuestionnaire) + : (normalReviewQuestionnaire ?? teammateQuestionnaire); + + let status: AssignedReviewRow["status"] = "Not saved"; + if (latestResponse) { + const submitted = typeof latestResponse.is_submitted === "boolean" + ? latestResponse.is_submitted + : Number(latestResponse.is_submitted) === 1; + status = submitted ? "Submitted" : "Saved"; + } + + return { + mapId: Number(map.id), + responseId: latestResponse ? Number(latestResponse.id) : undefined, + teamName, + revieweeTeamId: (map as any)._revieweeTeamId ?? Number(map.reviewee_team_id ?? map.reviewee_id), + assignmentId: mapAssignmentId, + assignmentName: (map as any).assignment_name, + status, + questionnaireType: isTeammateReview ? "Teammate Review" : "Review", + questionnaireId: selectedQuestionnaire?.id ? Number(selectedQuestionnaire.id) : undefined, + questionnaireName: selectedQuestionnaire?.name || (isTeammateReview ? "Teammate Review Questionnaire" : "Review Questionnaire") + }; + }); + + // 1) Check localStorage (written by AssignReviewer) if scoped to one assignment + if (resolvedAssignmentId) { + try { + const raw = localStorage.getItem(`assignreviewer:${resolvedAssignmentId}`); + if (raw) { + const parsed = JSON.parse(raw) as ReviewerPersist; + const participants = Array.isArray(parsed.participants) ? parsed.participants : []; + const responseMaps = Array.isArray(parsed.response_maps) ? parsed.response_maps : []; + const responses = Array.isArray(parsed.responses) ? parsed.responses : []; + const teams = Array.isArray(parsed.teams) ? parsed.teams : []; + + const participantIds = new Set( + participants + .filter((p) => Number(p.user_id) === Number(currentUser.id) && Number(p.parent_id) === Number(resolvedAssignmentId)) + .map((p) => p.id) + ); + + const currentParticipant = participants.find((p) => + Number(p.user_id) === Number(currentUser.id) && Number(p.parent_id) === Number(resolvedAssignmentId) + ); + const currentUserTeamId = currentParticipant?.team_id ? Number(currentParticipant.team_id) : undefined; + + if (participantIds.size > 0) { + const latestByMapId = new Map(); + responses.forEach((response) => { + const mapId = Number(response.map_id); + const timestamp = new Date(response.updated_at ?? response.created_at ?? "").getTime() || 0; + const previous = latestByMapId.get(mapId); + const previousTs = previous + ? (new Date(previous.updated_at ?? previous.created_at ?? "").getTime() || 0) + : -1; + if (!previous || timestamp > previousTs) latestByMapId.set(mapId, response); + }); + + const filtered = responseMaps + .filter((m) => Number(m.reviewed_object_id) === Number(resolvedAssignmentId) && participantIds.has(Number(m.reviewer_id))); + + const withResponse = filtered.map((m) => ({ + ...m, + team_name: teams.find((t) => Number(t.id) === Number(m.reviewee_team_id ?? m.reviewee_id))?.name, + latest_response: latestByMapId.get(Number(m.id)), + _revieweeTeamId: Number(m.reviewee_team_id ?? m.reviewee_id) + })); + + const rows = buildRows(withResponse, currentUserTeamId); + // Fall through to also fetch from backend + if (rows.length > 0) { setAssignedReviews(rows); } + } + } + } catch { /* fall through to API */ } + } + + // 2) Fetch all reviews from backend + let cancelled = false; + axiosClient + .get('/response_maps', { params: { reviewer_user_id: currentUser.id } }) + .then((res) => { + if (cancelled) return; + const maps = Array.isArray(res.data?.response_maps) ? res.data.response_maps : []; + const backendRows = buildRows(maps); + setAssignedReviews(prev => { + // Backend rows take priority; keep any localStorage-only rows + const byMapId = new Map(); + prev.forEach(r => byMapId.set(r.mapId, r)); + backendRows.forEach(r => byMapId.set(r.mapId, r)); + return Array.from(byMapId.values()); + }); + }) + .catch(() => {}); + + return () => { cancelled = true; }; + }, [currentUser?.id, assignmentResponse?.data]); + + const openReview = useCallback((review: AssignedReviewRow) => { + const effectiveAssignmentId = review.assignmentId ?? resolvedAssignmentId; + const params = new URLSearchParams({ + assignment_id: String(effectiveAssignmentId), + map_id: String(review.mapId), + questionnaire_type: review.questionnaireType, + questionnaire_name: review.questionnaireName, + team_name: review.teamName, + }); + + if (review.questionnaireId) { + params.set("questionnaire_id", String(review.questionnaireId)); + } + if (review.responseId) { + params.set("response_id", String(review.responseId)); + } + if (review.revieweeTeamId) { + params.set("reviewee_team_id", String(review.revieweeTeamId)); + } + + navigate(`/response/new?${params.toString()}`); + }, [navigate, resolvedAssignmentId]); + + // 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 + + const reviewsPath = assignmentId + ? `/student_tasks/${assignmentId}/reviews` + : "/student_tasks/reviews"; + + 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;