From 48bf0716ebe9f4d3a98f5f3df8b2ed48a8d6c0ea Mon Sep 17 00:00:00 2001 From: Yumo Shen Date: Thu, 16 Oct 2025 23:46:23 -0400 Subject: [PATCH 01/35] Implement View Submissions page, with mock data --- package-lock.json | 10 +- package.json | 2 +- src/App.tsx | 7 + src/hooks/useAPI.ts | 85 +++++++- src/layout/Header.tsx | 2 +- src/pages/Assignments/AssignmentUtil.ts | 28 ++- src/pages/Assignments/ViewSubmissions.tsx | 206 ++++++++++++++---- src/pages/Authentication/Login.tsx | 54 +++++ .../Submissions/SubmissionGradeModal.tsx | 32 +++ .../Submissions/SubmissionHistoryView.tsx | 51 +++++ src/pages/Submissions/SubmissionsView.tsx | 44 ++++ src/utils/mockStorage.ts | 153 +++++++++++++ 12 files changed, 616 insertions(+), 58 deletions(-) create mode 100644 src/pages/Submissions/SubmissionGradeModal.tsx create mode 100644 src/pages/Submissions/SubmissionHistoryView.tsx create mode 100644 src/pages/Submissions/SubmissionsView.tsx create mode 100644 src/utils/mockStorage.ts diff --git a/package-lock.json b/package-lock.json index 9594b10a..deb7633b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "@types/react-bootstrap": "^0.32.32", "@types/react-datepicker": "^4.10.0", "prettier": "^2.8.7", - "typescript": "^5.9.2" + "typescript": "4.9.5" } }, "node_modules/@adobe/css-tools": { @@ -18624,9 +18624,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", "bin": { @@ -18634,7 +18634,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { diff --git a/package.json b/package.json index 8ca6caed..940e72ca 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,6 @@ "@types/react-bootstrap": "^0.32.32", "@types/react-datepicker": "^4.10.0", "prettier": "^2.8.7", - "typescript": "^5.9.2" + "typescript": "4.9.5" } } diff --git a/src/App.tsx b/src/App.tsx index 27736ba3..2e4d5f0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,7 @@ import ViewSubmissions from "pages/Assignments/ViewSubmissions"; import ViewScores from "pages/Assignments/ViewScores"; import ViewReports from "pages/Assignments/ViewReports"; import ViewDelayedJobs from "pages/Assignments/ViewDelayedJobs"; +import SubmissionHistoryView from "./pages/Submissions/SubmissionHistoryView"; function App() { const router = createBrowserRouter([ { @@ -90,6 +91,10 @@ function App() { element: , loader: loadAssignment, }, + { + path: "submissions/history/:id", + element: , + }, { path: "assignments", element: } leastPrivilegeRole={ROLE.TA} />, @@ -138,6 +143,8 @@ function App() { }, ], }, + // Legacy route redirect: keep supporting old student_tasks path + { path: "student_tasks", element: }, { path: "profile", element: } />, diff --git a/src/hooks/useAPI.ts b/src/hooks/useAPI.ts index 47ba7ee5..644cabef 100644 --- a/src/hooks/useAPI.ts +++ b/src/hooks/useAPI.ts @@ -29,6 +29,87 @@ const useAPI = () => { setIsLoading(true); setError(""); + + // Development mock handlers: allow working without a backend + if (process.env.NODE_ENV === "development") { + const url = (requestConfig.url || "").toString(); + const method = (requestConfig.method || "get").toString().toLowerCase(); + + // Simple in-memory mock data + const mockAssignments = [ + { + id: 1, + name: "Mock Assignment", + directory_path: "mock/path", + spec_location: "", + private: false, + show_template_review: false, + require_quiz: false, + has_badge: false, + staggered_deadline: false, + is_calibrated: false, + course_id: 1, + }, + ]; + const mockCourses = [{ id: 1, name: "Mock Course" }]; + + const makeResponse = (data: any, status = 200) => { + const resp: AxiosResponse = { + data: data, + status: status, + statusText: status === 200 ? "OK" : "Created", + headers: {}, + config: requestConfig, + } as AxiosResponse; + return resp; + }; + + // Simulate network latency + setTimeout(() => { + try { + if (url === "/assignments" && method === "get") { + setData(makeResponse(mockAssignments)); + setIsLoading(false); + return; + } + + const assignmentIdMatch = url.match(/^\/assignments\/(\d+)/); + if (assignmentIdMatch && method === "get") { + const id = parseInt(assignmentIdMatch[1], 10); + const found = mockAssignments.find((a) => a.id === id) || mockAssignments[0]; + setData(makeResponse(found)); + setIsLoading(false); + return; + } + + if (url === "/assignments" && (method === "post" || method === "put")) { + // create or update - echo back created assignment with id + let payload: any = requestConfig.data || {}; + try { + if (typeof payload === "string") payload = JSON.parse(payload); + } catch (e) { + // ignore + } + const created = { id: Math.floor(Math.random() * 10000) + 2, ...payload }; + setData(makeResponse(created, 201)); + setIsLoading(false); + return; + } + + if (url === "/courses" && method === "get") { + setData(makeResponse(mockCourses)); + setIsLoading(false); + return; + } + + // Default: fall through to real network call if not matched + } catch (err) { + setError((err as Error).message || "Mock error"); + setIsLoading(false); + } + }, 200); + } + let errorMessage = ""; axios(requestConfig) @@ -51,8 +132,8 @@ const useAPI = () => { } if (errorMessage) setError(errorMessage); - }); - setIsLoading(false); + }) + .finally(() => setIsLoading(false)); }, []); return { data, setData, isLoading, error, sendRequest }; diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index 5b278dd8..3c03fbe4 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -143,7 +143,7 @@ const Header: React.FC = () => { )} - + Assignments diff --git a/src/pages/Assignments/AssignmentUtil.ts b/src/pages/Assignments/AssignmentUtil.ts index 0bb183da..3fb4e509 100644 --- a/src/pages/Assignments/AssignmentUtil.ts +++ b/src/pages/Assignments/AssignmentUtil.ts @@ -54,10 +54,30 @@ export async function loadAssignment({ params }: any) { let assignmentData = {}; // if params contains id, then we are editing a user, so we need to load the user data if (params.id) { - const userResponse = await axiosClient.get(`/assignments/${params.id}`, { - transformResponse: transformAssignmentResponse, - }); - assignmentData = await userResponse.data; + try { + const userResponse = await axiosClient.get(`/assignments/${params.id}`, { + transformResponse: transformAssignmentResponse, + }); + assignmentData = await userResponse.data; + } catch (err) { + // If backend is unavailable, and we're in development, return a mock assignment + if (process.env.NODE_ENV === "development") { + assignmentData = { + id: parseInt(params.id, 10) || 1, + name: "Mock Assignment", + directory_path: "mock/path", + spec_location: "", + private: false, + show_template_review: false, + require_quiz: false, + has_badge: false, + staggered_deadline: false, + is_calibrated: false, + }; + } else { + throw err; + } + } } return assignmentData; diff --git a/src/pages/Assignments/ViewSubmissions.tsx b/src/pages/Assignments/ViewSubmissions.tsx index d9fd69b1..d931fd5d 100644 --- a/src/pages/Assignments/ViewSubmissions.tsx +++ b/src/pages/Assignments/ViewSubmissions.tsx @@ -1,66 +1,189 @@ import React, { useMemo } from 'react'; -import { Button, Container, Row, Col } from 'react-bootstrap'; -// import { useNavigate } from 'react-router-dom'; +import { Container, Row, Col } from 'react-bootstrap'; import Table from "components/Table/Table"; import { createColumnHelper } from "@tanstack/react-table"; -import { useLoaderData } from 'react-router-dom'; +import { useLoaderData, useNavigate } from 'react-router-dom'; + + +interface ISubmissionMember { + id: number; + github: string; + full_name: string; +} + +interface ISubmissionLink { + url: string; + displayName: string; + name?: string; + size?: string; + type?: string; + modified?: string; +} interface ISubmission { id: number; - name: string; + teamName: string; + assignment: string; + members: ISubmissionMember[]; + links: ISubmissionLink[]; + fileInfo: Array<{ name: string; size: string; type?: string; modified?: string }>; } const columnHelper = createColumnHelper(); +const formatDate = (d: Date) => d.toLocaleString(); + const ViewSubmissions: React.FC = () => { const assignment: any = useLoaderData(); - // const navigate = useNavigate(); + const navigate = useNavigate(); - // Dummy data for submissions - const submissions = useMemo(() => [ - { id: 1, name: 'Submission 1' }, - { id: 2, name: 'Submission 2' }, - // ...other submissions - ], []); + // Create mock submissions (based on wiki example) + const submissions = useMemo(() => { + const baseDate = new Date(Date.parse('2021-12-01T00:12:00Z')); + return Array.from({ length: 23 }, (_, i) => { + const id = i + 1; + const teamNumber = 38121 + i; + const assignmentNumber = (i % 5) + 1; + const studentCount = (i % 3) + 1; + const currentDate = new Date(new Date(baseDate).setDate(baseDate.getDate() + i)); + + const members = Array.from({ length: studentCount }, (_, j) => ({ + full_name: `Student ${10000 + i * 10 + j}`, + github: `gh_user_${10000 + i * 10 + j}`, + id: 10000 + i * 10 + j, + })); + + const links = [ + { url: `https://github.com/example/repo${id}`, displayName: "GitHub Repository", name: `repo${id}`, size: '—', type: 'link', modified: formatDate(currentDate) }, + { url: `http://example.com/submission${id}`, displayName: "Submission Link", name: `submission${id}`, size: `${(Math.random() * 15 + 1).toFixed(1)} KB`, type: 'link', modified: formatDate(currentDate) }, + ]; + + const fileInfo = [ + { + name: `README.md`, + size: `${(Math.random() * 15 + 10).toFixed(1)} KB`, + type: 'file', + modified: formatDate(currentDate), + }, + ]; + + return { + id, + teamName: `Anonymized_Team_${teamNumber}`, + assignment: `Assignment ${assignmentNumber}`, + members, + links, + fileInfo, + } as ISubmission; + }); + }, []); const columns = useMemo(() => [ - columnHelper.accessor('name', { - header: () => 'Submission', - cell: info => info.getValue() + // Team Name column: team name + View Reviews / Assign grade + columnHelper.accessor('teamName', { + header: () => 'Team Name', + cell: info => { + const team = info.getValue() as string; + const row = info.row.original as ISubmission; + // Check assignment deadline: prefer assignment.due_date or assignment.deadline + const assignmentData: any = assignment || {}; + const deadlineStr = assignmentData.due_date || assignmentData.deadline || assignmentData.close_date; + let past = false; + if (deadlineStr) { + const d = new Date(deadlineStr); + past = !isNaN(d.getTime()) && d.getTime() < Date.now(); + } + return ( + + ); + } }), - columnHelper.display({ - id: 'actions', - header: () => 'Actions', - cell: ({ row }) => ( - + + // Team Members: each on its own line with github (Full Name) + columnHelper.accessor(row => row.members, { + id: 'members', + header: () => 'Team Member(s)', + cell: info => ( +
+ {info.getValue().map((m: ISubmissionMember, idx: number) => ( +
{m.github} ({m.full_name})
+ ))} +
) - }) - ], []); + }), - const handleActionClick = (submissionId: number) => { - console.log(`Action clicked for submission ID ${submissionId}`); - // Here goes the logic for handling the action - }; + // Links column: render sub-rows with Name, Size, Type, Modified + columnHelper.accessor(row => ({ links: row.links, files: row.fileInfo }), { + id: 'links', + header: () => 'Links', + cell: info => { + const val = info.getValue() as { links: ISubmissionLink[]; files: any[] }; + const rows: Array<{ name: any; size: any; type: any; modified: any; url?: string }> = [ + // map links first + ...val.links.map((l: any) => ({ name: l.name || l.displayName, size: l.size || '—', type: l.type || 'link', modified: l.modified || '—', url: l.url })), + // then files + ...val.files.map((f: any) => ({ name: f.name, size: f.size, type: f.type || 'file', modified: f.modified || '—', url: undefined })), + ]; + return ( +
+ + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + ))} + +
NameSizeTypeModified
{r.url ? {r.name} : r.name}{r.size}{r.type}{r.modified}
+
+ ); + } + }), - // const handleClose = () => { - // navigate(-1); // Go back to the previous page - // }; + // History column: link labeled 'History' + columnHelper.display({ + id: 'history', + header: () => 'History', + cell: ({ row }) => ( + + ) + }), + ], [assignment, navigate]); return ( - -
- This is a placeholder page and is still in progress. -
+ - -

