Files
emaui/apps/portal/src/app/features/exam-attempt/components/ExamQuestionDisplay.tsx
mihretue 4edce751ee feat(exam): candidate exam-taking experience (Phase 4)
New portal feature apps/portal/src/app/features/exam-attempt/, following
the repo's existing folder convention (pages/<Page>/index.tsx, components/,
types/, hooks/ — matches the payments feature's hooks/ precedent):

- types/exam-attempt.ts — response/local-state shapes
- hooks/useExamAttempt.ts — all API orchestration: load (registration +
  attempt), start/resume, per-question autosave (immediate on MCQ select,
  debounced+flushed-on-navigation for essay text), local countdown seeded
  from the server's serverTime/remainingSeconds, submit, and resync from
  the server whenever a write is refused as expired/already-submitted
- components/ExamInstructions, ExamTimer, ExamQuestionNav,
  ExamQuestionDisplay, ExamCompletion — one concern per file, not a single
  page dump
- pages/ExamAttemptPage — thin view layer over the hook

Flow: /exams (existing) gets a 'Take exam' action on eligible registration
rows → /exams/:examId/take, which shows instructions before an attempt
exists, the live exam screen while IN_PROGRESS, and a no-score completion
screen once SUBMITTED/EXPIRED (never fabricates a result — grading
doesn't exist yet).

Security note: RequirePermission on the route and disabled inputs after
local timeout are UI conveniences only. Every save/submit is independently
re-checked by the backend's ownership + applyExpiry() on each call: a
client that skipped the UI entirely and hit the API directly would be
bound by exactly the same rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 09:17:09 +00:00

119 lines
3.3 KiB
TypeScript

import { Badge, Button, Group, Paper, Radio, Stack, Text, Textarea } from '@mantine/core';
import { IconAlertCircle, IconCheck, IconRefresh } from '@tabler/icons-react';
import type { Bilingual } from '@ema-platform/api';
import type { CandidateQuestion, SaveState } from '../types/exam-attempt';
function SaveIndicator({ state, onRetry }: { state: SaveState; onRetry: () => void }) {
if (state === 'saving') {
return <Text fz="xs" c="dimmed">Saving</Text>;
}
if (state === 'saved') {
return (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal">Saved</Text>
</Group>
);
}
if (state === 'error') {
return (
<Group gap={6}>
<IconAlertCircle size={13} color="var(--mantine-color-red-6)" />
<Text fz="xs" c="red">Not saved</Text>
<Button
size="compact-xs"
variant="light"
color="red"
leftSection={<IconRefresh size={12} />}
onClick={onRetry}
>
Retry
</Button>
</Group>
);
}
return null;
}
/**
* Renders one question — never the answer key, because the API response
* this reads from (`CandidateQuestion`/`CandidateOption`) has no such field
* to render even by mistake.
*/
export function ExamQuestionDisplay({
question,
index,
total,
localized,
selectedOptionId,
answerText,
saveState,
disabled,
onSelectOption,
onChangeText,
onRetry,
}: {
question: CandidateQuestion;
index: number;
total: number;
localized: (value: Bilingual | undefined) => string;
selectedOptionId: string | null | undefined;
answerText: string | null | undefined;
saveState: SaveState;
disabled: boolean;
onSelectOption: (optionId: string) => void;
onChangeText: (text: string) => void;
onRetry: () => void;
}) {
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" mb="sm">
<Badge variant="light" color="gray">
Question {index + 1} of {total} · {question.points} pts
</Badge>
<SaveIndicator state={saveState} onRetry={onRetry} />
</Group>
<Text fz="md" fw={500} mb="lg">
{localized(question.title)}
</Text>
{question.form === 'CHOICE' ? (
<Radio.Group
value={selectedOptionId ?? ''}
onChange={onSelectOption}
>
<Stack gap="sm">
{question.options
.slice()
.sort((a, b) => a.order - b.order)
.map((option) => (
<Radio.Card
key={option.id}
value={option.id}
disabled={disabled}
p="sm"
radius="md"
>
<Group wrap="nowrap" gap="sm">
<Radio.Indicator disabled={disabled} />
<Text fz="sm">{localized(option.text)}</Text>
</Group>
</Radio.Card>
))}
</Stack>
</Radio.Group>
) : (
<Textarea
placeholder="Write your answer"
minRows={8}
autosize
disabled={disabled}
value={answerText ?? ''}
onChange={(event) => onChangeText(event.currentTarget.value)}
/>
)}
</Paper>
);
}