[] = [
+ {
+ 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 (
+
+ );
+ }
+
+ 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