View Submissions - {assignment.name}

+ +

View Submissions - {assignment?.name || 'Assignment'}

-
+ + + +
+ +
+ - + { /> - {/* - - - - */} ); }; diff --git a/src/pages/Authentication/Login.tsx b/src/pages/Authentication/Login.tsx index 2051b297..6b2a4d7e 100644 --- a/src/pages/Authentication/Login.tsx +++ b/src/pages/Authentication/Login.tsx @@ -5,6 +5,7 @@ import FormInput from "../../components/Form/FormInput"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useDispatch } from "react-redux"; import { authenticationActions } from "../../store/slices/authenticationSlice"; +import { ILoggedInUser, ROLE } from "../../utils/interfaces"; import { alertActions } from "../../store/slices/alertSlice"; import { setAuthToken } from "../../utils/auth"; import * as Yup from "yup"; @@ -54,6 +55,22 @@ const Login: React.FC = () => { submitProps.setSubmitting(false); }; + // Development helper: mock sign-in without backend + const handleMockSignIn = (user: ILoggedInUser) => { + // Set a fake token and expiration in localStorage so utils/getAuthToken behave + const mockToken = "MOCK_DEV_TOKEN"; + localStorage.setItem("token", mockToken); + localStorage.setItem("expiration", new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()); // 24h + + dispatch( + authenticationActions.setAuthentication({ + authToken: mockToken, + user: user, + }) + ); + navigate(location.state?.from ? location.state.from : "/"); + }; + return ( @@ -92,6 +109,43 @@ const Login: React.FC = () => { > Login + {process.env.NODE_ENV === "development" && ( +
+
Dev helpers
+ + + +
+ )} ); }} diff --git a/src/pages/Submissions/SubmissionGradeModal.tsx b/src/pages/Submissions/SubmissionGradeModal.tsx new file mode 100644 index 00000000..6d1a548e --- /dev/null +++ b/src/pages/Submissions/SubmissionGradeModal.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Modal, Button, Form } from 'react-bootstrap'; + +interface Props { + show: boolean; + onHide: () => void; + submissionId?: number; +} + +const SubmissionGradeModal: React.FC = ({ show, onHide, submissionId }) => { + return ( + + + Grade Submission {submissionId} + + +
+ + Grade + + + +
+ + + + +
+ ); +}; + +export default SubmissionGradeModal; diff --git a/src/pages/Submissions/SubmissionHistoryView.tsx b/src/pages/Submissions/SubmissionHistoryView.tsx new file mode 100644 index 00000000..18a4d646 --- /dev/null +++ b/src/pages/Submissions/SubmissionHistoryView.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react'; +import { Container, Row, Col } from 'react-bootstrap'; +import Table from 'components/Table/Table'; +import { createColumnHelper } from '@tanstack/react-table'; +import { useParams } from 'react-router-dom'; + +interface IHistoryRecord { + id: number; + fileName: string; + size: string; + date: string; +} + +const columnHelper = createColumnHelper(); + +const SubmissionHistoryView: React.FC = () => { + const { id } = useParams(); + + const records = useMemo(() => { + // generate mock history data + return Array.from({ length: 5 }, (_, i) => ({ + id: i + 1, + fileName: `file_${id}_${i + 1}.txt`, + size: `${(Math.random() * 10 + 1).toFixed(1)} KB`, + date: new Date(Date.now() - i * 1000 * 60 * 60 * 24).toLocaleString(), + })); + }, [id]); + + const columns = useMemo(() => [ + columnHelper.accessor('fileName', { header: () => 'File', cell: info => info.getValue() }), + columnHelper.accessor('size', { header: () => 'Size', cell: info => info.getValue() }), + columnHelper.accessor('date', { header: () => 'Date', cell: info => info.getValue() }), + ], []); + + return ( + + +
+

