diff --git a/src/App.tsx b/src/App.tsx index a25e5552..627258cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import ParticipantsDemo from "./pages/Participants/ParticipantsDemo"; import { loadParticipantDataRolesAndInstitutions } from "./pages/Participants/participantUtil"; import EditProfile from "./pages/Profile/Edit"; import Reviews from "./pages/Reviews/reviews"; +import ReviewTableau from "./pages/ReviewTableau/ReviewTableau"; import RoleEditor, { loadAvailableRole } from "./pages/Roles/RoleEditor"; import Roles, { loadRoles } from "./pages/Roles/Roles"; import TA from "./pages/TA/TA"; @@ -72,7 +73,7 @@ function App() { loader: loadAssignment, }, - // Assign Reviewer: no route loader (component handles localStorage/URL id) + // Assign Reviewer: no route loader (component handles localStorage/URL id) { path: "assignments/edit/:id/responsemappings", element: , @@ -204,6 +205,10 @@ function App() { path: "reviews", element: , }, + { + path: "review-tableau", + element: } />, + }, { path: "demo/participants", element: , diff --git a/src/pages/Authentication/Login.tsx b/src/pages/Authentication/Login.tsx index c99cb37f..2051b297 100644 --- a/src/pages/Authentication/Login.tsx +++ b/src/pages/Authentication/Login.tsx @@ -30,7 +30,7 @@ const Login: React.FC = () => { const onSubmit = (values: ILoginFormValues, submitProps: FormikHelpers) => { axios - .post("http://152.7.177.187:3002/login", values) + .post("http://localhost:3002/login", values) .then((response) => { const payload = setAuthToken(response.data.token); diff --git a/src/pages/ReviewTableau/ReviewCell.tsx b/src/pages/ReviewTableau/ReviewCell.tsx new file mode 100644 index 00000000..f1b89ae7 --- /dev/null +++ b/src/pages/ReviewTableau/ReviewCell.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { ReviewCellProps } from '../../types/reviewTableau'; +import { ScoreWidget, CheckWidget } from './ScoreWidgets'; + +/** + * Component for displaying individual review responses in tableau cells + * Handles different response types based on rubric item type + */ +export const ReviewCell: React.FC = ({ + item, + response, + reviewerName +}) => { + // Handle empty responses + if (!response) { + return
; + } + + // Handle end markers (null txt) + if (item.txt === null) { + return null; + } + + const renderResponseContent = () => { + switch (item.itemType) { + case 'Section_header': + case 'Table_header': + case 'Column_header': + // Headers don't have responses + return
; + + case 'Criterion': + case 'Scale': + if (response.score !== undefined && item.maxScore) { + return ( + + ); + } + return
; + + case 'TextField': + case 'TextArea': + if (response.textResponse) { + const displayText = response.textResponse.length > 50 + ? response.textResponse.substring(0, 47) + '...' + : response.textResponse; + + return ( +
50 ? 'pointer' : 'default' + }} + title={response.textResponse} + > + {displayText} +
+ ); + } + return
; + + case 'Dropdown': + case 'MultipleChoice': + if (response.selectedOption) { + return ( +
+ {response.selectedOption} +
+ ); + } + return
; + + case 'Checkbox': + if (response.selections && response.selections.length > 0) { + return ( +
+ {response.selections.map((selection, idx) => ( +
+ ✓ {selection} +
+ ))} +
+ ); + } + return
; + + case 'UploadFile': + if (response.fileName) { + return ( +
+ {response.fileUrl ? ( + + 📎 {response.fileName.length > 20 + ? response.fileName.substring(0, 17) + '...' + : response.fileName} + + ) : ( + + 📎 {response.fileName.length > 20 + ? response.fileName.substring(0, 17) + '...' + : response.fileName} + + )} +
+ ); + } + return
; + + default: + return
; + } + }; + + const content = renderResponseContent(); + + // Don't render anything for end markers + if (content === null) { + return null; + } + + return ( +
+ {content} +
+ ); +}; \ No newline at end of file diff --git a/src/pages/ReviewTableau/ReviewTableau.scss b/src/pages/ReviewTableau/ReviewTableau.scss new file mode 100644 index 00000000..2956c405 --- /dev/null +++ b/src/pages/ReviewTableau/ReviewTableau.scss @@ -0,0 +1,261 @@ +/* Review by Student Styles - Using Bootstrap Table Component */ + +.review-by-student-container { + padding: 20px; + background-color: white; + font-family: verdana, arial, helvetica, sans-serif; + max-width: 100%; + overflow-x: auto; +} + +/* Main title */ +.main-title { + line-height: 18px; + font-weight: bold; + margin: 0 0 15px 0; + text-align: left; +} + +/* Course and assignment info */ +.course-info { + font-size: 1.2em; + line-height: 30px; +} + +/* Round sections (main level) */ +.round-section { + margin-bottom: 50px; +} + +.round-title-main { + font-size: 1.4em; + line-height: 22px; + font-weight: bold; + margin: 30px 0 20px 0; + color: #333; + padding-bottom: 5px; +} + +/* Rubric sections (within rounds) */ +.rubric-section { + margin-bottom: 30px; +} + +.rubric-title { + font-size: 1.2em; + line-height: 18px; + font-weight: bold; + margin: 0 0 10px 0; + color: #333; +} + +/* Table wrapper */ +.review-table-wrapper { + margin-bottom: 20px; +} + +.review-table-wrapper .table { + font-size: 15px; + line-height: 1.428em; + border: 1px solid #999; + margin-bottom: 0; + font-family: verdana, arial, helvetica, sans-serif; +} + +.review-table-wrapper .table thead th { + background-color: white !important; + border: 1px solid #999 !important; + padding: 8px !important; + text-align: center !important; + font-weight: bold !important; + font-size: 15px !important; + line-height: 1.428em !important; + vertical-align: top !important; + color: #333 !important; +} + +/* First header cell (Item column) should have beige background */ +.review-table-wrapper .table thead th:first-child { + background-color: #c0c0b0 !important; +} + +.review-table-wrapper .table tbody td { + border: 1px solid #999 !important; + padding: 4px 8px !important; + vertical-align: middle !important; + font-size: 15px !important; + line-height: 1.428em !important; +} + +/* Make sure the first column (ITEM) has beige background */ +.review-table-wrapper .table tbody td:first-child { + background-color: #c0c0b0 !important; +} + +/* Reviewer header styling */ +.reviewer-header-content { + text-align: center; +} + +.reviewer-name { + font-weight: bold; + margin-bottom: 3px; + font-size: 15px; + line-height: 1.428em; +} + +.submission-time { + font-size: 13px; + line-height: 30px; + font-weight: normal; + color: #4e4d4d; + font-style: italic; +} + +/* Response cell styling */ +.response-cell-content { + text-align: left; + display: flex; + align-items: center; + justify-content: flex-start; +} + +/* Score widgets - match the green circular design */ +.score-widget { + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* Dropdown response styling */ +.dropdown-response { + display: flex; + align-items: center; + gap: 5px; +} + +.selected-option { + background-color: #f0f0f0; + padding: 2px 6px; + border-radius: 3px; + font-size: 13px; + border: 1px solid #ccc; +} + +/* Check/X icons using images */ +.check-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin: 0 auto; +} + +.check-icon img { + width: 16px; + height: 16px; + object-fit: contain; +} + +.check-icon.check-true { + background-color: rgba(40, 167, 69, 0.1); + border-radius: 3px; +} + +.check-icon.check-false { + background-color: rgba(220, 53, 69, 0.1); + border-radius: 3px; +} + +/* Text response styling */ +.text-response-cell { + font-size: 13px; + line-height: 1.3; + text-align: left; + padding: 0; + margin: 0; + width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; +} + +/* No response placeholder */ +.no-response { + color: #ccc; + font-style: italic; + font-size: 13px; + line-height: 30px; +} + +/* Loading and error states */ +.review-tableau-loading, +.review-tableau-error, +.review-tableau-empty { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + font-size: 13px; + line-height: 30px; + color: #666; + font-family: verdana, arial, helvetica, sans-serif; +} + +.error-message { + color: #dc3545; + font-weight: bold; + font-size: 13px; + line-height: 30px; +} + +.loading-spinner { + display: flex; + align-items: center; + gap: 10px; +} + +.loading-spinner::before { + content: ''; + width: 20px; + height: 20px; + border: 2px solid #f3f3f3; + border-top: 2px solid #b00404; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Responsive design */ +@media (max-width: 768px) { + .review-by-student-container { + padding: 10px; + } + + .main-title { + font-size: 1.1em; + line-height: 16px; + } + + .review-table-wrapper .table { + font-size: 13px; + line-height: 30px; + } +} + +@media (max-width: 480px) { + .main-title { + font-size: 1em; + line-height: 14px; + } + + .review-table-wrapper .table { + font-size: 12px; + line-height: 1.2em; + } +} \ No newline at end of file diff --git a/src/pages/ReviewTableau/ReviewTableau.tsx b/src/pages/ReviewTableau/ReviewTableau.tsx new file mode 100644 index 00000000..0f05b227 --- /dev/null +++ b/src/pages/ReviewTableau/ReviewTableau.tsx @@ -0,0 +1,312 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { ColumnDef } from '@tanstack/react-table'; +import { ReviewTableauData, ReviewRound, RubricItem, ReviewResponse, Rubric } from '../../types/reviewTableau'; +import { getReviewTableauData, transformReviewTableauData } from '../../services/gradesService'; +import { ScoreWidget } from './ScoreWidgets'; +import Table from '../../components/Table/Table'; +import './ReviewTableau.scss'; + +/** + * Review Tableau Page + * Displays all reviews completed BY a specific reviewer (participant) for an assignment. + * Shows reviews done by this reviewer on different teams/students across different rounds. + */ +const ReviewTableau: React.FC = () => { + const [searchParams] = useSearchParams(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Get parameters from URL + const reviewerId = searchParams.get('studentId'); // Optional: if we want to override the display name + const assignmentId = searchParams.get('assignmentId'); + const participantId = searchParams.get('participantId'); // This is the reviewer's participant ID + + // Basic render/mount log to help debug routing/requests + console.log('ReviewTableau render - params', { reviewerId, assignmentId, participantId, isLoading, error }); + + useEffect(() => { + const fetchData = async () => { + // Check for required parameters + if (!assignmentId || !participantId) { + setError('Unauthorized: Missing required parameters. Please provide assignmentId and participantId in the URL.'); + return; + } + + setIsLoading(true); + setError(null); + + try { + console.log('ReviewTableau: requesting review tableau data', { assignmentId, participantId }); + + const apiResponse = await getReviewTableauData({ + assignmentId, + participantId, + }); + + console.log('ReviewTableau: apiResponse received', apiResponse); + + const transformedData = transformReviewTableauData(apiResponse, reviewerId || undefined); + setData(transformedData); + + console.log('ReviewTableau: data transformed and set', transformedData); + } catch (err: any) { + console.error('ReviewTableau: error fetching data', err); + setError(err.response?.data?.error || err.message || 'Failed to fetch review tableau data'); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [assignmentId, participantId, reviewerId]); + + // Group by round first, then by rubrics within each round + const roundRubricGroups = useMemo(() => { + if (!data?.rubrics || !data?.rounds) return []; + + // Group rounds by round number + const roundsMap = new Map(); + data.rounds.forEach(round => { + if (!roundsMap.has(round.roundNumber)) { + roundsMap.set(round.roundNumber, []); + } + roundsMap.get(round.roundNumber)!.push(round); + }); + + // Convert to array and sort by round number + return Array.from(roundsMap.entries()) + .sort(([a], [b]) => a - b) + .map(([roundNumber, rounds]) => ({ + roundNumber, + roundName: `Review Round ${roundNumber}`, + rubricRounds: rounds.map(round => { + const rubric = data.rubrics!.find(r => r.id === round.rubricId); + return { + rubric, + round + }; + }).filter(item => item.rubric) // Only include rounds with valid rubrics + })); + }, [data?.rubrics, data?.rounds]); + + // Generate table data for a specific rubric and round + const generateRubricRoundTableData = (rubric: Rubric, round: ReviewRound) => { + return rubric.items.map(item => { + const rowData: any = { + id: item.id, + item: item.txt, + itemType: item.itemType, + questionType: item.questionType, + maxScore: item.maxScore + }; + + // Add response data for each reviewer in this round + round.reviews.forEach((review, index) => { + rowData[`reviewer_${index}`] = { + reviewerId: review.reviewerId, + reviewerName: review.reviewerName, + response: review.responses[item.id] + }; + }); + + return rowData; + }); + }; + + // Generate columns for a specific round + const generateRubricRoundColumns = (round: ReviewRound): ColumnDef[] => { + const columns: ColumnDef[] = [ + { + id: 'item', + header: 'Item', + accessorKey: 'item', + cell: ({ row }) => row.original.item, + enableSorting: false, + enableColumnFilter: false, + } + ]; + + // Add review columns for this specific round (each column represents a different team/student reviewed) + round.reviews.forEach((review, index) => { + columns.push({ + id: `reviewer_${index}`, + header: () => ( +
+
{review.reviewerName}
+
{review.submissionTime || ''}
+
+ ), + accessorKey: `reviewer_${index}`, + cell: ({ row }) => { + const reviewData = row.original[`reviewer_${index}`]; + if (reviewData?.response) { + // Construct proper RubricItem object for renderReviewCell + const item: RubricItem = { + id: row.original.id, + txt: row.original.item, + itemType: row.original.itemType, + questionType: row.original.questionType, + maxScore: row.original.maxScore + }; + + return ( +
+ {renderReviewCell(item, reviewData.response)} +
+ ); + } + return ; + }, + enableSorting: false, + enableColumnFilter: false, + }); + }); + + return columns; + }; + + // Helper function to render score or check/X + const renderReviewCell = (item: RubricItem, response: any) => { + if (!response) return ; + + // Handle different question types based on both itemType and questionType + const questionType = item.questionType || item.itemType; + + // Scale and Criterion questions with numeric scores + if ((questionType === 'Scale' || questionType === 'Criterion' || item.itemType === 'Criterion') && response.score !== undefined) { + if (item.maxScore) { + return ; + } else { + // If no max score defined, just show the score value + return ( +
+ {response.score} + {response.comment && 💬} +
+ ); + } + } + + // Dropdown questions + if (questionType === 'Dropdown' && (response.selectedOption !== undefined || response.score !== undefined)) { + const displayValue = response.selectedOption || response.score; + return ( +
+ {displayValue} + {response.comment && 💬} +
+ ); + } + + // Checkbox questions + if ((questionType === 'Checkbox' || item.itemType === 'Checkbox')) { + // Handle various checkbox value formats + let checkValue = response.checkValue; + if (checkValue === undefined && response.score !== undefined) { + // Sometimes checkbox values come as score (1 for checked, 0 for unchecked) + checkValue = response.score === 1 || response.score === true; + } + if (checkValue === undefined && response.selectedOption !== undefined) { + // Handle cases where checkbox comes as selectedOption + checkValue = response.selectedOption === 'Yes' || response.selectedOption === 'true' || response.selectedOption === true; + } + + if (checkValue !== undefined) { + const isChecked = checkValue === true || checkValue === 1 || checkValue === '1' || checkValue === 'true'; + return ( + + {isChecked ? ( + ✓ + ) : ( + ✗ + )} + + ); + } + } + + // Text-based questions + if ((questionType === 'TextArea' || questionType === 'TextField') && response.textResponse !== undefined) { + return ( +
+ {response.textResponse || '—'} +
+ ); + } + + return ; + }; + + if (isLoading) { + return ( +
+
Loading review tableau...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (!data) { + return ( +
+
No review data available
+
+ ); + } + + return ( +
+ {/* Header */} +

Reviews By {data.studentId}

+ + {/* Course and Assignment Info */} +
+
Course : {data.course}
+
Assignment: {data.assignment}
+
+ + {/* Render by round first, then rubrics within each round */} + {roundRubricGroups.map((roundGroup) => ( +
+

{roundGroup.roundName}

+ + {roundGroup.rubricRounds.map((rubricRound) => { + if (!rubricRound.rubric) return null; + + const tableData = generateRubricRoundTableData(rubricRound.rubric, rubricRound.round); + const columns = generateRubricRoundColumns(rubricRound.round); + + return ( +
+ +
+ + + + ); + })} + + ))} + + ); +}; + +export default ReviewTableau; \ No newline at end of file diff --git a/src/pages/ReviewTableau/RubricItemDisplay.tsx b/src/pages/ReviewTableau/RubricItemDisplay.tsx new file mode 100644 index 00000000..d81983c9 --- /dev/null +++ b/src/pages/ReviewTableau/RubricItemDisplay.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { RubricItemDisplayProps } from '../../types/reviewTableau'; +import { MaxScoreWidget } from './ScoreWidgets'; + +/** + * Component for displaying rubric items in the left column of the tableau + * Handles all different item types with appropriate styling + */ +export const RubricItemDisplay: React.FC = ({ + item, + isHeader = false +}) => { + const renderItemContent = () => { + // Handle null txt (end markers) + if (item.txt === null) { + return null; + } + + switch (item.itemType) { + case 'Section_header': + return ( +
+ {item.txt} +
+ ); + + case 'Table_header': + return ( +
+ {item.txt} +
+ ); + + case 'Column_header': + return ( +
+ {item.txt} +
+ ); + + case 'Criterion': + case 'Scale': + return ( +
+
+
+ {item.questionNumber && ( + {item.questionNumber}. + )} + {item.txt} +
+ {item.scaleDescription && ( +
+ {item.scaleDescription} +
+ )} +
+ {item.maxScore && ( + + )} +
+ ); + + case 'TextField': + case 'TextArea': + return ( +
+
+ {item.questionNumber && ( + {item.questionNumber}. + )} + {item.txt} + + ({item.itemType === 'TextArea' ? 'Long Text' : 'Short Text'}) + +
+
+ ); + + case 'Dropdown': + case 'MultipleChoice': + return ( +
+
+ {item.questionNumber && ( + {item.questionNumber}. + )} + {item.txt} + + ({item.itemType}) + +
+ {item.options && item.options.length > 0 && ( +
+ Options: {item.options.slice(0, 3).join(', ')} + {item.options.length > 3 ? '...' : ''} +
+ )} +
+ ); + + case 'Checkbox': + return ( +
+
+ {item.questionNumber && ( + {item.questionNumber}. + )} + {item.txt} + + (Multiple Selection) + +
+ {item.options && item.options.length > 0 && ( +
+ Options: {item.options.slice(0, 3).join(', ')} + {item.options.length > 3 ? '...' : ''} +
+ )} +
+ ); + + case 'UploadFile': + return ( +
+
+ {item.questionNumber && ( + {item.questionNumber}. + )} + {item.txt} + + (File Upload) + +
+
+ ); + + default: + return ( +
+ {item.questionNumber && ( + {item.questionNumber}. + )} + {item.txt} +
+ ); + } + }; + + const content = renderItemContent(); + + // Don't render anything for end markers + if (content === null) { + return null; + } + + return
{content}
; +}; \ No newline at end of file diff --git a/src/pages/ReviewTableau/ScoreWidgets.tsx b/src/pages/ReviewTableau/ScoreWidgets.tsx new file mode 100644 index 00000000..7a255c1b --- /dev/null +++ b/src/pages/ReviewTableau/ScoreWidgets.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { getColorClass } from '../ViewTeamGrades/utils'; +import { ScoreWidgetProps } from '../../types/reviewTableau'; +import '../ViewTeamGrades/grades.scss'; + +/** + * Reusable circular score widget that matches the design used in ViewTeamGrades + * Shows a score inside a colored circle with color coding based on performance + */ +export const ScoreWidget: React.FC = ({ + score, + maxScore, + comment, + hasComment = false +}) => { + const colorClass = getColorClass(score, maxScore); + const title = comment ? `Score: ${score}/${maxScore}\nComment: ${comment}` : `Score: ${score}/${maxScore}`; + + return ( +
+
+ + {score} + +
+ {comment && ( +
+ {comment} +
+ )} +
+ ); +}; + +/** + * Widget for displaying maximum score in rubric column + */ +export const MaxScoreWidget: React.FC<{ maxScore: number }> = ({ maxScore }) => { + return ( +
+ {maxScore} +
+ ); +}; + +/** + * Simple checkmark widget for boolean/completed items + */ +export const CheckWidget: React.FC<{ checked?: boolean }> = ({ checked = false }) => { + return ( +
+ {checked ? '✓' : ''} +
+ ); +}; \ No newline at end of file diff --git a/src/pages/ReviewTableau/__tests__/ReviewTableau.test.tsx b/src/pages/ReviewTableau/__tests__/ReviewTableau.test.tsx new file mode 100644 index 00000000..75350459 --- /dev/null +++ b/src/pages/ReviewTableau/__tests__/ReviewTableau.test.tsx @@ -0,0 +1,209 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import '@testing-library/jest-dom'; + +import ReviewTableau from '../ReviewTableau'; +import * as gradesService from '../../../services/gradesService'; + +// Mock the grades service +vi.mock('../../../services/gradesService'); +const mockGradesService = gradesService as any; + +// Mock components +vi.mock('../ScoreWidgets', () => ({ + ScoreWidget: ({ score, maxScore, comment }: any) => ( +
+ Score: {score}/{maxScore} {comment && 💬} +
+ ), +})); + +vi.mock('../../../components/Table/Table', () => ({ + default: function MockTable({ data, columns }: any) { + return ( +
+
{JSON.stringify(data)}
+
{columns.length}
+
+ ); + }, +})); + +const mockApiResponse = { + responses_by_round: { + "1": { + "1": { + description: "Rate the overall quality", + question_type: "Scale", + answers: { values: [4, 5], comments: ["Good work", "Excellent"] } + } + } + }, + participant: { + id: 1, user_id: 3, user_name: "student1", + full_name: "Student One", handle: "student1" + }, + assignment: { id: 1, name: "Test Assignment" } +}; + +const renderWithRouter = (searchParams: string = '') => { + const url = `/review-tableau${searchParams}`; + window.history.pushState({}, 'Test page', url); + return render(); +}; + +describe('ReviewTableau Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // TEST 1: Parameter Validation + test('should show error when required parameters are missing', async () => { + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/unauthorized: missing required parameters/i)).toBeInTheDocument(); + }); + + expect(mockGradesService.getReviewTableauData).not.toHaveBeenCalled(); + }); + + // TEST 2: Successful Data Loading + test('should load and display review data successfully', async () => { + mockGradesService.getReviewTableauData.mockResolvedValue(mockApiResponse); + mockGradesService.transformReviewTableauData.mockReturnValue({ + studentId: 'student1', + course: 'Course Information', + assignment: 'Test Assignment', + rubrics: [{ id: 'rubric_1', name: 'Review Rubric - Round 1', items: [] }], + rounds: [{ roundNumber: 1, roundName: 'Review Round 1', rubricId: 'rubric_1', reviews: [] }], + assignmentId: '1', participantId: '1' + }); + + renderWithRouter('?assignmentId=1&participantId=1'); + + await waitFor(() => { + expect(screen.getByText('Reviews By student1')).toBeInTheDocument(); + }); + + expect(screen.getByText('Course :')).toBeInTheDocument(); + expect(screen.getByText('Course Information')).toBeInTheDocument(); + expect(screen.getByText('Assignment:')).toBeInTheDocument(); + expect(screen.getByText('Test Assignment')).toBeInTheDocument(); + expect(screen.getByText('Review Round 1')).toBeInTheDocument(); + expect(mockGradesService.getReviewTableauData).toHaveBeenCalledWith({ + assignmentId: '1', participantId: '1' + }); + }); + + // TEST 3: API Error Handling + test('should handle API errors gracefully', async () => { + const errorMessage = 'Failed to fetch review data'; + mockGradesService.getReviewTableauData.mockRejectedValue({ + response: { data: { error: errorMessage } } + }); + + renderWithRouter('?assignmentId=1&participantId=1'); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + expect(screen.queryByText('Reviews By')).not.toBeInTheDocument(); + }); + + // TEST 4: Loading State + test('should show loading state during data fetch', async () => { + let resolvePromise: (value: any) => void; + const controlledPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + mockGradesService.getReviewTableauData.mockReturnValue(controlledPromise); + renderWithRouter('?assignmentId=1&participantId=1'); + + expect(screen.getByText('Loading review tableau...')).toBeInTheDocument(); + + resolvePromise!(mockApiResponse); + mockGradesService.transformReviewTableauData.mockReturnValue({ + studentId: 'student1', course: 'Course Information', assignment: 'Test Assignment', + rubrics: [], rounds: [], assignmentId: '1', participantId: '1' + }); + + await waitFor(() => { + expect(screen.queryByText('Loading review tableau...')).not.toBeInTheDocument(); + }); + }); + + // TEST 5: Different Question Types + test('should handle different question types correctly', async () => { + const mixedApiResponse = { + responses_by_round: { + "1": { + "1": { + description: "Rate quality", + question_type: "Scale", + answers: { values: [4], comments: ["Good"] } + }, + "2": { + description: "Technical accuracy", + question_type: "Criterion", + answers: { values: [3], comments: ["Needs work"] } + }, + "3": { + description: "Choose option", + question_type: "Dropdown", + answers: { values: [1], comments: ["Option A"] } + } + } + }, + participant: mockApiResponse.participant, + assignment: mockApiResponse.assignment + }; + + mockGradesService.getReviewTableauData.mockResolvedValue(mixedApiResponse); + mockGradesService.transformReviewTableauData.mockReturnValue({ + studentId: 'student1', course: 'Course Information', assignment: 'Test Assignment', + rubrics: [{ + id: 'rubric_1', name: 'Mixed Question Types Rubric', + items: [ + { id: '1', txt: 'Rate quality', itemType: 'Scale', questionType: 'Scale', maxScore: 5 }, + { id: '2', txt: 'Technical accuracy', itemType: 'Criterion', questionType: 'Criterion', maxScore: 5 }, + { id: '3', txt: 'Choose option', itemType: 'Dropdown', questionType: 'Dropdown' } + ] + }], + rounds: [{ + roundNumber: 1, roundName: 'Review Round 1', rubricId: 'rubric_1', + reviews: [{ + reviewerId: 'reviewer_1', reviewerName: 'Reviewer 1', roundNumber: 1, + responses: { + '1': { score: 4, comment: 'Good' }, + '2': { score: 3, comment: 'Needs work' }, + '3': { selectedOption: 'Option A', comment: 'Best choice' } + } + }] + }], + assignmentId: '1', participantId: '1' + }); + + renderWithRouter('?assignmentId=1&participantId=1&studentId=student1'); + + await waitFor(() => { + expect(screen.getByText('Reviews By student1')).toBeInTheDocument(); + }); + + expect(screen.getByText('Mixed Question Types Rubric')).toBeInTheDocument(); + + expect(screen.getByTestId('mock-table')).toBeInTheDocument(); + + // Verify API calls + expect(mockGradesService.getReviewTableauData).toHaveBeenCalledWith({ + assignmentId: '1', participantId: '1' + }); + + expect(mockGradesService.transformReviewTableauData).toHaveBeenCalledWith( + mixedApiResponse, 'student1' + ); + }); +}); \ No newline at end of file diff --git a/src/pages/ReviewTableau/index.ts b/src/pages/ReviewTableau/index.ts new file mode 100644 index 00000000..ed70f2fa --- /dev/null +++ b/src/pages/ReviewTableau/index.ts @@ -0,0 +1,6 @@ +// Export all components from the ReviewTableau module +export { default } from './ReviewTableau'; +export { ScoreWidget, MaxScoreWidget, CheckWidget } from './ScoreWidgets'; +export { RubricItemDisplay } from './RubricItemDisplay'; +export { ReviewCell } from './ReviewCell'; +export * from '../../types/reviewTableau'; \ No newline at end of file diff --git a/src/pages/ViewTeamGrades/utils.ts b/src/pages/ViewTeamGrades/utils.ts index f5dd8c7c..f5afd7a7 100644 --- a/src/pages/ViewTeamGrades/utils.ts +++ b/src/pages/ViewTeamGrades/utils.ts @@ -82,12 +82,17 @@ export const convertBackendRoundArray = (backendRounds: any[][]): ReviewData[][] export const getColorClass = (score: number, maxScore: number) => { let scoreColor = score; + // Calculate the percentage of how far from max score (inverted so lower scores = higher percentage) scoreColor = ((maxScore - scoreColor) / maxScore) * 100; - if (scoreColor >= 80) return 'c1'; - else if (scoreColor >= 60 && scoreColor < 80) return 'c2'; - else if (scoreColor >= 40 && scoreColor < 60) return 'c3'; - else if (scoreColor >= 20 && scoreColor < 40) return 'c4'; - else if (scoreColor >= 0 && scoreColor < 20) return 'c5'; + + // Use dynamic intervals that work for any scale (1-3, 1-5, 1-10, etc.) + const interval = 100 / 5; // 20% intervals for 5 color gradients + + if (scoreColor >= interval * 4) return 'c1'; // Bottom quintile (worst 20%) + else if (scoreColor >= interval * 3) return 'c2'; // 4th quintile (60-80% from max) + else if (scoreColor >= interval * 2) return 'c3'; // Middle quintile (40-60% from max) + else if (scoreColor >= interval * 1) return 'c4'; // 2nd quintile (20-40% from max) + else if (scoreColor >= 0) return 'c5'; // Top quintile (best 20%) else return 'cf'; }; diff --git a/src/services/gradesService.ts b/src/services/gradesService.ts index e69de29b..d9521608 100644 --- a/src/services/gradesService.ts +++ b/src/services/gradesService.ts @@ -0,0 +1,137 @@ +import axiosClient from '../utils/axios_client'; +import { ReviewTableauData } from '../types/reviewTableau'; + +/** + * Service for grades-related API calls + */ + +export interface GetReviewTableauDataParams { + assignmentId: string; + participantId: string; +} + +export interface ReviewTableauApiResponse { + responses_by_round: { + [roundId: string]: { + min_answer_value: number; + max_answer_value: number; + items: { + [itemId: string]: { + description: string; + question_type: string; + answers: { + values: number[]; + comments: string[]; + }; + }; + }; + }; + }; + participant: { + id: number; + user_id: number; + user_name: string; + full_name: string; + handle: string; + }; + assignment: { + id: number; + name: string; + }; +} + +/** + * Fetch review tableau data for a specific assignment and participant + */ +export const getReviewTableauData = async (params: GetReviewTableauDataParams): Promise => { + const { assignmentId, participantId } = params; + const response = await axiosClient.get(`/grades/${assignmentId}/${participantId}/get_review_tableau_data`); + return response.data; +}; + +/** + * Transform API response to frontend data structure + * The API returns all reviews completed BY the given participant (reviewer) for the assignment + */ +export const transformReviewTableauData = (apiData: ReviewTableauApiResponse, reviewerId?: string): ReviewTableauData => { + const { responses_by_round, participant, assignment } = apiData; + + // Transform the API response structure to match the frontend types + const rubrics: any[] = []; + const rounds: any[] = []; + + // Group data by rounds + Object.entries(responses_by_round).forEach(([roundId, roundData]) => { + const roundNumber = parseInt(roundId) || 1; + + // Extract rubric metadata + const { min_answer_value, max_answer_value, items } = roundData; + + // Create rubric items from the round data + const rubricItems = Object.entries(items).map(([itemId, itemData]) => ({ + id: itemId, + txt: itemData.description, + itemType: itemData.question_type || 'Criterion', + questionType: itemData.question_type, + maxScore: max_answer_value || 5, // Use the actual max value from the rubric + minScore: min_answer_value || 1, // Store min value as well + })); + + // Create rubric for this round + const rubric = { + id: `rubric_${roundId}`, + name: `Review Rubric - Round ${roundNumber}`, + items: rubricItems, + maxScore: max_answer_value || 5, + minScore: min_answer_value || 1, + }; + + rubrics.push(rubric); + + // Create reviews for this round + const reviews: any[] = []; + const maxResponses = Math.max( + ...Object.values(items).map(item => item.answers.values.length) + ); + + // Create one review per response (each index represents a different team/student reviewed by THIS reviewer) + for (let reviewIndex = 0; reviewIndex < maxResponses; reviewIndex++) { + const responses: any = {}; + + Object.entries(items).forEach(([itemId, itemData]) => { + if (itemData.answers.values[reviewIndex] !== undefined) { + responses[itemId] = { + score: itemData.answers.values[reviewIndex], + comment: itemData.answers.comments[reviewIndex] || '', + }; + } + }); + + reviews.push({ + reviewerId: `review_${reviewIndex + 1}`, + reviewerName: `Team ${reviewIndex + 1}`, // We'll improve this later with actual team names + roundNumber, + submissionTime: undefined, + responses, + }); + } + + // Create round + rounds.push({ + roundNumber, + roundName: `Review Round ${roundNumber}`, + rubricId: rubric.id, + reviews, + }); + }); + + return { + studentId: reviewerId || apiData.participant.user_name || apiData.participant.full_name || `Reviewer ${apiData.participant.id}`, + course: 'Course Information', // This might need to be fetched separately + assignment: apiData.assignment.name || 'Assignment Information', + rubrics, + rounds, + assignmentId: apiData.assignment.id.toString(), + participantId: apiData.participant.id.toString(), + }; +}; diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 00000000..46c57c44 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,22 @@ +import '@testing-library/jest-dom'; + +// Extend expect with jest-dom matchers +import { expect, vi } from 'vitest'; +import * as matchers from '@testing-library/jest-dom/matchers'; + +expect.extend(matchers); + +// Mock window.matchMedia for testing +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); \ No newline at end of file diff --git a/src/types/reviewTableau.ts b/src/types/reviewTableau.ts new file mode 100644 index 00000000..2cf7bd5b --- /dev/null +++ b/src/types/reviewTableau.ts @@ -0,0 +1,90 @@ +// Types for Review Tableau component + +export type ItemType = + | 'Section_header' + | 'Table_header' + | 'Column_header' + | 'Criterion' + | 'TextField' + | 'TextArea' + | 'Dropdown' + | 'MultipleChoice' + | 'Scale' + | 'Grid' + | 'Checkbox' + | 'UploadFile'; + +export interface RubricItem { + id: string; + txt: string | null; + itemType: ItemType; + questionType?: string; + questionNumber?: string; + maxScore?: number; + minScore?: number; + weight?: number; + options?: string[]; // For dropdown, multiple choice + scaleDescription?: string; // For scale items + isRequired?: boolean; + topicName?: string; // For grouping items by topic +} + +export interface ReviewResponse { + reviewerId: string; + reviewerName: string; + roundNumber: number; + submissionTime?: string; + responses: { + [itemId: string]: { + score?: number; + comment?: string; + textResponse?: string; + selectedOption?: string; + selections?: string[]; + fileName?: string; + fileUrl?: string; + checkValue?: boolean; + }; + }; +} + +export interface ReviewRound { + roundNumber: number; + roundName: string; + reviews: ReviewResponse[]; + rubricId?: string; // Which rubric this round uses +} + +export interface Rubric { + id: string; + name: string; + items: RubricItem[]; +} + +export interface ReviewTableauData { + studentId?: string; // Actually the reviewer's name/ID + course?: string; + assignment?: string; + rubrics: Rubric[]; // Multiple rubrics instead of single rubric + rounds: ReviewRound[]; + assignmentId?: string; + participantId?: string; // The reviewer's participant ID +} + +export interface ScoreWidgetProps { + score: number; + maxScore: number; + comment?: string; + hasComment?: boolean; +} + +export interface RubricItemDisplayProps { + item: RubricItem; + isHeader?: boolean; +} + +export interface ReviewCellProps { + item: RubricItem; + response?: ReviewResponse['responses'][string]; + reviewerName?: string; +} \ No newline at end of file diff --git a/src/utils/axios_client.ts b/src/utils/axios_client.ts index 8dcb2464..aa8c3267 100644 --- a/src/utils/axios_client.ts +++ b/src/utils/axios_client.ts @@ -7,7 +7,7 @@ import { getAuthToken } from "./auth"; const axiosClient = axios.create({ baseURL: "http://localhost:3002", - timeout: 1000, + timeout: 10000, headers: { "Content-Type": "application/json", Accept: "application/json", diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..10bac166 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + 'components': path.resolve(__dirname, './src/components'), + 'utils': path.resolve(__dirname, './src/utils'), + }, + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + }, +}); \ No newline at end of file