diff --git a/src/App.tsx b/src/App.tsx index 27736ba3..83b6f354 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,45 +1,46 @@ -import React from "react"; +import RootLayout from "layout/Root"; +import { loadAssignment } from "pages/Assignments/AssignmentUtil"; +import AssignReviewer from "pages/Assignments/AssignReviewer"; +import CreateTeams from "pages/Assignments/CreateTeams"; +import ViewDelayedJobs from "pages/Assignments/ViewDelayedJobs"; +import ViewReports from "pages/Assignments/ViewReports"; +import ViewScores from "pages/Assignments/ViewScores"; +import Courses from "pages/Courses/Course"; +import CourseEditor from "pages/Courses/CourseEditor"; +import { loadCourseInstructorDataAndInstitutions } from "pages/Courses/CourseUtil"; +import Questionnaire from "pages/EditQuestionnaire/Questionnaire"; +import Home from "pages/Home"; +import Participants from "pages/Participants/Participant"; +import ParticipantEditor from "pages/Participants/ParticipantEditor"; +import { loadParticipantDataRolesAndInstitutions } from "pages/Participants/participantUtil"; +import EditProfile from "pages/Profile/Edit"; +import Reviews from "pages/Reviews/reviews"; +import SubmissionsView from "pages/Submissions/SubmissionsView"; +import SubmissionView from "pages/Submissions/SubmissionView"; +import SubmissionHistoryView from "./pages/Submissions/SubmissionHistoryView"; +import TA from "pages/TA/TA"; +import TAEditor from "pages/TA/TAEditor"; +import { loadTAs } from "pages/TA/TAUtil"; import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom"; import AdministratorLayout from "./layout/Administrator"; import ManageUserTypes, { loader as loadUsers } from "./pages/Administrator/ManageUserTypes"; +import Assignment from "./pages/Assignments/Assignment"; +import AssignmentEditor from "./pages/Assignments/AssignmentEditor"; import Login from "./pages/Authentication/Login"; import Logout from "./pages/Authentication/Logout"; +import Email_the_author from "./pages/Email_the_author/email_the_author"; import InstitutionEditor, { loadInstitution } from "./pages/Institutions/InstitutionEditor"; import Institutions, { loadInstitutions } from "./pages/Institutions/Institutions"; import RoleEditor, { loadAvailableRole } from "./pages/Roles/RoleEditor"; import Roles, { loadRoles } from "./pages/Roles/Roles"; -import Assignment from "./pages/Assignments/Assignment"; -import AssignmentEditor from "./pages/Assignments/AssignmentEditor"; -import { loadAssignment } from "pages/Assignments/AssignmentUtil"; -import ErrorPage from "./router/ErrorPage"; -import ProtectedRoute from "./router/ProtectedRoute"; -import { ROLE } from "./utils/interfaces"; -import NotFound from "./router/NotFound"; -import Participants from "pages/Participants/Participant"; -import ParticipantEditor from "pages/Participants/ParticipantEditor"; -import { loadParticipantDataRolesAndInstitutions } from "pages/Participants/participantUtil"; -import RootLayout from "layout/Root"; -import UserEditor from "./pages/Users/UserEditor"; import Users from "./pages/Users/User"; +import UserEditor from "./pages/Users/UserEditor"; import { loadUserDataRolesAndInstitutions } from "./pages/Users/userUtil"; -import Home from "pages/Home"; -import Questionnaire from "pages/EditQuestionnaire/Questionnaire"; -import Courses from "pages/Courses/Course"; -import CourseEditor from "pages/Courses/CourseEditor"; -import { loadCourseInstructorDataAndInstitutions } from "pages/Courses/CourseUtil"; -import TA from "pages/TA/TA"; -import TAEditor from "pages/TA/TAEditor"; -import { loadTAs } from "pages/TA/TAUtil"; import ReviewTable from "./pages/ViewTeamGrades/ReviewTable"; -import EditProfile from "pages/Profile/Edit"; -import Reviews from "pages/Reviews/reviews"; -import Email_the_author from "./pages/Email_the_author/email_the_author"; -import CreateTeams from "pages/Assignments/CreateTeams"; -import AssignReviewer from "pages/Assignments/AssignReviewer"; -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 ErrorPage from "./router/ErrorPage"; +import NotFound from "./router/NotFound"; +import ProtectedRoute from "./router/ProtectedRoute"; +import { ROLE } from "./utils/interfaces"; function App() { const router = createBrowserRouter([ { @@ -72,9 +73,13 @@ function App() { }, { path: "assignments/edit/:id/viewsubmissions", - element: , + element: , loader: loadAssignment, }, + { + path: "submissions/history/:submissionId", + element: } leastPrivilegeRole={ROLE.TA} />, + }, { path: "assignments/edit/:id/viewscores", element: , @@ -122,6 +127,10 @@ function App() { }, ], }, + { + path: "student_tasks", + element: } leastPrivilegeRole={ROLE.TA} />, + }, { path: "student_tasks/participants", element: , diff --git a/src/pages/Submissions/SubmissionHistoryView.test.tsx b/src/pages/Submissions/SubmissionHistoryView.test.tsx new file mode 100644 index 00000000..e85b8f21 --- /dev/null +++ b/src/pages/Submissions/SubmissionHistoryView.test.tsx @@ -0,0 +1,72 @@ +import '@testing-library/jest-dom'; +import { render, screen, within } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import SubmissionHistoryView from './SubmissionHistoryView'; + +const mockUseParams = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => mockUseParams() +})); + +const renderWithRouter = (component: React.ReactNode) => { + return render( + + {component} + + ); +}; + +describe('SubmissionHistoryView', () => { + beforeEach(() => { + mockUseParams.mockReset(); + mockUseParams.mockReturnValue({ submissionId: '1' }); + }); + + // Check if Submission ID is correct + test('receives correct submission ID from URL parameters', () => { + mockUseParams.mockReturnValue({ submissionId: '1' }); + renderWithRouter(); + expect(mockUseParams).toHaveBeenCalled(); + const { submissionId } = mockUseParams(); + expect(submissionId).toBe('1'); + }); + + test('renders submission record title', () => { + renderWithRouter(); + expect(screen.getByText('Submission Record')).toBeInTheDocument(); + }); + + // Check if table renders properly + test('renders table headers', () => { + renderWithRouter(); + + expect(screen.getByText('Team Id')).toBeInTheDocument(); + expect(screen.getByText('Operation')).toBeInTheDocument(); + expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('Content')).toBeInTheDocument(); + expect(screen.getByText('Created')).toBeInTheDocument(); + }); + + // Check if data is displayed correctly + test('displays data correctly', () => { + renderWithRouter(); + + const rows = screen.getAllByRole('row').slice(2); + const firstRow = rows[0]; + const cells = within(firstRow).getAllByRole('cell'); + + expect(cells[0]).toHaveTextContent('12345'); + expect(cells[1]).toHaveTextContent('Submit Hyperlink'); + expect(cells[2]).toHaveTextContent('Test_User'); + expect(cells[3]).toHaveTextContent('https://github.ncsu.edu/masonhorne/reimplementation-front-end'); + expect(cells[4]).toHaveTextContent('2024-09-17 22:38:09'); + }); + + // Check if rows are displayed correctly + test('renders correct number of rows', () => { + renderWithRouter(); + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(7); + }); +}); \ No newline at end of file diff --git a/src/pages/Submissions/SubmissionHistoryView.tsx b/src/pages/Submissions/SubmissionHistoryView.tsx new file mode 100644 index 00000000..f42764d2 --- /dev/null +++ b/src/pages/Submissions/SubmissionHistoryView.tsx @@ -0,0 +1,136 @@ +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { useEffect, useState } from 'react'; +import { Container } from 'react-bootstrap'; +import { useParams } from 'react-router-dom'; + +interface HistoryEntry { + teamId: number; + operation: string; + user: string; + content: string; + created: string; +} + +const SubmissionHistoryView = () => { + const [history, setHistory] = useState([]); + + // Does nothing at the moment but a real implementation would likely + // retrieve submission history data via the submission ID + const { submissionId } = useParams(); + + const columnHelper = createColumnHelper(); + + const columns = [ + columnHelper.accessor('teamId', { + header: 'Team Id', + cell: info => info.getValue(), + }), + columnHelper.accessor('operation', { + header: 'Operation', + cell: info => info.getValue(), + }), + columnHelper.accessor('user', { + header: 'User', + cell: info => info.getValue(), + }), + columnHelper.accessor('content', { + header: 'Content', + cell: info => info.getValue(), + }), + columnHelper.accessor('created', { + header: 'Created', + cell: info => info.getValue(), + }), + ]; + + // Load data, dummy data for now + useEffect(() => { + const dummyData: HistoryEntry[] = [ + { + teamId: 12345, + operation: 'Submit Hyperlink', + user: 'Test_User', + content: 'https://github.ncsu.edu/masonhorne/reimplementation-front-end', + created: '2024-09-17 22:38:09' + }, + { + teamId: 12345, + operation: 'Submit Hyperlink', + user: 'Test_User', + content: 'http://152.7.176.240:8080/', + created: '2024-09-27 18:32:10' + }, + { + teamId: 12345, + operation: 'Submit File', + user: 'Test_User', + content: 'README.md', + created: '2024-09-29 17:52:24' + }, + { + teamId: 12345, + operation: 'Remove File', + user: 'Test_User', + content: 'README.md', + created: '2024-10-03 23:36:03' + }, + { + teamId: 12345, + operation: 'Submit File', + user: 'Test_User', + content: 'README_4_.md', + created: '2024-10-03 23:36:57' + } + ]; + setHistory(dummyData); + }, [submissionId]); + + const table = useReactTable({ + data: history, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + +
+ + + + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ))} + +
+

