Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab8aab43c |
@@ -31,11 +31,13 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
|
||||
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
||||
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
||||
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
||||
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
|
||||
vi.mock('./pages/exam', () => ({
|
||||
ExamDashboardPage: () => <div>Exam Marker</div>,
|
||||
MarkSchemePage: () => <div>Mark Scheme editor</div>,
|
||||
}));
|
||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||
vi.mock('./pages/tldraw/ExamMarkerSpikePage', () => ({ default: () => <div>Exam Marker Spike</div> }));
|
||||
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
|
||||
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
|
||||
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
|
||||
@@ -123,3 +125,22 @@ describe('/admin route authorization', () => {
|
||||
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('exam-marker routes', () => {
|
||||
beforeEach(() => {
|
||||
mockUseAuth.mockReset();
|
||||
});
|
||||
|
||||
it('renders the mark scheme editor route for authenticated users', () => {
|
||||
mockUseAuth.mockReturnValue(authState({
|
||||
user: { id: 'teacher-1', email: '[email protected]', user_type: 'email_teacher' },
|
||||
user_role: 'email_teacher',
|
||||
accessToken: 'token',
|
||||
}));
|
||||
|
||||
renderAt('/exam-marker/template-123/marks');
|
||||
|
||||
expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+2
-3
@@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
|
||||
import SignupPage from './pages/auth/signupPage';
|
||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||
import { ExamDashboardPage } from './pages/exam';
|
||||
import { ExamDashboardPage, MarkSchemePage } from './pages/exam';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import CalendarPage from './pages/user/calendarPage';
|
||||
import SettingsPage from './pages/user/settingsPage';
|
||||
@@ -16,7 +16,6 @@ import AdminDashboard from './pages/auth/adminPage';
|
||||
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
|
||||
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
||||
import DevPage from './pages/tldraw/devPage';
|
||||
import ExamMarkerSpikePage from './pages/tldraw/ExamMarkerSpikePage';
|
||||
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
||||
import MorphicPage from './pages/morphicPage';
|
||||
import NotFound from './pages/user/NotFound';
|
||||
@@ -184,10 +183,10 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/search" element={<SearxngPage />} />
|
||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
|
||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||
<Route path="/morphic" element={<MorphicPage />} />
|
||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||
<Route path="/exam-marker-spike" element={<ExamMarkerSpikePage />} />
|
||||
<Route path="/dev" element={<DevPage />} />
|
||||
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
|
||||
<Route path="/single-player" element={<SinglePlayerPage />} />
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import GradingIcon from '@mui/icons-material/Grading';
|
||||
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
@@ -46,7 +47,6 @@ const ExamDashboardPage: React.FC = () => {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [sourcePdf, setSourcePdf] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -67,18 +67,6 @@ const ExamDashboardPage: React.FC = () => {
|
||||
void load();
|
||||
}, [load, instituteId]);
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseCreate = () => {
|
||||
if (saving) return;
|
||||
setCreateOpen(false);
|
||||
setTitle('');
|
||||
setSubject('');
|
||||
setSourcePdf(null);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
@@ -87,12 +75,10 @@ const ExamDashboardPage: React.FC = () => {
|
||||
title: title.trim(),
|
||||
subject: subject.trim() || undefined,
|
||||
institute_id: instituteId,
|
||||
source_pdf: sourcePdf,
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setTitle('');
|
||||
setSubject('');
|
||||
setSourcePdf(null);
|
||||
navigate(`/exam-marker/${created.id}/setup`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -127,7 +113,7 @@ const ExamDashboardPage: React.FC = () => {
|
||||
Build a template for an exam paper, then run marking batches against your classes.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
New template
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -149,7 +135,7 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Create your first template to start mapping an exam paper.
|
||||
</Typography>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
New template
|
||||
</Button>
|
||||
</Paper>
|
||||
@@ -159,16 +145,8 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
transition: 'box-shadow 120ms',
|
||||
'&:hover': { boxShadow: 6 },
|
||||
}}
|
||||
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
||||
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
@@ -191,6 +169,16 @@ const ExamDashboardPage: React.FC = () => {
|
||||
{new Date(t.updated_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} sx={{ pt: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
|
||||
startIcon={<GradingIcon fontSize="small" />}
|
||||
>
|
||||
Edit marks
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
@@ -198,7 +186,7 @@ const ExamDashboardPage: React.FC = () => {
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dialog open={createOpen} onClose={handleCloseCreate} fullWidth maxWidth="sm">
|
||||
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||
<DialogTitle>New exam template</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
@@ -216,24 +204,10 @@ const ExamDashboardPage: React.FC = () => {
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<Box>
|
||||
<Button variant="outlined" component="label">
|
||||
{sourcePdf ? 'Replace source PDF' : 'Attach source PDF'}
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={(e) => setSourcePdf(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{sourcePdf ? sourcePdf.name : 'Optional: upload a PDF source for this template.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseCreate} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||
{saving ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import SyncIcon from '@mui/icons-material/Sync';
|
||||
|
||||
import { logger } from '../../debugConfig';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
import type {
|
||||
ExamQuestion,
|
||||
ExamTemplateDetail,
|
||||
MarkScheme,
|
||||
MarkSchemeType,
|
||||
SpecPoint,
|
||||
} from '../../types/exam.types';
|
||||
|
||||
type Drafts = Record<string, PartDraft>;
|
||||
|
||||
interface PartDraft {
|
||||
max_marks: string;
|
||||
answer_type: 'written' | 'mcq' | 'short' | 'diagram';
|
||||
spec_ref: string;
|
||||
schemeType: MarkSchemeType;
|
||||
body: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const ANSWER_TYPES: Array<PartDraft['answer_type']> = ['written', 'short', 'mcq', 'diagram'];
|
||||
const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [
|
||||
{ value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' },
|
||||
{ value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' },
|
||||
{ value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' },
|
||||
{ value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' },
|
||||
{ value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' },
|
||||
];
|
||||
|
||||
function deriveSpecCode(template: ExamTemplateDetail | null): string {
|
||||
const examCode = template?.exam_code ?? '';
|
||||
const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i);
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
||||
function schemeToDraft(scheme: MarkScheme | null | undefined): Pick<PartDraft, 'schemeType' | 'body' | 'notes'> {
|
||||
const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points';
|
||||
const notes = typeof scheme?.notes === 'string' ? scheme.notes : '';
|
||||
|
||||
if (type === 'levels' && Array.isArray(scheme?.levels)) {
|
||||
return {
|
||||
schemeType: 'levels',
|
||||
body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (type === 'parts' && Array.isArray(scheme?.parts)) {
|
||||
return {
|
||||
schemeType: 'parts',
|
||||
body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (type === 'checklist' && Array.isArray(scheme?.checklist)) {
|
||||
return {
|
||||
schemeType: 'checklist',
|
||||
body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (type === 'free') {
|
||||
return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes };
|
||||
}
|
||||
if (Array.isArray(scheme?.points)) {
|
||||
return {
|
||||
schemeType: 'points',
|
||||
body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
return { schemeType: type, body: '', notes };
|
||||
}
|
||||
|
||||
function parseNumber(value: string, fallback = 0): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function splitLine(line: string): string[] {
|
||||
return line.split('|').map((part) => part.trim());
|
||||
}
|
||||
|
||||
function draftToScheme(draft: PartDraft): MarkScheme {
|
||||
const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined };
|
||||
if (draft.schemeType === 'levels') {
|
||||
return {
|
||||
type: 'levels',
|
||||
levels: lines.map((line, idx) => {
|
||||
const [level, range, descriptor] = splitLine(line);
|
||||
const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim());
|
||||
return {
|
||||
level: level || `L${idx + 1}`,
|
||||
min: parseNumber(minRaw, 0),
|
||||
max: parseNumber(maxRaw, parseNumber(minRaw, 0)),
|
||||
descriptor: descriptor || line,
|
||||
};
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
if (draft.schemeType === 'parts') {
|
||||
return {
|
||||
type: 'parts',
|
||||
parts: lines.map((line, idx) => {
|
||||
const [label, marks, guidance] = splitLine(line);
|
||||
return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line };
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
if (draft.schemeType === 'checklist') {
|
||||
return {
|
||||
type: 'checklist',
|
||||
checklist: lines.map((line) => {
|
||||
const [marks, text] = splitLine(line);
|
||||
return { marks: parseNumber(marks, 1), text: text || line };
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'points',
|
||||
points: lines.map((line) => {
|
||||
const [mark, text] = splitLine(line);
|
||||
return { mark: parseNumber(mark, 1), text: text || line };
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDraft(question: ExamQuestion): PartDraft {
|
||||
const scheme = schemeToDraft(question.mark_scheme);
|
||||
return {
|
||||
max_marks: String(question.max_marks ?? 0),
|
||||
answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written',
|
||||
spec_ref: question.spec_ref ?? '',
|
||||
schemeType: scheme.schemeType,
|
||||
body: scheme.body,
|
||||
notes: scheme.notes,
|
||||
};
|
||||
}
|
||||
|
||||
const MarkSchemePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { templateId } = useParams<{ templateId: string }>();
|
||||
|
||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Drafts>({});
|
||||
const [specCode, setSpecCode] = useState('');
|
||||
const [specPoints, setSpecPoints] = useState<SpecPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [specEndpointMissing, setSpecEndpointMissing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!templateId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const detail = await examRepository.getTemplate(templateId);
|
||||
const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
|
||||
setTemplate(detail);
|
||||
setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null);
|
||||
setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)])));
|
||||
setSpecCode((prev) => prev || deriveSpecCode(detail));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [templateId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const loadSpecPoints = useCallback(async () => {
|
||||
if (!specCode.trim()) return;
|
||||
setSpecEndpointMissing(false);
|
||||
try {
|
||||
setSpecPoints(await examRepository.listSpecPoints(specCode.trim()));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode });
|
||||
setSpecEndpointMissing(true);
|
||||
setSpecPoints([]);
|
||||
}
|
||||
}, [specCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSpecPoints();
|
||||
}, [loadSpecPoints]);
|
||||
|
||||
const parts = useMemo(
|
||||
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)),
|
||||
[template],
|
||||
);
|
||||
const selected = parts.find((p) => p.id === selectedId) ?? null;
|
||||
const draft = selected ? drafts[selected.id] : null;
|
||||
const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null;
|
||||
|
||||
const updateDraft = (questionId: string, patch: Partial<PartDraft>) => {
|
||||
setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } }));
|
||||
};
|
||||
|
||||
const savePart = async (question: ExamQuestion) => {
|
||||
const partDraft = drafts[question.id];
|
||||
if (!partDraft) return;
|
||||
setSavingId(question.id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await examRepository.patchQuestion(question.id, {
|
||||
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||
answer_type: partDraft.answer_type,
|
||||
mark_scheme: draftToScheme(partDraft),
|
||||
spec_ref: partDraft.spec_ref.trim() || null,
|
||||
});
|
||||
setTemplate((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
questions: prev.questions.map((q) => q.id === question.id ? {
|
||||
...q,
|
||||
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||
answer_type: partDraft.answer_type,
|
||||
mark_scheme: draftToScheme(partDraft),
|
||||
spec_ref: partDraft.spec_ref.trim() || null,
|
||||
} : q),
|
||||
};
|
||||
});
|
||||
setNotice(`Saved mark scheme for Part ${question.label}.`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const syncGraph = async () => {
|
||||
if (!templateId) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await examRepository.syncTemplateToGraph(templateId);
|
||||
setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||
Back to templates
|
||||
</Button>
|
||||
<Typography variant="h3" component="h1">Mark Scheme editor</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{templateId && <Button variant="outlined" onClick={() => navigate(`/exam-marker/${templateId}/setup`)}>Setup canvas</Button>}
|
||||
<Button variant="outlined" startIcon={<SyncIcon />} onClick={syncGraph} disabled={syncing || !templateId}>
|
||||
{syncing ? 'Syncing…' : 'Sync to graph'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||
{notice && <Alert severity="success" onClose={() => setNotice(null)}>{notice}</Alert>}
|
||||
{specEndpointMissing && (
|
||||
<Alert severity="warning">
|
||||
SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label="Specification code"
|
||||
value={specCode}
|
||||
onChange={(e) => setSpecCode(e.target.value)}
|
||||
fullWidth
|
||||
helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker."
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]->(:SpecPoint).
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{parts.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes.
|
||||
</Alert>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Parts</Typography>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
value={selectedId ?? false}
|
||||
onChange={(_, value) => setSelectedId(value)}
|
||||
variant="scrollable"
|
||||
sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }}
|
||||
>
|
||||
{parts.map((part) => (
|
||||
<Tab
|
||||
key={part.id}
|
||||
value={part.id}
|
||||
label={
|
||||
<Box sx={{ textAlign: 'left', width: '100%' }}>
|
||||
<Typography variant="body2">Part {part.label}</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
||||
<Chip size="small" label={`${drafts[part.id]?.max_marks ?? part.max_marks} marks`} />
|
||||
{(drafts[part.id]?.spec_ref || part.spec_ref) && <Chip size="small" color="info" label={drafts[part.id]?.spec_ref || part.spec_ref || ''} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={9}>
|
||||
{selected && draft && (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Typography variant="h5">Part {selected.label}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
disabled={savingId === selected.id}
|
||||
onClick={() => savePart(selected)}
|
||||
>
|
||||
{savingId === selected.id ? 'Saving…' : 'Save part'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Max marks"
|
||||
value={draft.max_marks}
|
||||
onChange={(e) => updateDraft(selected.id, { max_marks: e.target.value })}
|
||||
type="number"
|
||||
inputProps={{ min: 0, step: 0.5 }}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="answer-type-label">Answer type</InputLabel>
|
||||
<Select
|
||||
labelId="answer-type-label"
|
||||
label="Answer type"
|
||||
value={draft.answer_type}
|
||||
onChange={(e) => updateDraft(selected.id, { answer_type: e.target.value as PartDraft['answer_type'] })}
|
||||
>
|
||||
{ANSWER_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="scheme-type-label">Scheme form</InputLabel>
|
||||
<Select
|
||||
labelId="scheme-type-label"
|
||||
label="Scheme form"
|
||||
value={draft.schemeType}
|
||||
onChange={(e) => updateDraft(selected.id, { schemeType: e.target.value as MarkSchemeType })}
|
||||
>
|
||||
{SCHEME_TYPES.map((type) => <MenuItem key={type.value} value={type.value}>{type.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Autocomplete
|
||||
options={specPoints}
|
||||
value={selectedSpecPoint}
|
||||
onChange={(_, value) => updateDraft(selected.id, { spec_ref: value?.ref ?? '' })}
|
||||
getOptionLabel={(option) => `${option.ref} — ${option.description}`}
|
||||
isOptionEqualToValue={(option, value) => option.ref === value.ref}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="SpecPoint picker"
|
||||
helperText={specPoints.length ? 'Choose a seeded SpecPoint, or type below to override.' : 'No picker results loaded; type spec_ref manually below.'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="spec_ref"
|
||||
value={draft.spec_ref}
|
||||
onChange={(e) => updateDraft(selected.id, { spec_ref: e.target.value })}
|
||||
helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint."
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TextField
|
||||
label={`${SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`}
|
||||
value={draft.body}
|
||||
onChange={(e) => updateDraft(selected.id, { body: e.target.value })}
|
||||
helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper}
|
||||
multiline
|
||||
minRows={10}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Internal marking notes"
|
||||
value={draft.notes}
|
||||
onChange={(e) => updateDraft(selected.id, { notes: e.target.value })}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkSchemePage;
|
||||
@@ -1 +1,2 @@
|
||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||
export { default as MarkSchemePage } from './MarkSchemePage';
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Tldraw,
|
||||
createTLStore,
|
||||
createTLSchemaFromUtils,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
Editor,
|
||||
TLAnyBindingUtilConstructor,
|
||||
TLAnyShapeUtilConstructor,
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
} from '@tldraw/tldraw'
|
||||
import { allBindingUtils } from '../../utils/tldraw/bindings'
|
||||
import { allShapeUtils } from '../../utils/tldraw/shapes'
|
||||
import { customAssets } from '../../utils/tldraw/assets'
|
||||
import { getUiComponents, getUiOverrides } from '../../utils/tldraw/ui-overrides'
|
||||
import { HEADER_HEIGHT } from '../Layout'
|
||||
import { devTools } from '../../utils/tldraw/tools'
|
||||
import {
|
||||
activateExamMarkerTool,
|
||||
EXAM_MARKER_BOX_TYPE,
|
||||
ExamMarkerBoxShapeUtil,
|
||||
ExamMarkerBoxTool,
|
||||
placeExamMarkerSpike,
|
||||
} from '../../utils/tldraw/exam-marker-spike'
|
||||
|
||||
const spikeShapeUtils = [...allShapeUtils, ExamMarkerBoxShapeUtil] as TLAnyShapeUtilConstructor[]
|
||||
const spikeBindingUtils = allBindingUtils as TLAnyBindingUtilConstructor[]
|
||||
const spikeTools = [...devTools, ExamMarkerBoxTool]
|
||||
|
||||
const spikeSchema = createTLSchemaFromUtils({
|
||||
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
|
||||
bindingUtils: [...defaultBindingUtils, ...spikeBindingUtils],
|
||||
})
|
||||
|
||||
export default function ExamMarkerSpikePage() {
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const [status, setStatus] = useState('Ready to insert an image and a custom exam-marker box.')
|
||||
|
||||
const store = useMemo(() => {
|
||||
const nextStore = createTLStore({
|
||||
schema: spikeSchema,
|
||||
shapeUtils: spikeShapeUtils,
|
||||
bindingUtils: spikeBindingUtils,
|
||||
})
|
||||
;(nextStore as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
|
||||
return nextStore
|
||||
}, [])
|
||||
|
||||
const onMount = (editor: Editor) => {
|
||||
editorRef.current = editor
|
||||
}
|
||||
|
||||
const insertSpike = () => {
|
||||
if (!editorRef.current) return
|
||||
const { imageId, boxId } = placeExamMarkerSpike(editorRef.current)
|
||||
const toolId = activateExamMarkerTool(editorRef.current)
|
||||
setStatus(`Inserted image ${imageId} and custom box ${boxId}; active tool is ${toolId}.`)
|
||||
}
|
||||
|
||||
const focusBox = () => {
|
||||
if (!editorRef.current) return
|
||||
const current = editorRef.current.getCurrentPageShapes().find((shape) => shape.type === EXAM_MARKER_BOX_TYPE)
|
||||
if (!current) {
|
||||
setStatus('No exam-marker box found yet.')
|
||||
return
|
||||
}
|
||||
const bounds = editorRef.current.getShapePageBounds(current)
|
||||
if (bounds) {
|
||||
editorRef.current.zoomToBounds(bounds)
|
||||
setStatus('Focused the camera on the exam-marker box using getShapePageBounds + zoomToBounds.')
|
||||
}
|
||||
}
|
||||
|
||||
const uiOverrides = getUiOverrides(false)
|
||||
const uiComponents = getUiComponents(false)
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, top: HEADER_HEIGHT, background: '#f8fafc' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
top: 16,
|
||||
left: 16,
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 16,
|
||||
background: 'rgba(15, 23, 42, 0.9)',
|
||||
color: 'white',
|
||||
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.22)',
|
||||
maxWidth: 880,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<strong style={{ fontSize: 14 }}>Exam-marker spike</strong>
|
||||
<span style={{ fontSize: 12, opacity: 0.9 }}>{status}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={insertSpike}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
background: '#f59e0b',
|
||||
color: '#111827',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Insert image + marker box
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!editorRef.current) return
|
||||
const toolId = activateExamMarkerTool(editorRef.current)
|
||||
setStatus(`Activated tool ${toolId}. Drag on the canvas to create another marker box.`)
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
background: 'transparent',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Activate box tool
|
||||
</button>
|
||||
<button
|
||||
onClick={focusBox}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
background: 'transparent',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Focus marker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tldraw
|
||||
store={store}
|
||||
tools={spikeTools}
|
||||
shapeUtils={spikeShapeUtils}
|
||||
bindingUtils={spikeBindingUtils}
|
||||
components={uiComponents}
|
||||
overrides={uiOverrides}
|
||||
assetUrls={customAssets}
|
||||
autoFocus
|
||||
hideUi={false}
|
||||
inferDarkMode={false}
|
||||
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
|
||||
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
|
||||
maxImageDimension={Infinity}
|
||||
maxAssetSize={100 * 1024 * 1024}
|
||||
onMount={onMount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import type {
|
||||
CreateTemplatePayload,
|
||||
ExamTemplate,
|
||||
ExamTemplateDetail,
|
||||
Neo4jSyncResult,
|
||||
PatchQuestionPayload,
|
||||
SpecPoint,
|
||||
} from '../../types/exam.types';
|
||||
|
||||
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||
@@ -43,24 +46,6 @@ export const examRepository = {
|
||||
|
||||
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||
const headers = await authHeaders();
|
||||
|
||||
if (payload.source_pdf) {
|
||||
const form = new FormData();
|
||||
form.append('title', payload.title);
|
||||
if (payload.subject) form.append('subject', payload.subject);
|
||||
if (payload.exam_id) form.append('exam_id', payload.exam_id);
|
||||
if (payload.exam_code) form.append('exam_code', payload.exam_code);
|
||||
if (payload.source_file_id) form.append('source_file_id', payload.source_file_id);
|
||||
if (payload.page_count !== undefined) form.append('page_count', String(payload.page_count));
|
||||
if (payload.institute_id) form.append('institute_id', payload.institute_id);
|
||||
form.append('source_pdf', payload.source_pdf);
|
||||
|
||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, form, {
|
||||
headers,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
@@ -69,6 +54,28 @@ export const examRepository = {
|
||||
const headers = await authHeaders();
|
||||
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
},
|
||||
|
||||
async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async listSpecPoints(specCode: string, search?: string): Promise<SpecPoint[]> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
|
||||
`${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
|
||||
{ headers, params: search ? { q: search } : undefined },
|
||||
);
|
||||
if (Array.isArray(res.data)) return res.data;
|
||||
return res.data.points ?? [];
|
||||
},
|
||||
|
||||
async syncTemplateToGraph(templateId: string): Promise<Neo4jSyncResult> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default examRepository;
|
||||
|
||||
+66
-3
@@ -27,11 +27,46 @@ export interface CreateTemplatePayload {
|
||||
exam_id?: string;
|
||||
exam_code?: string;
|
||||
source_file_id?: string;
|
||||
source_pdf?: File | null;
|
||||
page_count?: number;
|
||||
institute_id?: string;
|
||||
}
|
||||
|
||||
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
|
||||
|
||||
export interface MarkSchemePoint {
|
||||
mark: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MarkSchemeLevel {
|
||||
level: string;
|
||||
min: number;
|
||||
max: number;
|
||||
descriptor: string;
|
||||
}
|
||||
|
||||
export interface MarkSchemePart {
|
||||
label: string;
|
||||
marks: number;
|
||||
guidance: string;
|
||||
}
|
||||
|
||||
export interface MarkSchemeChecklistItem {
|
||||
text: string;
|
||||
marks: number;
|
||||
}
|
||||
|
||||
export interface MarkScheme {
|
||||
type?: MarkSchemeType;
|
||||
points?: MarkSchemePoint[];
|
||||
levels?: MarkSchemeLevel[];
|
||||
parts?: MarkSchemePart[];
|
||||
checklist?: MarkSchemeChecklistItem[];
|
||||
text?: string;
|
||||
notes?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||
export interface ExamQuestion {
|
||||
id: string;
|
||||
@@ -42,9 +77,11 @@ export interface ExamQuestion {
|
||||
max_marks: number;
|
||||
answer_type: string | null;
|
||||
mcq_options: unknown | null;
|
||||
mark_scheme: Record<string, unknown>;
|
||||
mark_scheme: MarkScheme;
|
||||
is_container: boolean;
|
||||
spec_ref: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
page?: number | null;
|
||||
}
|
||||
|
||||
export interface ExamResponseArea {
|
||||
@@ -53,8 +90,9 @@ export interface ExamResponseArea {
|
||||
template_id: string;
|
||||
page: number;
|
||||
bounds: Record<string, number>;
|
||||
kind: 'response' | 'context';
|
||||
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
|
||||
response_form: string | null;
|
||||
context_type?: string | null;
|
||||
source: 'manual' | 'ai';
|
||||
confirmed: boolean;
|
||||
confidence: number | null;
|
||||
@@ -77,3 +115,28 @@ export interface ExamTemplateDetail extends ExamTemplate {
|
||||
response_areas: ExamResponseArea[];
|
||||
boundaries: ExamBoundary[];
|
||||
}
|
||||
|
||||
export interface PatchQuestionPayload {
|
||||
label?: string;
|
||||
order?: number;
|
||||
max_marks?: number;
|
||||
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||
mcq_options?: unknown;
|
||||
mark_scheme?: MarkScheme;
|
||||
is_container?: boolean;
|
||||
spec_ref?: string | null;
|
||||
}
|
||||
|
||||
export interface SpecPoint {
|
||||
uid?: string;
|
||||
uuid_string?: string;
|
||||
ref: string;
|
||||
description: string;
|
||||
spec_code: string;
|
||||
exam_board_code?: string;
|
||||
}
|
||||
|
||||
export interface Neo4jSyncResult {
|
||||
status: string;
|
||||
projection?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
Editor,
|
||||
createTLStore,
|
||||
createTLSchemaFromUtils,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
} from '@tldraw/tldraw'
|
||||
import {
|
||||
EXAM_MARKER_BOX_TYPE,
|
||||
ExamMarkerBoxShapeUtil,
|
||||
ExamMarkerBoxTool,
|
||||
activateExamMarkerTool,
|
||||
buildExamMarkerSvgDataUrl,
|
||||
getExamMarkerHitTestPoints,
|
||||
insertExamMarkerBox,
|
||||
} from './exam-marker-spike'
|
||||
|
||||
const spikeShapeUtils = [ExamMarkerBoxShapeUtil]
|
||||
const spikeSchema = createTLSchemaFromUtils({
|
||||
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
|
||||
bindingUtils: defaultBindingUtils,
|
||||
})
|
||||
|
||||
if (typeof globalThis.matchMedia !== 'function') {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
} as unknown as MediaQueryList))
|
||||
}
|
||||
|
||||
function makeEditor() {
|
||||
const store = createTLStore({
|
||||
schema: spikeSchema,
|
||||
shapeUtils: spikeShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
})
|
||||
;(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
|
||||
|
||||
return new Editor({
|
||||
shapeUtils: spikeShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: [ExamMarkerBoxTool],
|
||||
store,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('exam-marker spike tldraw helpers', () => {
|
||||
let editor: Editor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = makeEditor()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor.dispose()
|
||||
})
|
||||
|
||||
it('creates a custom box shape and supports point hit-testing + page bounds', () => {
|
||||
const boxId = insertExamMarkerBox(editor)
|
||||
const box = editor.getShape(boxId)
|
||||
|
||||
expect(box?.type).toBe(EXAM_MARKER_BOX_TYPE)
|
||||
expect(editor.getShapePageBounds(boxId)).toBeTruthy()
|
||||
|
||||
const boxPoint = getExamMarkerHitTestPoints(editor, boxId)
|
||||
expect(boxPoint?.bounds).toBeTruthy()
|
||||
expect(editor.getShapesAtPoint(boxPoint!.center).some((shape) => shape.id === boxId)).toBe(true)
|
||||
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
expect(viewport.w).toBeGreaterThan(0)
|
||||
expect(viewport.h).toBeGreaterThan(0)
|
||||
expect(() => editor.zoomToBounds(boxPoint!.bounds)).not.toThrow()
|
||||
})
|
||||
|
||||
it('registers the custom BaseBoxShapeTool and emits a locked svg asset URL', () => {
|
||||
expect(activateExamMarkerTool(editor)).toBe(EXAM_MARKER_BOX_TYPE)
|
||||
expect(editor.getCurrentToolId()).toBe(EXAM_MARKER_BOX_TYPE)
|
||||
|
||||
const svgUrl = buildExamMarkerSvgDataUrl()
|
||||
expect(svgUrl.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true)
|
||||
expect(decodeURIComponent(svgUrl.split(',')[1])).toContain('Exam scan')
|
||||
})
|
||||
})
|
||||
@@ -1,197 +0,0 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
BaseBoxShapeTool,
|
||||
BaseBoxShapeUtil,
|
||||
Editor,
|
||||
HTMLContainer,
|
||||
T,
|
||||
TLBaseBoxShape,
|
||||
TLShapeId,
|
||||
createShapeId,
|
||||
toDomPrecision,
|
||||
} from '@tldraw/tldraw'
|
||||
|
||||
export const EXAM_MARKER_BOX_TYPE = 'exam-marker-box' as const
|
||||
|
||||
export type ExamMarkerBoxShape = TLBaseBoxShape & {
|
||||
type: typeof EXAM_MARKER_BOX_TYPE
|
||||
}
|
||||
|
||||
export class ExamMarkerBoxShapeUtil extends BaseBoxShapeUtil<ExamMarkerBoxShape> {
|
||||
static override type = EXAM_MARKER_BOX_TYPE
|
||||
static override props = {
|
||||
w: T.number,
|
||||
h: T.number,
|
||||
}
|
||||
|
||||
override getDefaultProps(): ExamMarkerBoxShape['props'] {
|
||||
return {
|
||||
w: 320,
|
||||
h: 180,
|
||||
}
|
||||
}
|
||||
|
||||
override component(shape: ExamMarkerBoxShape) {
|
||||
return (
|
||||
<HTMLContainer
|
||||
id={shape.id}
|
||||
style={{
|
||||
width: toDomPrecision(shape.props.w),
|
||||
height: toDomPrecision(shape.props.h),
|
||||
border: '2px solid #b91c1c',
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(254, 242, 242, 0.96))',
|
||||
boxShadow: '0 12px 24px rgba(185, 28, 28, 0.18)',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
color: '#7f1d1d',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Exam marker spike
|
||||
</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.35 }}>
|
||||
This custom box shape uses the app's tldraw schema + BaseBoxShapeTool path.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
background: 'rgba(185, 28, 28, 0.12)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Drag to resize · use the spike tool to create more
|
||||
</div>
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ExamMarkerBoxTool extends BaseBoxShapeTool {
|
||||
static override id = EXAM_MARKER_BOX_TYPE
|
||||
static override initial = 'pointing'
|
||||
shapeType = EXAM_MARKER_BOX_TYPE
|
||||
}
|
||||
|
||||
export function buildExamMarkerSvgDataUrl(label = 'Exam scan') {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="720" viewBox="0 0 960 720">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="100%" stop-color="#f4f7fb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="960" height="720" rx="32" fill="url(#g)" stroke="#cbd5e1" stroke-width="6"/>
|
||||
<rect x="72" y="56" width="816" height="88" rx="18" fill="#1f2937" opacity="0.92"/>
|
||||
<text x="116" y="108" fill="#fff" font-size="42" font-family="Inter, Arial, sans-serif" font-weight="700">${label}</text>
|
||||
<rect x="72" y="176" width="816" height="120" rx="16" fill="#eef2ff" stroke="#c7d2fe"/>
|
||||
<text x="106" y="224" fill="#1e3a8a" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Question 1</text>
|
||||
<text x="106" y="266" fill="#334155" font-size="24" font-family="Inter, Arial, sans-serif">Show your working and annotate the image.</text>
|
||||
<rect x="72" y="332" width="816" height="250" rx="16" fill="#fff7ed" stroke="#fed7aa"/>
|
||||
<text x="106" y="384" fill="#9a3412" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Answer space</text>
|
||||
<rect x="106" y="420" width="700" height="18" rx="9" fill="#fdba74" opacity="0.75"/>
|
||||
<rect x="106" y="470" width="640" height="18" rx="9" fill="#fdba74" opacity="0.55"/>
|
||||
<rect x="106" y="520" width="570" height="18" rx="9" fill="#fdba74" opacity="0.4"/>
|
||||
<text x="106" y="644" fill="#475569" font-size="20" font-family="Inter, Arial, sans-serif">Inserted as a built-in tldraw image shape.</text>
|
||||
</svg>
|
||||
`.trim()
|
||||
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
function getViewportAnchor(editor: Editor) {
|
||||
const bounds = editor.getViewportPageBounds()
|
||||
return {
|
||||
x: bounds.x + bounds.w / 2,
|
||||
y: bounds.y + bounds.h / 2,
|
||||
}
|
||||
}
|
||||
|
||||
export function insertExamMarkerImage(editor: Editor) {
|
||||
const anchor = getViewportAnchor(editor)
|
||||
const imageId = createShapeId()
|
||||
|
||||
editor.createShape({
|
||||
id: imageId,
|
||||
type: 'image',
|
||||
x: anchor.x - 320,
|
||||
y: anchor.y - 240,
|
||||
props: {
|
||||
url: buildExamMarkerSvgDataUrl(),
|
||||
w: 640,
|
||||
h: 480,
|
||||
name: 'exam-marker-sample.svg',
|
||||
},
|
||||
})
|
||||
|
||||
return imageId
|
||||
}
|
||||
|
||||
export function insertExamMarkerBox(editor: Editor) {
|
||||
const anchor = getViewportAnchor(editor)
|
||||
const boxId = createShapeId()
|
||||
|
||||
editor.createShape<ExamMarkerBoxShape>({
|
||||
id: boxId,
|
||||
type: EXAM_MARKER_BOX_TYPE,
|
||||
x: anchor.x + 180,
|
||||
y: anchor.y - 120,
|
||||
props: {
|
||||
w: 320,
|
||||
h: 180,
|
||||
},
|
||||
})
|
||||
|
||||
return boxId
|
||||
}
|
||||
|
||||
export function placeExamMarkerSpike(editor: Editor) {
|
||||
const imageId = insertExamMarkerImage(editor)
|
||||
const boxId = insertExamMarkerBox(editor)
|
||||
|
||||
const box = editor.getShape(boxId)
|
||||
if (box) {
|
||||
const bounds = editor.getShapePageBounds(box)
|
||||
if (bounds) editor.zoomToBounds(bounds)
|
||||
}
|
||||
|
||||
return { imageId, boxId }
|
||||
}
|
||||
|
||||
export function activateExamMarkerTool(editor: Editor) {
|
||||
editor.setCurrentTool(EXAM_MARKER_BOX_TYPE)
|
||||
return editor.getCurrentToolId()
|
||||
}
|
||||
|
||||
export function getExamMarkerHitTestPoints(editor: Editor, shapeId: TLShapeId) {
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return null
|
||||
|
||||
const bounds = editor.getShapePageBounds(shape)
|
||||
if (!bounds) return null
|
||||
|
||||
return {
|
||||
center: {
|
||||
x: bounds.x + bounds.w / 2,
|
||||
y: bounds.y + bounds.h / 2,
|
||||
},
|
||||
bounds,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user