Skip to content

Commit 8aa3cbe

Browse files
authored
Merge pull request #98 from Moonwalker-rgb/feature/issue-76-zod-forms
feat(frontend): add Zod schema validation to all frontend forms (with profile.test.tsx clean-up) — closes #76
2 parents daa2e8b + 71388c0 commit 8aa3cbe

11 files changed

Lines changed: 1175 additions & 111 deletions

frontend/src/components/AchievementDisplay.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,15 @@ export function AchievementDisplay({
247247

248248
{/* Category Filter */}
249249
{filterable && (
250-
<div className="flex gap-2">
250+
<div className="flex gap-2 items-center">
251+
<label
252+
htmlFor="achievement-category-filter"
253+
className="text-sm font-medium text-gray-700 dark:text-gray-300"
254+
>
255+
Category
256+
</label>
251257
<select
258+
id="achievement-category-filter"
252259
value={selectedCategory}
253260
onChange={(e) => setSelectedCategory(e.target.value)}
254261
className="px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white text-sm"

frontend/src/components/AssignmentSubmission.tsx

Lines changed: 86 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
*/
55

66
import React, { useState, useCallback, useRef } from 'react';
7-
import {
8-
Upload,
9-
FileText,
10-
Code,
11-
Video,
12-
Music,
13-
Clock,
7+
import {
8+
Upload,
9+
FileText,
10+
Code,
11+
Video,
12+
Music,
13+
Clock,
1414
AlertCircle,
1515
CheckCircle,
1616
Save,
@@ -19,6 +19,11 @@ import {
1919
File
2020
} from 'lucide-react';
2121
import { toast } from 'react-hot-toast';
22+
import {
23+
assignmentTextSchema,
24+
assignmentCodeSchema,
25+
validateFile as validateFileMeta,
26+
} from '@/lib/schemas';
2227

2328
interface Assignment {
2429
id: string;
@@ -71,6 +76,9 @@ export default function AssignmentSubmission({
7176
const [isSaving, setIsSaving] = useState(false);
7277
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>(existingSubmission?.files || []);
7378
const [dragActive, setDragActive] = useState(false);
79+
// Inline form errors. Shape is keyed by submissionType + field when useful.
80+
const [formError, setFormError] = useState<string | null>(null);
81+
const [fileErrors, setFileErrors] = useState<Record<string, string>>({});
7482
const fileInputRef = useRef<HTMLInputElement>(null);
7583

7684
const isLate = new Date() > new Date(assignment.dueDate);
@@ -91,25 +99,40 @@ export default function AssignmentSubmission({
9199
if (!files) return;
92100

93101
const newFiles: UploadedFile[] = [];
102+
const nextFileErrors: Record<string, string> = { ...fileErrors };
94103
const maxSize = assignment.maxFileSize ? assignment.maxFileSize * 1024 * 1024 : 100 * 1024 * 1024; // Default 100MB
95104
const maxFiles = assignment.maxFiles || 10;
96105

97106
Array.from(files).forEach((file) => {
98107
if (uploadedFiles.length + newFiles.length >= maxFiles) {
99-
toast.error(`Maximum ${maxFiles} files allowed`);
108+
const msg = `Maximum ${maxFiles} files allowed`;
109+
toast.error(msg);
110+
nextFileErrors[file.name] = msg;
100111
return;
101112
}
102113

114+
// Schema-level validation (size + mime type). We additionally enforce
115+
// the caller's `maxSize` override and `allowedFileTypes` extension
116+
// list because `fileMetaSchema` doesn't see component props.
117+
const schemaResult = validateFileMeta(file);
118+
if (!schemaResult.valid) {
119+
toast.error(`${file.name}: ${schemaResult.error}`);
120+
nextFileErrors[file.name] = schemaResult.error;
121+
return;
122+
}
103123
if (file.size > maxSize) {
104-
toast.error(`File ${file.name} exceeds maximum size limit`);
124+
const msg = `File ${file.name} exceeds maximum size limit`;
125+
toast.error(msg);
126+
nextFileErrors[file.name] = msg;
105127
return;
106128
}
107129

108-
// Check file type if restrictions exist
109130
if (assignment.allowedFileTypes && assignment.allowedFileTypes.length > 0) {
110131
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
111132
if (!assignment.allowedFileTypes.includes(fileExtension)) {
112-
toast.error(`File type ${fileExtension} is not allowed`);
133+
const msg = `File type ${fileExtension} is not allowed`;
134+
toast.error(msg);
135+
nextFileErrors[file.name] = msg;
113136
return;
114137
}
115138
}
@@ -124,7 +147,14 @@ export default function AssignmentSubmission({
124147
});
125148

126149
setUploadedFiles(prev => [...prev, ...newFiles]);
127-
}, [uploadedFiles, assignment.maxFileSize, assignment.maxFiles, assignment.allowedFileTypes]);
150+
setFileErrors(nextFileErrors);
151+
}, [
152+
uploadedFiles,
153+
fileErrors,
154+
assignment.maxFileSize,
155+
assignment.maxFiles,
156+
assignment.allowedFileTypes,
157+
]);
128158

129159
const handleDrag = useCallback((e: React.DragEvent) => {
130160
e.preventDefault();
@@ -152,26 +182,46 @@ export default function AssignmentSubmission({
152182

153183
const handleSubmit = async () => {
154184
if (!canSubmit) {
185+
setFormError('Submission is not allowed');
155186
toast.error('Submission is not allowed');
156187
return;
157188
}
158189

159-
// Validate submission based on assignment requirements
160-
if (assignment.submissionTypes.includes('text') && !submissionData.textContent.trim()) {
161-
toast.error('Text content is required');
162-
return;
190+
// Text-only / text-bearing assignments: use Zod schema. The same
191+
// schema is also enforced server-side (or will be), so client- and
192+
// server-side copies can't drift apart.
193+
if (assignment.submissionTypes.includes('text')) {
194+
const result = assignmentTextSchema.safeParse(submissionData);
195+
if (!result.success) {
196+
const msg = result.error.issues[0]?.message ?? 'Text content is required';
197+
setFormError(msg);
198+
toast.error(msg);
199+
return;
200+
}
163201
}
164202

165-
if (assignment.submissionTypes.includes('file') && uploadedFiles.length === 0) {
166-
toast.error('At least one file must be uploaded');
167-
return;
203+
if (assignment.submissionTypes.includes('code')) {
204+
const result = assignmentCodeSchema.safeParse(submissionData.codeSubmission);
205+
if (!result.success) {
206+
const msg = result.error.issues[0]?.message ?? 'Code submission is required';
207+
setFormError(msg);
208+
toast.error(msg);
209+
return;
210+
}
168211
}
169212

170-
if (assignment.submissionTypes.includes('code') && !submissionData.codeSubmission.code.trim()) {
171-
toast.error('Code submission is required');
213+
// File count remains a domain-level check (the Zod `fileMetaSchema`
214+
// validates *each* file but doesn't know "at least one"). File-level
215+
// metadata errors already surfaced inline via `setFileErrors`
216+
// during upload; here we gate on the count.
217+
if (assignment.submissionTypes.includes('file') && uploadedFiles.length === 0) {
218+
const msg = 'At least one file must be uploaded';
219+
setFormError(msg);
220+
toast.error(msg);
172221
return;
173222
}
174223

224+
setFormError(null);
175225
setIsSubmitting(true);
176226
try {
177227
const submissionPayload = {
@@ -182,7 +232,9 @@ export default function AssignmentSubmission({
182232
await onSubmit(submissionPayload);
183233
toast.success('Assignment submitted successfully!');
184234
} catch (error) {
185-
toast.error('Failed to submit assignment');
235+
const msg = 'Failed to submit assignment';
236+
setFormError(msg);
237+
toast.error(msg);
186238
console.error('Submission error:', error);
187239
} finally {
188240
setIsSubmitting(false);
@@ -397,6 +449,18 @@ export default function AssignmentSubmission({
397449
</div>
398450
)}
399451

452+
{/* Top-level form error banner so screen readers pick up the news. */}
453+
{formError && (
454+
<div
455+
role="alert"
456+
aria-live="assertive"
457+
className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3"
458+
>
459+
<AlertCircle className="w-5 h-5 text-red-600" />
460+
<p className="text-sm text-red-800">{formError}</p>
461+
</div>
462+
)}
463+
400464
{/* Action Buttons */}
401465
<div className="flex justify-between items-center">
402466
<button

frontend/src/components/ContentUploader.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState, useCallback, useRef } from 'react';
22
import { Upload, X, File, Image, Video, Music, FileText, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
33
import ipfsClient, { IpfsUploadOptions, IpfsUploadResult, UploadProgress } from '../lib/ipfs';
4+
import { validateFile as validateFileMeta } from '../lib/schemas';
45

56
interface ContentUploaderProps {
67
onUploadComplete?: (result: IpfsUploadResult) => void;
@@ -80,14 +81,22 @@ const ContentUploader: React.FC<ContentUploaderProps> = ({
8081
});
8182
};
8283

83-
// Validate file
84+
// Validate file. The component's `acceptedTypes` and `maxSize` props
85+
// are the source of truth, so we honour them first and only fall back
86+
// to the shared Zod `fileMetaSchema` for files that fall in the default
87+
// constructor-arg accepted list. This preserves the original behaviour
88+
// where caller's `acceptedTypes` overrides always win.
8489
const validateFile = (file: File): string | null => {
8590
if (!acceptedTypes.includes(file.type)) {
8691
return 'File type not supported';
8792
}
8893
if (file.size > maxSize) {
8994
return `File size exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`;
9095
}
96+
if (file.size === 0) {
97+
const schemaResult = validateFileMeta(file);
98+
if (!schemaResult.valid) return schemaResult.error;
99+
}
91100
return null;
92101
};
93102

0 commit comments

Comments
 (0)