Skip to content

Commit b4409ad

Browse files
WangYuTenggclaude
andcommitted
fix(course-settings): persist automation toggles to backend
Save Settings button on the per-course Settings tab popped a placeholder alert. Now it loads + persists the two toggles (auto-grade on submit, MCQ-only) via new GET/PUT /api/courses/:id/automation-settings, stored in system_settings under course-scoped keys. UI shows inline success or error feedback instead of an alert(). Field set is disabled while loading/saving. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2a70fe8 commit b4409ad

4 files changed

Lines changed: 194 additions & 15 deletions

File tree

src/client/features/staff-course/StaffCourseDetail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ export function StaffCourseDetail({ courseId }: StaffCourseDetailProps) {
383383
Configure course-level automatic grading behavior.
384384
</p>
385385
</div>
386-
<SettingsTab />
386+
<SettingsTab courseId={courseId} />
387387
</div>
388388
)}
389389
</div>

src/client/features/staff-grading/components/SettingsTab.tsx

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,58 @@
1-
import { useState } from 'react';
1+
import { useEffect, useState } from 'react';
2+
import { apiClient } from '../../../lib/api';
23

3-
export function SettingsTab() {
4+
type AutomationSettings = {
5+
autoGradeOnSubmit: boolean;
6+
autoGradeMcqOnly: boolean;
7+
};
8+
9+
type SettingsTabProps = {
10+
courseId: string;
11+
};
12+
13+
export function SettingsTab({ courseId }: SettingsTabProps) {
414
const [autoGradeOnSubmit, setAutoGradeOnSubmit] = useState(false);
5-
const [autoGradeMCQOnly, setAutoGradeMCQOnly] = useState(true);
15+
const [autoGradeMcqOnly, setAutoGradeMcqOnly] = useState(true);
16+
const [loading, setLoading] = useState(true);
17+
const [saving, setSaving] = useState(false);
18+
const [feedback, setFeedback] = useState<{ kind: 'success' | 'error'; message: string } | null>(null);
19+
20+
useEffect(() => {
21+
let cancelled = false;
22+
setLoading(true);
23+
apiClient<AutomationSettings>(`/api/courses/${courseId}/automation-settings`)
24+
.then((data) => {
25+
if (cancelled) return;
26+
setAutoGradeOnSubmit(data.autoGradeOnSubmit);
27+
setAutoGradeMcqOnly(data.autoGradeMcqOnly);
28+
})
29+
.catch((err) => {
30+
if (cancelled) return;
31+
setFeedback({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to load settings' });
32+
})
33+
.finally(() => {
34+
if (!cancelled) setLoading(false);
35+
});
36+
return () => {
37+
cancelled = true;
38+
};
39+
}, [courseId]);
40+
41+
const handleSave = async () => {
42+
setSaving(true);
43+
setFeedback(null);
44+
try {
45+
await apiClient<AutomationSettings>(`/api/courses/${courseId}/automation-settings`, {
46+
method: 'PUT',
47+
body: JSON.stringify({ autoGradeOnSubmit, autoGradeMcqOnly }),
48+
});
49+
setFeedback({ kind: 'success', message: 'Settings saved.' });
50+
} catch (err) {
51+
setFeedback({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to save settings' });
52+
} finally {
53+
setSaving(false);
54+
}
55+
};
656

757
return (
858
<div className="space-y-6">
@@ -13,15 +63,15 @@ export function SettingsTab() {
1363
Configure automatic grading to run when submissions are received
1464
</p>
1565

16-
<div className="space-y-4">
66+
<fieldset disabled={loading || saving} className="space-y-4">
1767
{/* Auto-grade on submit toggle */}
1868
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
1969
<div className="flex-1">
2070
<label htmlFor="auto-grade-submit" className="font-medium text-gray-900 block mb-1">
2171
Auto-grade on submission
2272
</label>
2373
<p className="text-sm text-gray-600">
24-
Automatically grade MCQ questions when a student submits their assignment
74+
Automatically grade questions when a student submits their assignment
2575
</p>
2676
</div>
2777
<label className="relative inline-flex items-center cursor-pointer ml-4">
@@ -50,25 +100,36 @@ export function SettingsTab() {
50100
<input
51101
id="mcq-only"
52102
type="checkbox"
53-
checked={autoGradeMCQOnly}
54-
onChange={(e) => setAutoGradeMCQOnly(e.target.checked)}
103+
checked={autoGradeMcqOnly}
104+
onChange={(e) => setAutoGradeMcqOnly(e.target.checked)}
55105
className="sr-only peer"
56106
/>
57107
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
58108
</label>
59109
</div>
60-
</div>
110+
</fieldset>
111+
112+
{feedback && (
113+
<div
114+
role="status"
115+
className={`mt-4 px-4 py-2 rounded-md text-sm ${
116+
feedback.kind === 'success'
117+
? 'bg-green-50 border border-green-200 text-green-800'
118+
: 'bg-red-50 border border-red-200 text-red-800'
119+
}`}
120+
>
121+
{feedback.message}
122+
</div>
123+
)}
61124

62125
<div className="mt-6 flex justify-end">
63126
<button
64127
type="button"
65-
onClick={() => {
66-
// TODO: Save settings to backend
67-
alert('Settings saved! (This is a placeholder - functionality to be implemented)');
68-
}}
69-
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors"
128+
onClick={handleSave}
129+
disabled={loading || saving}
130+
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white font-medium py-2 px-6 rounded-lg transition-colors"
70131
>
71-
Save Settings
132+
{saving ? 'Saving…' : 'Save Settings'}
72133
</button>
73134
</div>
74135
</div>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Hono } from 'hono';
2+
import { z } from 'zod';
3+
import { eq, inArray } from 'drizzle-orm';
4+
import { db } from '../../../db/index.js';
5+
import { courses, systemSettings } from '../../../db/schema.js';
6+
import { authMiddleware, requireRole, type AuthContext } from '../../middleware/auth.js';
7+
import { HTTPException } from 'hono/http-exception';
8+
9+
const automationSettingsRoute = new Hono<AuthContext>();
10+
11+
const AUTO_GRADE_ON_SUBMIT_KEY = (id: string) => `course.${id}.auto_grade_on_submit`;
12+
const AUTO_GRADE_MCQ_ONLY_KEY = (id: string) => `course.${id}.auto_grade_mcq_only`;
13+
14+
const updateSchema = z.object({
15+
autoGradeOnSubmit: z.boolean(),
16+
autoGradeMcqOnly: z.boolean(),
17+
});
18+
19+
const parseBool = (value: string | undefined, fallback: boolean): boolean => {
20+
if (value === 'true') return true;
21+
if (value === 'false') return false;
22+
return fallback;
23+
};
24+
25+
/**
26+
* GET /api/courses/:id/automation-settings
27+
* Returns the course's auto-grading toggles. Defaults: off / mcq-only on.
28+
*/
29+
automationSettingsRoute.get(
30+
'/:id/automation-settings',
31+
authMiddleware,
32+
requireRole('staff', 'admin'),
33+
async (c) => {
34+
const courseId = c.req.param('id');
35+
36+
const [course] = await db
37+
.select({ id: courses.id })
38+
.from(courses)
39+
.where(eq(courses.id, courseId))
40+
.limit(1);
41+
42+
if (!course) return c.json({ error: 'Course not found' }, 404);
43+
44+
const rows = await db
45+
.select()
46+
.from(systemSettings)
47+
.where(
48+
inArray(systemSettings.key, [
49+
AUTO_GRADE_ON_SUBMIT_KEY(courseId),
50+
AUTO_GRADE_MCQ_ONLY_KEY(courseId),
51+
])
52+
);
53+
54+
const byKey = new Map(rows.map((r) => [r.key, r.value]));
55+
56+
return c.json({
57+
autoGradeOnSubmit: parseBool(byKey.get(AUTO_GRADE_ON_SUBMIT_KEY(courseId)), false),
58+
autoGradeMcqOnly: parseBool(byKey.get(AUTO_GRADE_MCQ_ONLY_KEY(courseId)), true),
59+
});
60+
}
61+
);
62+
63+
/**
64+
* PUT /api/courses/:id/automation-settings
65+
*/
66+
automationSettingsRoute.put(
67+
'/:id/automation-settings',
68+
authMiddleware,
69+
requireRole('staff', 'admin'),
70+
async (c) => {
71+
const user = c.get('user')!;
72+
const courseId = c.req.param('id');
73+
74+
const [course] = await db
75+
.select({ id: courses.id })
76+
.from(courses)
77+
.where(eq(courses.id, courseId))
78+
.limit(1);
79+
80+
if (!course) return c.json({ error: 'Course not found' }, 404);
81+
82+
const body = await c.req.json();
83+
const parsed = updateSchema.safeParse(body);
84+
if (!parsed.success) {
85+
throw new HTTPException(400, { message: 'Invalid request', cause: parsed.error });
86+
}
87+
88+
const { autoGradeOnSubmit, autoGradeMcqOnly } = parsed.data;
89+
const now = new Date();
90+
91+
const upsert = async (key: string, value: string, description: string) => {
92+
await db
93+
.insert(systemSettings)
94+
.values({ key, value, description, updatedBy: user.id })
95+
.onConflictDoUpdate({
96+
target: systemSettings.key,
97+
set: { value, updatedAt: now, updatedBy: user.id },
98+
});
99+
};
100+
101+
await upsert(
102+
AUTO_GRADE_ON_SUBMIT_KEY(courseId),
103+
String(autoGradeOnSubmit),
104+
'Per-course: auto-grade on submission'
105+
);
106+
await upsert(
107+
AUTO_GRADE_MCQ_ONLY_KEY(courseId),
108+
String(autoGradeMcqOnly),
109+
'Per-course: auto-grade MCQ questions only'
110+
);
111+
112+
return c.json({ success: true, autoGradeOnSubmit, autoGradeMcqOnly });
113+
}
114+
);
115+
116+
export default automationSettingsRoute;

src/server/routes/courses/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import listEnrollmentsRoute from './list-enrollments.js';
88
import bulkEnrollRoute from './bulk-enroll.js';
99
import removeEnrollmentRoute from './remove-enrollment.js';
1010
import exportGradesRoute from './export-grades.js';
11+
import automationSettingsRoute from './automation-settings.js';
1112

1213
const courses = new Hono<AuthContext>();
1314

@@ -19,5 +20,6 @@ courses.route('/', listEnrollmentsRoute);
1920
courses.route('/', bulkEnrollRoute);
2021
courses.route('/', removeEnrollmentRoute);
2122
courses.route('/', exportGradesRoute);
23+
courses.route('/', automationSettingsRoute);
2224

2325
export default courses;

0 commit comments

Comments
 (0)