Submission History - ID {id}

+ + + + +
+ + + + ); +}; + +export default SubmissionHistoryView; \ No newline at end of file diff --git a/src/pages/Submissions/SubmissionsView.tsx b/src/pages/Submissions/SubmissionsView.tsx new file mode 100644 index 00000000..ea514a7e --- /dev/null +++ b/src/pages/Submissions/SubmissionsView.tsx @@ -0,0 +1,44 @@ +import React, { useMemo } from 'react'; +import { Container, Row, Col } from 'react-bootstrap'; +import Table from 'components/Table/Table'; +import { createColumnHelper } from '@tanstack/react-table'; +import { useNavigate } from 'react-router-dom'; + +const columnHelper = createColumnHelper(); + +const SubmissionsView: React.FC = () => { + const navigate = useNavigate(); + const submissions = useMemo(() => { + if (process.env.NODE_ENV === 'development') { + const mock = require('../../utils/mockStorage').default; + return mock.getAllSubmissions(); + } + return []; + }, []); + + const columns = useMemo(() => [ + columnHelper.accessor('teamName', { header: () => 'Team', cell: info => info.getValue() }), + columnHelper.accessor('assignment', { header: () => 'Assignment', cell: info => info.getValue() }), + columnHelper.accessor(row => row.members, { id: 'members', header: () => 'Members', cell: info => info.getValue().map((m: any) => m.name).join(', ') }), + columnHelper.display({ id: 'actions', header: () => 'Actions', cell: ({ row }) => ( +
+ +
+ ) }), + ], [navigate]); + + return ( + + +

Submissions

+ + + +
+ + + + ); +}; + +export default SubmissionsView; \ No newline at end of file diff --git a/src/utils/mockStorage.ts b/src/utils/mockStorage.ts new file mode 100644 index 00000000..f524edd8 --- /dev/null +++ b/src/utils/mockStorage.ts @@ -0,0 +1,153 @@ +/** + * Simple localStorage-backed mock data store for development. + * Provides assignments, submissions and submission history persistence. + */ + +import { IAssignmentResponse } from "./interfaces"; + +const ASSIGNMENTS_KEY = "mock:assignments"; +const SUBMISSIONS_KEY = "mock:submissions"; +const HISTORY_KEY = "mock:submissionHistory"; + +function read(key: string): T | null { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : null; +} + +function write(key: string, value: T) { + localStorage.setItem(key, JSON.stringify(value)); +} + +function ensureInitialized() { + if (!read(ASSIGNMENTS_KEY)) { + const defaultAssignments: IAssignmentResponse[] = [ + { + id: 1, + name: "Mock Assignment", + directory_path: "mock/path", + spec_location: "", + private: false, + show_template_review: false, + require_quiz: false, + has_badge: false, + staggered_deadline: false, + is_calibrated: false, + created_at: new Date(), + updated_at: new Date(), + course_id: 1, + courseName: "Mock Course", + institution_id: 1, + instructor_id: 1, + info: "", + } as any, + ]; + write(ASSIGNMENTS_KEY, defaultAssignments); + } + + if (!read(SUBMISSIONS_KEY)) { + // create a few dummy submissions for assignment 1 + const subs = Array.from({ length: 5 }, (_, i) => ({ + id: i + 1, + assignmentId: 1, + teamName: `Anonymized_Team_${38121 + i}`, + assignment: `Assignment ${(i % 5) + 1}`, + members: Array.from({ length: ((i % 3) + 1) }, (__, j) => ({ id: 10000 + i * 10 + j, name: `Student ${10000 + i * 10 + j}` })), + links: [ + { url: `https://github.com/example/repo${i + 1}`, displayName: "GitHub Repository" }, + ], + fileInfo: [{ name: `README.md`, size: `${(Math.random() * 15 + 10).toFixed(1)} KB`, dateModified: new Date().toISOString() }], + })); + write(SUBMISSIONS_KEY, subs); + } + + if (!read(HISTORY_KEY)) { + const history: Record = {}; + const subs = read(SUBMISSIONS_KEY) || []; + subs.forEach((s) => { + history[String(s.id)] = [ + { id: 1, name: `file_${s.id}_1.txt`, size: `${(Math.random() * 10 + 1).toFixed(1)} KB`, date: new Date().toISOString() }, + ]; + }); + write(HISTORY_KEY, history); + } +} + +export function getAssignments() { + ensureInitialized(); + return read(ASSIGNMENTS_KEY) || []; +} + +export function getAssignment(id: number) { + ensureInitialized(); + const arr = read(ASSIGNMENTS_KEY) || []; + return arr.find((a) => a.id === id) || null; +} + +export function createAssignment(payload: any) { + ensureInitialized(); + const arr = read(ASSIGNMENTS_KEY) || []; + const id = Math.max(0, ...arr.map((a) => a.id || 0)) + 1; + const created = { id, ...payload, created_at: new Date(), updated_at: new Date() }; + arr.push(created); + write(ASSIGNMENTS_KEY, arr); + // create an empty submissions list for this assignment (optional) + const subs = read(SUBMISSIONS_KEY) || []; + write(SUBMISSIONS_KEY, subs); + return created; +} + +export function getCourses() { + // minimal course mock + return [{ id: 1, name: "Mock Course" }]; +} + +export function getSubmissionsForAssignment(assignmentId: number) { + ensureInitialized(); + const subs = read(SUBMISSIONS_KEY) || []; + return subs.filter((s) => s.assignmentId === assignmentId); +} + +export function getAllSubmissions() { + ensureInitialized(); + return read(SUBMISSIONS_KEY) || []; +} + +export function createSubmission(assignmentId: number, payload: any) { + ensureInitialized(); + const subs = read(SUBMISSIONS_KEY) || []; + const id = Math.max(0, ...subs.map((s) => s.id || 0)) + 1; + const created = { id, assignmentId, ...payload }; + subs.push(created); + write(SUBMISSIONS_KEY, subs); + // add history + const history = read>(HISTORY_KEY) || {}; + history[String(id)] = history[String(id)] || []; + write(HISTORY_KEY, history); + return created; +} + +export function getHistoryForSubmission(submissionId: number) { + ensureInitialized(); + const history = read>(HISTORY_KEY) || {}; + return history[String(submissionId)] || []; +} + +export function addHistoryEntry(submissionId: number, entry: any) { + ensureInitialized(); + const history = read>(HISTORY_KEY) || {}; + history[String(submissionId)] = history[String(submissionId)] || []; + history[String(submissionId)].push(entry); + write(HISTORY_KEY, history); + return history[String(submissionId)]; +} + +export default { + getAssignments, + getAssignment, + createAssignment, + getCourses, + getSubmissionsForAssignment, + createSubmission, + getHistoryForSubmission, + addHistoryEntry, +}; From 551f390161942bfb597fa386ca3f72a43df95793 Mon Sep 17 00:00:00 2001 From: Yumo Shen Date: Thu, 23 Oct 2025 14:16:36 -0400 Subject: [PATCH 02/35] Add student name for submission history. --- .../Submissions/SubmissionHistoryView.tsx | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/pages/Submissions/SubmissionHistoryView.tsx b/src/pages/Submissions/SubmissionHistoryView.tsx index 18a4d646..74a666cc 100644 --- a/src/pages/Submissions/SubmissionHistoryView.tsx +++ b/src/pages/Submissions/SubmissionHistoryView.tsx @@ -9,6 +9,11 @@ interface IHistoryRecord { fileName: string; size: string; date: string; + teamName: string; + submitter: { + name: string; + id: number; + }; } const columnHelper = createColumnHelper(); @@ -17,31 +22,62 @@ const SubmissionHistoryView: React.FC = () => { const { id } = useParams(); const records = useMemo(() => { - // generate mock history data + // Mock team name - in real implementation this would come from API + const teamName = `Team_${id}`; + + // generate mock history data with team and submitter info return Array.from({ length: 5 }, (_, i) => ({ id: i + 1, + teamName: teamName, fileName: `file_${id}_${i + 1}.txt`, size: `${(Math.random() * 10 + 1).toFixed(1)} KB`, date: new Date(Date.now() - i * 1000 * 60 * 60 * 24).toLocaleString(), + submitter: { + name: `Student ${1000 + i}`, + id: 1000 + i + } })); }, [id]); const columns = useMemo(() => [ - columnHelper.accessor('fileName', { header: () => 'File', cell: info => info.getValue() }), - columnHelper.accessor('size', { header: () => 'Size', cell: info => info.getValue() }), - columnHelper.accessor('date', { header: () => 'Date', cell: info => info.getValue() }), + columnHelper.accessor('teamName', { + header: () => 'Team Name', + cell: info => info.getValue() + }), + columnHelper.accessor(row => row.submitter.name, { + id: 'submitter', + header: () => 'Submitted By', + cell: info => info.getValue() + }), + columnHelper.accessor('fileName', { + header: () => 'File', + cell: info => info.getValue() + }), + columnHelper.accessor('size', { + header: () => 'Size', + cell: info => info.getValue() + }), + columnHelper.accessor('date', { + header: () => 'Date', + cell: info => info.getValue() + }), ], []); return ( -

Submission History - ID {id}

+

Submission History

+

{records[0]?.teamName || 'Loading...'}

-
+
From 36018f4a26c0e133f61552c916f99372458c62db Mon Sep 17 00:00:00 2001 From: Yumo Shen Date: Tue, 28 Oct 2025 16:09:29 -0400 Subject: [PATCH 03/35] Modify the style for view submission page to match the requirement --- src/pages/Assignments/ViewSubmissions.css | 132 ++++++++++++++++++ src/pages/Assignments/ViewSubmissions.tsx | 40 +++--- .../__tests__/ViewSubmissions.test.tsx | 128 +++++++++++++++++ 3 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 src/pages/Assignments/ViewSubmissions.css create mode 100644 src/pages/Assignments/__tests__/ViewSubmissions.test.tsx diff --git a/src/pages/Assignments/ViewSubmissions.css b/src/pages/Assignments/ViewSubmissions.css new file mode 100644 index 00000000..b49da09b --- /dev/null +++ b/src/pages/Assignments/ViewSubmissions.css @@ -0,0 +1,132 @@ +.submission-container { + width: 100% !important; + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; +} + +/* Force table to take full width */ +.table-responsive, +div[class*="Table_container"], +div[class*="Table_table-container"], +table { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + padding: 0 !important; +} + +/* Set columns to use available space */ +.table td { + width: auto; +} + +/* Set specific column widths */ +.table td:first-child { + width: 15%; /* Team Name column */ +} +.table td:nth-child(2) { + width: 25%; /* Team Members column */ +} +.table td:nth-child(3) { + width: 50%; /* Links column */ +} +.table td:last-child { + width: 10%; /* History column */ +} + +.submission-link { + color: #e49b1f !important; + text-decoration: none; +} + +.submission-link:hover { + color: #e49b1f !important; + text-decoration: underline; +} + +.team-name { + color: #0d6efd; + font-weight: 500; +} + +/* Make all tables use full width */ +.table { + width: 100% !important; + max-width: none !important; + margin-bottom: 0; +} + +/* Add proper spacing to table cells and make headers bold */ +.table td { + padding: 1rem; +} + +.table th { + padding: 1rem; + font-weight: bold !important; +} + +/* Ensure nested tables use full width */ +.table td > table { + width: 100% !important; + margin: 0; +} + +/* Style for the links table inside cells */ +.table td > div { + width: 100% !important; +} + +.table td > div > table { + width: 100% !important; + margin: 0; + table-layout: fixed; +} + +/* Set widths for nested table columns */ +.table td > div > table th:nth-child(1), +.table td > div > table td:nth-child(1) { + width: 40%; +} +.table td > div > table th:nth-child(2), +.table td > div > table td:nth-child(2) { + width: 15%; +} +.table td > div > table th:nth-child(3), +.table td > div > table td:nth-child(3) { + width: 15%; +} +.table td > div > table th:nth-child(4), +.table td > div > table td:nth-child(4) { + width: 30%; +} + +/* Remove extra padding from nested tables and style headers */ +.table td > div > table td { + padding: 0.5rem; + background: none !important; + font-weight: normal; + border: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.table td > div > table th { + padding: 0.5rem; + background: none !important; + font-weight: bold !important; + border: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Ensure the table container takes full width */ +div[class*="Table_container"] { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + padding: 0 !important; +} \ No newline at end of file diff --git a/src/pages/Assignments/ViewSubmissions.tsx b/src/pages/Assignments/ViewSubmissions.tsx index d931fd5d..78bd4896 100644 --- a/src/pages/Assignments/ViewSubmissions.tsx +++ b/src/pages/Assignments/ViewSubmissions.tsx @@ -3,6 +3,7 @@ import { Container, Row, Col } from 'react-bootstrap'; import Table from "components/Table/Table"; import { createColumnHelper } from "@tanstack/react-table"; import { useLoaderData, useNavigate } from 'react-router-dom'; +import './ViewSubmissions.css'; interface ISubmissionMember { @@ -95,11 +96,11 @@ const ViewSubmissions: React.FC = () => { } return ( ); @@ -113,7 +114,10 @@ const ViewSubmissions: React.FC = () => { cell: info => (
{info.getValue().map((m: ISubmissionMember, idx: number) => ( -
{m.github} ({m.full_name})
+
+ + ({m.full_name}) +
))}
) @@ -139,13 +143,13 @@ const ViewSubmissions: React.FC = () => {
- + {rows.map((r, i) => ( - + @@ -163,13 +167,13 @@ const ViewSubmissions: React.FC = () => { id: 'history', header: () => 'History', cell: ({ row }) => ( - + ) }), ], [assignment, navigate]); return ( - +

View Submissions - {assignment?.name || 'Assignment'}

@@ -183,14 +187,18 @@ const ViewSubmissions: React.FC = () => { - -
Name Size TypeModifiedDate Modified
{r.url ? {r.name} : r.name}{r.url ? {r.name} : r.name} {r.size} {r.type} {r.modified}
+ +
+
+ diff --git a/src/pages/Assignments/__tests__/ViewSubmissions.test.tsx b/src/pages/Assignments/__tests__/ViewSubmissions.test.tsx new file mode 100644 index 00000000..b5073f69 --- /dev/null +++ b/src/pages/Assignments/__tests__/ViewSubmissions.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import ViewSubmissions from '../ViewSubmissions'; +import '@testing-library/jest-dom'; + +// Mock the useLoaderData hook +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLoaderData: () => ({ + id: 1, + name: 'Test Assignment', + due_date: '2025-12-01T00:00:00Z' + }), + useNavigate: () => jest.fn() +})); + +describe('ViewSubmissions Component', () => { + const renderComponent = () => { + return render( + + + } /> + + + ); + }; + + it('renders the component with correct title', () => { + renderComponent(); + expect(screen.getByText('View Submissions - Test Assignment')).toBeInTheDocument(); + }); + + it('displays table headers correctly', () => { + renderComponent(); + const headers = screen.getAllByRole('columnheader'); + expect(headers[0]).toHaveTextContent('Team Name'); + expect(headers[1]).toHaveTextContent('Team Member(s)'); + expect(headers[2]).toHaveTextContent('Links'); + expect(headers[3]).toHaveTextContent('History'); + }); + + it('displays submission data correctly', () => { + renderComponent(); + // Check for the first team name + const teamNameElement = screen.getByText('Anonymized_Team_38121'); + expect(teamNameElement).toBeInTheDocument(); + expect(teamNameElement).toHaveClass('team-name'); + + // Check for links in the nested table + const cells = screen.getAllByRole('cell'); + const linksCell = cells.find(cell => cell.textContent?.includes('repo1')); + expect(linksCell).toBeInTheDocument(); + }); + + it('displays nested table headers in Links column', () => { + renderComponent(); + // Find the cells containing the links table (it's in the third column) + const rows = screen.getAllByRole('row'); + const firstDataRow = rows[1]; // First row after header + const cells = within(firstDataRow).getAllByRole('cell'); + const linksCell = cells[2]; // Third column contains links + + // Check the headers within the nested table + const nestedHeaders = within(linksCell).getAllByRole('columnheader'); + expect(nestedHeaders).toHaveLength(4); + expect(nestedHeaders[0]).toHaveTextContent('Name'); + expect(nestedHeaders[1]).toHaveTextContent('Size'); + expect(nestedHeaders[2]).toHaveTextContent('Type'); + expect(nestedHeaders[3]).toHaveTextContent('Modified'); + }); + + it('shows correct button text based on deadline', () => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + + const mockAssignment = { + id: 1, + name: 'Test Assignment', + due_date: futureDate.toISOString() + }; + + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLoaderData: () => mockAssignment, + useNavigate: () => jest.fn() + })); + + renderComponent(); + const reviewButtons = screen.getAllByRole('button', { name: 'View Reviews' }); + expect(reviewButtons.length).toBeGreaterThan(0); + }); + + it('renders team members correctly', () => { + renderComponent(); + const buttons = screen.getAllByRole('button'); + const memberButton = buttons.find(button => button.textContent === 'gh_user_10000'); + expect(memberButton).toBeInTheDocument(); + + const cells = screen.getAllByRole('cell'); + const memberCell = cells.find(cell => cell.textContent?.includes('Student 10000')); + expect(memberCell).toBeInTheDocument(); + }); + + it('has correct link styling', () => { + renderComponent(); + const buttons = screen.getAllByRole('button'); + const memberLinks = buttons.filter(button => button.textContent?.startsWith('gh_user_')); + expect(memberLinks.length).toBeGreaterThan(0); + expect(memberLinks[0]).toHaveClass('submission-link'); + }); + + it('renders history button for each submission', () => { + renderComponent(); + const historyButtons = screen.getAllByRole('button', { name: 'History' }); + expect(historyButtons.length).toBeGreaterThan(0); + expect(historyButtons[0]).toHaveClass('submission-link'); + }); + + it('applies correct table sizing', () => { + renderComponent(); + const tables = screen.getAllByRole('table'); + expect(tables.length).toBeGreaterThan(0); + + const tableContainer = screen.getByTestId('submission-table-container'); + expect(tableContainer).toHaveStyle({ width: '100%' }); + }); +}); \ No newline at end of file From ea085111275280a5d3f182441851308e92763a98 Mon Sep 17 00:00:00 2001 From: Spencer Kersey Date: Tue, 28 Oct 2025 20:55:36 -0400 Subject: [PATCH 04/35] Added AssignGrades Page --- src/App.tsx | 6 + src/pages/Assignments/AssignGrades.tsx | 359 ++++++++++++++++++++++ src/pages/Assignments/ViewSubmissions.tsx | 31 +- src/pages/Assignments/assignments.scss | 163 ++++++++++ 4 files changed, 552 insertions(+), 7 deletions(-) create mode 100644 src/pages/Assignments/AssignGrades.tsx create mode 100644 src/pages/Assignments/assignments.scss diff --git a/src/App.tsx b/src/App.tsx index 2e4d5f0a..20c4f818 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import Email_the_author from "./pages/Email_the_author/email_the_author"; import CreateTeams from "pages/Assignments/CreateTeams"; import AssignReviewer from "pages/Assignments/AssignReviewer"; import ViewSubmissions from "pages/Assignments/ViewSubmissions"; +import AssignGrades, { assignGradesLoader } from "pages/Assignments/AssignGrades"; import ViewScores from "pages/Assignments/ViewScores"; import ViewReports from "pages/Assignments/ViewReports"; import ViewDelayedJobs from "pages/Assignments/ViewDelayedJobs"; @@ -76,6 +77,11 @@ function App() { element: , loader: loadAssignment, }, + { + path: "/assignments/:assignmentId/assign-grades", + element: , + loader: assignGradesLoader + }, { path: "assignments/edit/:id/viewscores", element: , diff --git a/src/pages/Assignments/AssignGrades.tsx b/src/pages/Assignments/AssignGrades.tsx new file mode 100644 index 00000000..f9de8fc3 --- /dev/null +++ b/src/pages/Assignments/AssignGrades.tsx @@ -0,0 +1,359 @@ +import React, { useMemo, useState } from "react"; +import { Container, Row, Col, Button, Alert } from "react-bootstrap"; +import { useLoaderData, useNavigate, useSearchParams } from "react-router-dom"; +import { calculateAverages, getColorClass } from "../ViewTeamGrades/utils"; +import "./assignments.scss"; + +// ---------- types ---------- +type Reviewer = { id: number; name: string }; +type RubricRow = { questionNo: number; scores: Record }; +type LinkItem = { name: string; url: string }; + +type LoaderData = { + assignment: { id: number; name: string }; + team: { id: number; name: string }; + reviewers: Reviewer[]; + rubric: RubricRow[]; + links: LinkItem[]; + existing?: { grade?: number; comment?: string }; +}; + +// ---------- mock loader data for now ---------- +const USE_MOCK = true; +function makeMock(assignmentId: number, teamId: number): LoaderData { + const reviewers: Reviewer[] = [ + { id: 201, name: "Srinidhi Shivakumarasa" }, + { id: 202, name: "Aryel" }, + ]; + // Include each base score 0..5 but with random decimals. + const clamp = (x: number, lo = 0, hi = 5) => Math.max(lo, Math.min(hi, x)); + const jitter = (base: number) => { + const r = Math.random(); // [0, 1) + if (base === 0) return clamp(base + r); + if (base === 5) return clamp(base - r); + const sign = Math.random() < 0.5 ? -1 : 1; + return clamp(base + sign * r); // wiggle within [0,5] + }; + const cycle = [0, 1, 2, 3, 4, 5]; + const rubric: RubricRow[] = Array.from({ length: 12 }, (_, i) => { + const v = cycle[i % cycle.length]; + const s1 = jitter(v); + const s2 = jitter(5 - v); + return { + questionNo: i + 1, + scores: { 201: s1, 202: s2 }, + }; + }); + return { + assignment: { id: assignmentId, name: "Program 1" }, + team: { id: teamId, name: "Ash, Srinidhi Team" }, + reviewers, + rubric, + links: [ + { name: "Submission ZIP", url: "https://example.com/submission.zip" }, + { name: "GitHub repo", url: "https://github.com/example/repo" }, + ], + existing: {}, + }; +} + +export async function assignGradesLoader({ params, request }: any): Promise { + const url = new URL(request.url); + const teamId = Number(url.searchParams.get("team_id") || 0); + const assignmentId = Number(params.assignmentId || 0); + if (USE_MOCK) return makeMock(assignmentId, teamId); + + const res = await fetch(`/api/v1/assignments/${assignmentId}/teams/${teamId}/summary`); + if (!res.ok) throw new Error("Failed to load"); + return (await res.json()) as LoaderData; +} + +// ---------- helpers ---------- +type RowSort = "none" | "asc" | "desc"; + +const AssignGrades: React.FC = () => { + const data = useLoaderData() as LoaderData; + const navigate = useNavigate(); + const [search] = useSearchParams(); + + const [showSubmission, setShowSubmission] = useState(false); + const [grade, setGrade] = useState((data.existing?.grade ?? "").toString()); + const [comment, setComment] = useState(data.existing?.comment ?? ""); + const [saving, setSaving] = useState(false); + const [savedMsg, setSavedMsg] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + // Mirror ReviewTable behavior: optional "Question" column + sort by Avg + const [showToggleQuestion, setShowToggleQuestion] = useState(false); + const [sortOrderRow, setSortOrderRow] = useState("none"); + // dummy UI state for the two checkboxes + const [gt10Words, setGt10Words] = useState(false); + const [gt20Words, setGt20Words] = useState(false); + const toggleSortOrderRow = () => { + setSortOrderRow(prev => + prev === "asc" ? "desc" : prev === "desc" ? "none" : "asc" + ); + }; + + // Use the same data shape & helpers as ReviewTable + const MAX_PER_REVIEW = 5; + const currentRoundData = useMemo(() => { + if (!Array.isArray(data.rubric) || !Array.isArray(data.reviewers)) return []; + return data.rubric.map((r) => ({ + questionNo: r.questionNo, + maxScore: MAX_PER_REVIEW, + reviews: data.reviewers.map((rv) => ({ score: Number(r.scores[rv.id] ?? 0) })), + })); + }, [data.rubric, data.reviewers]); + + const { averagePeerReviewScore, columnAverages, sortedData } = useMemo(() => { + const clone = currentRoundData.map(row => ({ ...row, reviews: row.reviews.map(x => ({ ...x })) })); + return calculateAverages(clone as any, sortOrderRow); + }, [currentRoundData, sortOrderRow]); + + return ( + + + +

Summary Report for assignment: {data.assignment.name}

+ + + + + +
Team: {data.team.name}
+
There are no reviews for this assignment
+ + + + + {showSubmission && ( + + +
    + {data.links.map((l, i) => ( +
  • + {l.name} +
  • + ))} +
+ + + )} + + {savedMsg && ( + setSavedMsg(null)}> + {savedMsg} + + )} + {errorMsg && ( + setErrorMsg(null)}> + {errorMsg} + + )} + + + + {/* Heading + inline legend/toggles to match screenshot */} + +
+ + +
+ + {/* Recreated table with the same structure/CSS as ReviewTable */} +
+

Teammate Review

+
+ + {/* white header row (no gray background) */} + + + {showToggleQuestion && ( + + )} + {data.reviewers.map((r) => ( + + ))} + + + + + {sortedData.map((row: any) => ( + + + {showToggleQuestion && ( + + )} + {row.reviews.map((rv: any, i: number) => ( + + ))} + + + ))} + + + {showToggleQuestion && } + {columnAverages.map((avg: number, index: number) => ( + + ))} + + +
+ Question + + Question + + {r.name} + + Avg{" "} + {sortOrderRow === "none" ? ▲▼ : sortOrderRow === "asc" ? : } +
+ {row.questionNo} + + {/* No question text in our loader shape; leave blank / plug in if available */} + + {Number(rv.score ?? 0).toFixed(2)} + + {Number(row.RowAvg ?? 0).toFixed(2)} +
+ Avg + + {avg.toFixed(2)} +
+
+ +
+ + +
+ + {/* Grade & Comments */} + + +

Grade and comment for submission

+
{ + e.preventDefault(); + const g = grade === "" ? NaN : Number(grade); + if (Number.isNaN(g) || g < 0 || g > 100) { + setErrorMsg("Grade must be a number between 0 and 100."); + return; + } + setSaving(true); + (async () => { + try { + if (USE_MOCK) await new Promise(r => setTimeout(r, 350)); + setSavedMsg("Saved!"); + } catch (err: any) { + setErrorMsg(err?.message || "Failed to save"); + } finally { + setSaving(false); + } + })(); + }}> +
+ + setGrade(e.target.value)} + placeholder="Grade" + /> +
+
+ +