Spaces:
Sleeping
Sleeping
Assessment Integration Guide
Common patterns and shared components for integrating Quizzes, Assignments, and Labs in the EduVerse frontend.
Table of Contents
- Overview
- Authentication & Authorization
- Common API Patterns
- File Upload Integration
- Grading Integration
- State Management
- Shared Components
- Error Handling
- Real-time Features
Overview
All three assessment modules (Quizzes, Assignments, Labs) share common patterns:
| Feature | Quizzes | Assignments | Labs |
|---|---|---|---|
| Status workflow | ✅ draft→published→closed→archived | ✅ | ✅ |
| File attachments | ❌ (planned) | ✅ Google Drive | ✅ Google Drive |
| Auto-grading | ✅ MCQ/TF | ❌ | ❌ |
| Manual grading | ✅ Essay | ✅ | ✅ |
| Grades table integration | ✅ | ✅ | ✅ |
| Due dates | ✅ availableUntil | ✅ dueDate | ✅ dueDate |
| Late handling | ❌ | ✅ latePenalty % | ✅ isLate flag |
| Attendance | ❌ | ❌ | ✅ |
Authentication & Authorization
JWT Token Usage
All API requests require a Bearer token:
// api/client.ts
const API_BASE = 'http://localhost:8081';
export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = localStorage.getItem('accessToken');
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...options.headers,
},
});
if (response.status === 401) {
// Token expired - redirect to login
localStorage.removeItem('accessToken');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Request failed');
}
return response.json();
}
Login Flow
// POST /api/auth/login
interface LoginDto {
email: string;
password: string;
rememberMe?: boolean;
}
interface LoginResponse {
accessToken: string;
}
const login = async (credentials: LoginDto): Promise<void> => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error('Invalid credentials');
}
const { accessToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
};
Role-Based UI
// hooks/useAuth.ts
interface User {
userId: number;
email: string;
firstName: string;
lastName: string;
roles: Array<{ roleName: string }>;
}
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const hasRole = (role: string) => {
return user?.roles.some(r => r.roleName === role) ?? false;
};
const isInstructor = hasRole('instructor');
const isStudent = hasRole('student');
const isTA = hasRole('teaching_assistant');
const isAdmin = hasRole('admin') || hasRole('it_admin');
const canManageAssessments = isInstructor || isTA || isAdmin;
const canGrade = isInstructor || isTA;
const canSubmit = isStudent;
return { user, hasRole, isInstructor, isStudent, isTA, canManageAssessments, canGrade, canSubmit };
}
Common API Patterns
Pagination
All list endpoints support pagination:
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Usage
const getQuizzes = async (page = 1, limit = 10): Promise<PaginatedResponse<Quiz>> => {
return apiRequest(`/api/quizzes?page=${page}&limit=${limit}`);
};
Filtering
Common query parameters:
| Parameter | Description | Example |
|---|---|---|
courseId |
Filter by course | ?courseId=1 |
status |
Filter by status | ?status=published |
page |
Page number | ?page=2 |
limit |
Items per page | ?limit=20 |
search |
Search text | ?search=midterm |
Status Change Pattern
All modules use the same status change pattern:
type AssessmentStatus = 'draft' | 'published' | 'closed' | 'archived';
const changeStatus = async (
type: 'quizzes' | 'assignments' | 'labs',
id: number,
status: AssessmentStatus
): Promise<void> => {
await apiRequest(`/api/${type}/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
};
File Upload Integration
Google Drive Integration
The backend uses Google Drive for file storage. Two-step upload process:
// Step 1: Upload file to Drive
interface UploadResponse {
fileId: number;
fileName: string;
webViewLink: string;
}
const uploadFile = async (
type: 'assignments' | 'labs',
id: number,
file: File,
purpose: 'instructions' | 'submissions' | 'ta-materials'
): Promise<UploadResponse> => {
const formData = new FormData();
formData.append('file', file);
const endpoint = purpose === 'instructions'
? `/api/${type}/${id}/instructions/upload`
: purpose === 'ta-materials'
? `/api/${type}/${id}/ta-materials/upload`
: `/api/${type}/${id}/submissions/upload`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
},
body: formData,
});
return response.json();
};
// Step 2: Submit with file ID
const submitWithFile = async (
type: 'assignments' | 'labs',
id: number,
fileId: number
): Promise<void> => {
await apiRequest(`/api/${type}/${id}/submit`, {
method: 'POST',
body: JSON.stringify({ fileId }),
});
};
File Upload Component
// components/FileUploader.tsx
import { useState, useCallback } from 'react';
interface FileUploaderProps {
type: 'assignments' | 'labs';
id: number;
purpose: 'instructions' | 'submissions' | 'ta-materials';
accept?: string;
maxSize?: number; // bytes
onUpload: (fileId: number, fileName: string) => void;
onError: (error: string) => void;
}
export function FileUploader({
type,
id,
purpose,
accept = '*/*',
maxSize = 10 * 1024 * 1024, // 10MB default
onUpload,
onError,
}: FileUploaderProps) {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const handleFile = useCallback(async (file: File) => {
// Validate size
if (file.size > maxSize) {
onError(`File too large. Max size: ${formatBytes(maxSize)}`);
return;
}
setUploading(true);
setProgress(0);
try {
const result = await uploadFile(type, id, file, purpose);
onUpload(result.fileId, result.fileName);
} catch (err) {
onError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setUploading(false);
}
}, [type, id, purpose, maxSize, onUpload, onError]);
return (
<div className="file-uploader">
<input
type="file"
accept={accept}
disabled={uploading}
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
{uploading && (
<div className="progress-bar">
<div className="progress" style={{ width: `${progress}%` }} />
</div>
)}
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
Grading Integration
Central Grades Table
All grades are now stored in the central grades table. When you grade a submission, the backend automatically:
- Saves score/feedback to the submission record
- Creates/updates a
Graderecord in the grades table - Calculates percentage and letter grade
// Grade is automatically created when grading:
// - Quiz: After auto-grading or manual grading
// - Assignment: When calling PATCH /grade
// - Lab: When calling PATCH /grade
interface Grade {
gradeId: number;
userId: number;
courseId: number;
gradeType: 'assignment' | 'quiz' | 'lab' | 'exam' | 'final';
assignmentId?: number;
quizId?: number;
labId?: number;
score: number;
maxScore: number;
percentage: number;
letterGrade: string;
feedback?: string;
isPublished: boolean;
createdAt: Date;
updatedAt: Date;
}
Viewing Grades
// GET /api/grades/my - Student's grades
const getMyGrades = async (): Promise<PaginatedResponse<Grade>> => {
return apiRequest('/api/grades/my');
};
// GET /api/grades?courseId=X - Course grades (instructor)
const getCourseGrades = async (courseId: number): Promise<PaginatedResponse<Grade>> => {
return apiRequest(`/api/grades?courseId=${courseId}`);
};
// GET /api/grades/transcript/:studentId - Student transcript
const getTranscript = async (studentId: number): Promise<Transcript> => {
return apiRequest(`/api/grades/transcript/${studentId}`);
};
// GET /api/grades/gpa/:studentId - GPA calculation
const getGPA = async (studentId: number): Promise<GpaResult> => {
return apiRequest(`/api/grades/gpa/${studentId}`);
};
State Management
Recommended Store Structure (Zustand)
// stores/assessmentStore.ts
import { create } from 'zustand';
interface AssessmentStore {
// Quizzes
quizzes: Quiz[];
loadingQuizzes: boolean;
fetchQuizzes: (courseId?: number) => Promise<void>;
// Assignments
assignments: Assignment[];
loadingAssignments: boolean;
fetchAssignments: (courseId?: number) => Promise<void>;
// Labs
labs: Lab[];
loadingLabs: boolean;
fetchLabs: (courseId?: number) => Promise<void>;
// Common
currentCourseId: number | null;
setCourseId: (id: number) => void;
}
export const useAssessmentStore = create<AssessmentStore>((set, get) => ({
// Quizzes
quizzes: [],
loadingQuizzes: false,
fetchQuizzes: async (courseId) => {
set({ loadingQuizzes: true });
const cid = courseId || get().currentCourseId;
const { data } = await apiRequest<PaginatedResponse<Quiz>>(
`/api/quizzes${cid ? `?courseId=${cid}` : ''}`
);
set({ quizzes: data, loadingQuizzes: false });
},
// Assignments
assignments: [],
loadingAssignments: false,
fetchAssignments: async (courseId) => {
set({ loadingAssignments: true });
const cid = courseId || get().currentCourseId;
const { data } = await apiRequest<PaginatedResponse<Assignment>>(
`/api/assignments${cid ? `?courseId=${cid}` : ''}`
);
set({ assignments: data, loadingAssignments: false });
},
// Labs
labs: [],
loadingLabs: false,
fetchLabs: async (courseId) => {
set({ loadingLabs: true });
const cid = courseId || get().currentCourseId;
const { data } = await apiRequest<PaginatedResponse<Lab>>(
`/api/labs${cid ? `?courseId=${cid}` : ''}`
);
set({ labs: data, loadingLabs: false });
},
// Common
currentCourseId: null,
setCourseId: (id) => set({ currentCourseId: id }),
}));
Caching Strategy
// hooks/useAssessmentCache.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Quizzes
export function useQuizzes(courseId?: number) {
return useQuery({
queryKey: ['quizzes', courseId],
queryFn: () => apiRequest<PaginatedResponse<Quiz>>(
`/api/quizzes${courseId ? `?courseId=${courseId}` : ''}`
),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Single Quiz
export function useQuiz(id: number) {
return useQuery({
queryKey: ['quiz', id],
queryFn: () => apiRequest<Quiz>(`/api/quizzes/${id}`),
});
}
// Create Quiz
export function useCreateQuiz() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateQuizDto) =>
apiRequest<Quiz>('/api/quizzes', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['quizzes'] });
},
});
}
// Update Quiz Status
export function useChangeQuizStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) =>
apiRequest<Quiz>(`/api/quizzes/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
}),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['quiz', id] });
queryClient.invalidateQueries({ queryKey: ['quizzes'] });
},
});
}
Shared Components
Status Badge
// components/StatusBadge.tsx
type Status = 'draft' | 'published' | 'closed' | 'archived';
const statusColors: Record<Status, string> = {
draft: 'bg-gray-500',
published: 'bg-green-500',
closed: 'bg-yellow-500',
archived: 'bg-red-500',
};
export function StatusBadge({ status }: { status: Status }) {
return (
<span className={`px-2 py-1 rounded text-white text-sm ${statusColors[status]}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
Due Date Display
// components/DueDate.tsx
import { formatDistanceToNow, isPast, isToday, isTomorrow } from 'date-fns';
interface DueDateProps {
date: Date;
showCountdown?: boolean;
}
export function DueDate({ date, showCountdown = true }: DueDateProps) {
const due = new Date(date);
const overdue = isPast(due);
const today = isToday(due);
const tomorrow = isTomorrow(due);
let label: string;
let className: string;
if (overdue) {
label = 'Overdue';
className = 'text-red-600 font-bold';
} else if (today) {
label = 'Due Today';
className = 'text-orange-600 font-bold';
} else if (tomorrow) {
label = 'Due Tomorrow';
className = 'text-yellow-600';
} else {
label = showCountdown
? `Due ${formatDistanceToNow(due, { addSuffix: true })}`
: due.toLocaleDateString();
className = 'text-gray-600';
}
return (
<span className={className} title={due.toLocaleString()}>
{label}
</span>
);
}
Grade Display
// components/GradeDisplay.tsx
interface GradeDisplayProps {
score: number;
maxScore: number;
percentage?: number;
letterGrade?: string;
showLetter?: boolean;
}
export function GradeDisplay({
score,
maxScore,
percentage,
letterGrade,
showLetter = true,
}: GradeDisplayProps) {
const pct = percentage ?? (score / maxScore) * 100;
const letter = letterGrade ?? getLetterGrade(pct);
const getColor = () => {
if (pct >= 90) return 'text-green-600';
if (pct >= 80) return 'text-blue-600';
if (pct >= 70) return 'text-yellow-600';
if (pct >= 60) return 'text-orange-600';
return 'text-red-600';
};
return (
<div className={`grade-display ${getColor()}`}>
<span className="score">{score} / {maxScore}</span>
<span className="percentage">({pct.toFixed(1)}%)</span>
{showLetter && <span className="letter">{letter}</span>}
</div>
);
}
function getLetterGrade(percentage: number): string {
if (percentage >= 97) return 'A+';
if (percentage >= 93) return 'A';
if (percentage >= 90) return 'A-';
if (percentage >= 87) return 'B+';
if (percentage >= 83) return 'B';
if (percentage >= 80) return 'B-';
if (percentage >= 77) return 'C+';
if (percentage >= 73) return 'C';
if (percentage >= 70) return 'C-';
if (percentage >= 67) return 'D+';
if (percentage >= 63) return 'D';
if (percentage >= 60) return 'D-';
return 'F';
}
Loading Skeleton
// components/AssessmentSkeleton.tsx
export function AssessmentCardSkeleton() {
return (
<div className="assessment-card skeleton">
<div className="skeleton-line title" />
<div className="skeleton-line description" />
<div className="skeleton-line meta" />
</div>
);
}
export function AssessmentListSkeleton({ count = 5 }: { count?: number }) {
return (
<div className="assessment-list">
{Array.from({ length: count }).map((_, i) => (
<AssessmentCardSkeleton key={i} />
))}
</div>
);
}
Error Handling
Common Error Codes
| Status | Meaning | Action |
|---|---|---|
| 400 | Bad Request | Show validation errors |
| 401 | Unauthorized | Redirect to login |
| 403 | Forbidden | Show "access denied" |
| 404 | Not Found | Show "not found" page |
| 409 | Conflict | Show conflict message |
| 500 | Server Error | Show generic error |
Error Boundary
// components/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Error caught:', error, info);
// Log to monitoring service
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="error-fallback">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}>
Refresh Page
</button>
</div>
);
}
return this.props.children;
}
}
API Error Hook
// hooks/useApiError.ts
import { useState, useCallback } from 'react';
interface ApiError {
message: string;
statusCode: number;
field?: string;
}
export function useApiError() {
const [error, setError] = useState<ApiError | null>(null);
const handleError = useCallback((err: unknown) => {
if (err instanceof Response) {
err.json().then(data => {
setError({
message: data.message || 'An error occurred',
statusCode: err.status,
field: data.field,
});
});
} else if (err instanceof Error) {
setError({
message: err.message,
statusCode: 500,
});
}
}, []);
const clearError = useCallback(() => setError(null), []);
return { error, handleError, clearError };
}
Real-time Features
Quiz Timer WebSocket (Future)
// For quiz timers, consider WebSocket connection for:
// - Server-authoritative timer
// - Auto-submit on timeout
// - Sync across devices
interface TimerMessage {
type: 'TICK' | 'EXPIRED' | 'SYNC';
attemptId: number;
remainingSeconds: number;
}
// Example WebSocket setup (to be implemented)
const connectQuizTimer = (attemptId: number, onMessage: (msg: TimerMessage) => void) => {
const ws = new WebSocket(`ws://localhost:8081/quiz-timer/${attemptId}`);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
onMessage(message);
};
return () => ws.close();
};
Notifications
// Poll for notifications (WebSocket integration planned)
const useNotifications = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
const poll = setInterval(async () => {
const data = await apiRequest<Notification[]>('/api/notifications/unread');
setNotifications(data);
}, 30000); // Poll every 30 seconds
return () => clearInterval(poll);
}, []);
return notifications;
};
Quick Reference
Endpoint Summary
| Module | Base URL | CRUD | Submit | Grade | Status |
|---|---|---|---|---|---|
| Quizzes | /api/quizzes |
✅ | POST /attempts/start |
POST /attempts/:id/grade |
PATCH /:id/status |
| Assignments | /api/assignments |
✅ | POST /:id/submit |
PATCH /:id/submissions/:subId/grade |
PATCH /:id/status |
| Labs | /api/labs |
✅ | POST /:id/submit |
PATCH /:id/submissions/:subId/grade |
PATCH /:id/status |
Status Values
All three modules use the same status values:
draft- Not visible to studentspublished- Available to studentsclosed- No longer accepting submissionsarchived- Hidden from all views
Test Accounts
| Role | Password | |
|---|---|---|
| Admin | admin.tarek@example.com | SecureP@ss123 |
| Instructor | instructor.tarek@example.com | SecureP@ss123 |
| TA | ta.tarek@example.com | SecureP@ss123 |
| Student | student.tarek@example.com | SecureP@ss123 |