Submission History

+
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ ); +}; + +export default SubmissionHistoryView; \ No newline at end of file diff --git a/src/pages/Submissions/SubmissionTable/SubmissionEntry.test.tsx b/src/pages/Submissions/SubmissionTable/SubmissionEntry.test.tsx new file mode 100644 index 00000000..41e2790b --- /dev/null +++ b/src/pages/Submissions/SubmissionTable/SubmissionEntry.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import SubmissionList from './SubmissionList'; + +const mockSubmissions = [ + { + id: 1, + teamName: 'Anonymized_Team_38121', + assignment: 'Assignment 1', + members: [{ name: 'Student 1', id: 1 }], + links: [], + fileInfo: [], + }, +]; + +const mockOnGradeClick = jest.fn(); + +describe('SubmissionEntry', () => { + it('displays the correct team name', () => { + render( + + + + ); + + // Check if team name is rendered correctly + expect(screen.getByText('Anonymized_Team_38121')).toBeTruthy(); + }); + + it('calls onGradeClick when the grade button is clicked', () => { + render( + + + + ); + + // Simulate the button click + const button = screen.getByRole('button', { name: /Assign Grade/i }); + fireEvent.click(button); + + expect(mockOnGradeClick).toHaveBeenCalledWith(mockSubmissions[0].id); + }); +}); diff --git a/src/pages/Submissions/SubmissionTable/SubmissionEntry.tsx b/src/pages/Submissions/SubmissionTable/SubmissionEntry.tsx new file mode 100644 index 00000000..6f4b9fc8 --- /dev/null +++ b/src/pages/Submissions/SubmissionTable/SubmissionEntry.tsx @@ -0,0 +1,111 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { Button } from "react-bootstrap"; +import { Link } from "react-router-dom"; + +interface ISubmissionEntry { + id: number; + teamName: string; + assignment: string; + members: { name: string; id: number }[]; + links: { url: string; displayName: string }[]; + fileInfo: { name: string; size: string; dateModified: string }[]; +} + +const columnHelper = createColumnHelper(); + +const SubmissionEntry = ({ onGradeClick }: { onGradeClick: (id: number) => void }) => { + const columns = [ + // Team Name column: Sorting enabled, search disabled + columnHelper.accessor('teamName', { + header: ({ column }) => ( +
+ Team Name + { + !column.getIsSorted() && + } +
+ ), + cell: (info) => ( + <> +
{info.getValue()}
+ + + ), + size: 25, + enableSorting: true, + enableColumnFilter: false, + enableGlobalFilter: false, + }), + + // Team Members column: No search, no sorting + columnHelper.accessor('members', { + header: () => 'Team Members', + cell: (info) => + info.getValue().map((member) => ( +
+ {/* This can be used to link to the users profile once the profile component exists */} + {/* */} + {member.name} (Student {member.id}) + {/* */} +
+ )), + size: 35, + enableSorting: false, + enableColumnFilter: false, + enableGlobalFilter: false, + }), + // Links and File Info column: No search, no sorting + columnHelper.accessor(row => ({ links: row.links, fileInfo: row.fileInfo }), { + id: 'links', + header: () => 'Links', + cell: (info) => ( +
+ {info.getValue().links.map((link, idx) => ( + + ))} +
+
+
+
Name
+
Size
+
Date Modified
+
+ {info.getValue().fileInfo.map((file, idx) => ( +
+
{file.name}
+
{file.size}
+
{file.dateModified}
+
+ ))} +
+
+ ), + size: 40, + enableSorting: false, + enableColumnFilter: false, + enableGlobalFilter: false, + }), + // History column: Links to history pages (No search or sorting) + columnHelper.display({ + id: 'history', + header: () => 'History', + cell: (info) => ( + History + ), + enableSorting: false, + enableColumnFilter: false, + enableGlobalFilter: false, + }), + ]; + + return columns; +}; + +export default SubmissionEntry; diff --git a/src/pages/Submissions/SubmissionTable/SubmissionList.test.tsx b/src/pages/Submissions/SubmissionTable/SubmissionList.test.tsx new file mode 100644 index 00000000..5f6ab171 --- /dev/null +++ b/src/pages/Submissions/SubmissionTable/SubmissionList.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import SubmissionList from './SubmissionList'; + +const mockSubmissions = [ + { + id: 1, + teamName: 'Team B', + assignment: 'Assignment 1', + members: [{ name: 'Student 1', id: 1 }], + links: [], + fileInfo: [], + }, + { + id: 2, + teamName: 'Team A', + assignment: 'Assignment 1', + members: [{ name: 'Student 2', id: 2 }], + links: [], + fileInfo: [], + }, +]; + +const mockOnGradeClick = jest.fn(); + +describe('SubmissionList', () => { + it('renders submission entries correctly', () => { + render( + + + + ); + + // Check if submission entry is rendered + expect(screen.getByText('Team B')).toBeTruthy(); + expect(screen.getByText('Team A')).toBeTruthy(); + }); + + it('sorts the submissions by team name', () => { + render( + + + + ); + + // Click the team name header to sort ascending + const teamNameHeader = screen.getByText('Team Name'); + fireEvent.click(teamNameHeader); + + // Get the rows that contain submission entries + const rows = screen.getAllByRole('row'); + + // Check the order of the first two submission rows (excluding the header) + expect(rows[1].innerHTML).toContain('Team A'); + expect(rows[2].innerHTML).toContain('Team B'); + + // Click again to sort descending + fireEvent.click(teamNameHeader); + + // Get the rows again after sorting + const sortedRows = screen.getAllByRole('row'); + + // Check the order of the first two submission rows (excluding the header) + expect(sortedRows[1].innerHTML).toContain('Team B'); + expect(sortedRows[2].innerHTML).toContain('Team A'); + }); + +}); diff --git a/src/pages/Submissions/SubmissionTable/SubmissionList.tsx b/src/pages/Submissions/SubmissionTable/SubmissionList.tsx new file mode 100644 index 00000000..f0a65f0f --- /dev/null +++ b/src/pages/Submissions/SubmissionTable/SubmissionList.tsx @@ -0,0 +1,22 @@ +import Table from "components/Table/Table"; +import { useMemo } from "react"; +import SubmissionEntry from "./SubmissionEntry"; + +const SubmissionList = ({ submissions, onGradeClick }: { submissions: any[], onGradeClick: (id: number) => void }) => { + + const columns = useMemo(() => SubmissionEntry({ onGradeClick }), [onGradeClick]); + + return ( +
+ + + ); +}; + +export default SubmissionList; diff --git a/src/pages/Assignments/ViewSubmissions.tsx b/src/pages/Submissions/SubmissionView.tsx similarity index 84% rename from src/pages/Assignments/ViewSubmissions.tsx rename to src/pages/Submissions/SubmissionView.tsx index d9fd69b1..4e8f14cc 100644 --- a/src/pages/Assignments/ViewSubmissions.tsx +++ b/src/pages/Submissions/SubmissionView.tsx @@ -1,8 +1,8 @@ import React, { useMemo } from 'react'; -import { Button, Container, Row, Col } from 'react-bootstrap'; +import { Button, Col, Container, Row } from 'react-bootstrap'; // import { useNavigate } from 'react-router-dom'; -import Table from "components/Table/Table"; import { createColumnHelper } from "@tanstack/react-table"; +import Table from "components/Table/Table"; import { useLoaderData } from 'react-router-dom'; interface ISubmission { @@ -12,7 +12,7 @@ interface ISubmission { const columnHelper = createColumnHelper(); -const ViewSubmissions: React.FC = () => { +const SubmissionView: React.FC = () => { const assignment: any = useLoaderData(); // const navigate = useNavigate(); @@ -70,15 +70,8 @@ const ViewSubmissions: React.FC = () => { /> - {/* - - - - */} ); }; -export default ViewSubmissions; \ No newline at end of file +export default SubmissionView; \ No newline at end of file diff --git a/src/pages/Submissions/SubmissionsView.test.tsx b/src/pages/Submissions/SubmissionsView.test.tsx new file mode 100644 index 00000000..d0df159b --- /dev/null +++ b/src/pages/Submissions/SubmissionsView.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import SubmissionView from './SubmissionsView'; + +describe('SubmissionsView', () => { + it('renders the title and filter', () => { + render( + + + + ); + + const title = screen.getByText('Submissions'); + const filter = screen.getByLabelText('Filter by Assignment'); + + expect(title).toBeTruthy(); + expect(filter).toBeTruthy(); + }); + + it('filters submissions based on selected assignment', async () => { + render( + + + + ); + + // Select an assignment to filter + const select = screen.getByLabelText('Filter by Assignment'); + fireEvent.change(select, { target: { value: 'Assignment 1' } }); + + // Check if the filtered submission is displayed + expect(await screen.findByText('Anonymized_Team_38121')).toBeTruthy(); + expect(screen.queryByText('Anonymized_Team_38122')).toBeFalsy(); + }); + + it('shows all submissions when no filter is applied', async () => { + render( + + + + ); + + // Select an assignment to filter + const select = screen.getByLabelText('Filter by Assignment'); + fireEvent.change(select, { target: { value: 'Assignment 1' } }); + + // Reset filter + fireEvent.change(select, { target: { value: '' } }); + + // Check if all submissions are displayed + expect(await screen.findByText('Anonymized_Team_38121')).toBeTruthy(); + expect(await screen.findByText('Anonymized_Team_38122')).toBeTruthy(); + }); +}); diff --git a/src/pages/Submissions/SubmissionsView.tsx b/src/pages/Submissions/SubmissionsView.tsx new file mode 100644 index 00000000..1f595c01 --- /dev/null +++ b/src/pages/Submissions/SubmissionsView.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { Col, Container, Form, Row } from "react-bootstrap"; +import SubmissionList from "./SubmissionTable/SubmissionList"; + +const SubmissionView = () => { + const [submissions, setSubmissions] = useState([]); + const [filteredSubmissions, setFilteredSubmissions] = useState([]); + const [assignmentFilter, setAssignmentFilter] = useState(""); + + // Dummy assignments for filtering + const assignments = ["Assignment 1", "Assignment 2", "Assignment 3"]; + + useEffect(() => { + // Simulating data fetching + const fetchSubmissions = async () => { + const date = new Date(Date.parse('04 Dec 2021 00:12:00 GMT')); + const data = 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().setDate(date.getDate() + i)); + + const members = Array.from({ length: studentCount }, (_, j) => ({ + name: `Student ${10000 + i * 10 + j}`, + id: 10000 + i * 10 + j, + })); + + const links = [ + { url: `https://github.com/example/repo${id}`, displayName: "GitHub Repository" }, + { url: `http://example.com/submission${id}`, displayName: "Submission Link" }, + ]; + + const fileInfo = [ + { + name: `README.md`, + size: `${(Math.random() * 15 + 10).toFixed(1)} KB`, + dateModified: formatDate(currentDate), + }, + ]; + + return { + id, + teamName: `Anonymized_Team_${teamNumber}`, + assignment: `Assignment ${assignmentNumber}`, + members, + links, + fileInfo, + }; + }); + + setSubmissions(data); + setFilteredSubmissions(data); + }; + + fetchSubmissions(); + }, []); + + const formatDate = (date: Date) => { + const padZero = (num: number) => String(num).padStart(2, '0'); + + const year = String(date.getFullYear()) // Last two digits of the year + const month = padZero(date.getMonth() + 1); // Months are zero-based, so we add 1 + const day = padZero(date.getDate()); + + const hours = padZero(date.getHours()); + const minutes = padZero(date.getMinutes()); + const seconds = padZero(date.getSeconds()); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + } + + const handleGradeClick = (id: number) => { + console.log(`Assign Grade clicked for submission ID ${id}`); + }; + + const handleAssignmentChange = (e: React.ChangeEvent) => { + const selectedAssignment = e.target.value; + setAssignmentFilter(selectedAssignment); + if (selectedAssignment) { + setFilteredSubmissions(submissions.filter(sub => sub.assignment === selectedAssignment)); + } else { + setFilteredSubmissions(submissions); + } + }; + + return ( + + + +

Submissions

+
+ + + + + + Filter by Assignment + handleAssignmentChange(e as any)}> + + {assignments.map((assignment, index) => ( + + ))} + + + + + + + + + + ); +}; + +export default SubmissionView; diff --git a/src/pages/ViewTeamGrades/grades.scss b/src/pages/ViewTeamGrades/grades.scss index 8434ae5c..7b9252ec 100644 --- a/src/pages/ViewTeamGrades/grades.scss +++ b/src/pages/ViewTeamGrades/grades.scss @@ -227,7 +227,7 @@ .container { display: flex; justify-content: space-between; /* Adjust as needed */ - width: 80%; /* Ensure the container takes up the full width */ + width: 100%; /* Ensure the container takes up the full width */ }