mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 02:30:57 +00:00
feat(question): MCQ options authoring editor (Phase 2 domain foundation)
Backend now supports MCQ options + a separate answer-key table (PUT /questions/:id/options). Frontend side of that, authoring only — no candidate exam-taking UI. - Question type gains options?: QuestionOption[] (no correctness field — the API never returns one, matching the backend's split-table design) - New question-api hooks: getQuestionWithOptions, setQuestionOptions - New QuestionOptionsEditor component (separate file, not inlined into QuestionPage) — add/remove/reorder options, mark correct, bilingual text - Wired into the question edit form, shown only for an existing CHOICE question (options attach to an id, matching the backend's replace endpoint) - en/am i18n strings for the new editor Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { BilingualInput, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetQuestionWithOptionsQuery,
|
||||
useSetQuestionOptionsMutation,
|
||||
} from '../api/question-api';
|
||||
|
||||
interface DraftOption {
|
||||
text: BilingualValue;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCQ options + correct-answer editor for a CHOICE-form question (Phase 2).
|
||||
*
|
||||
* Only reachable while editing an already-created question — options attach
|
||||
* to a question id, matching the backend's `PUT /questions/:id/options`
|
||||
* full-replace endpoint. Nothing here is ever shown to a candidate; this is
|
||||
* the authoring side only.
|
||||
*/
|
||||
export function QuestionOptionsEditor({ questionId }: { questionId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: question, isFetching } = useGetQuestionWithOptionsQuery(questionId);
|
||||
const [setOptions, { isLoading: isSaving }] = useSetQuestionOptionsMutation();
|
||||
const [draft, setDraft] = useState<DraftOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!question) return;
|
||||
const existing = question.options ?? [];
|
||||
setDraft(
|
||||
existing.length
|
||||
? existing
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((o) => ({ text: o.text, isCorrect: false }))
|
||||
: [
|
||||
{ text: { en: '', am: '' }, isCorrect: false },
|
||||
{ text: { en: '', am: '' }, isCorrect: false },
|
||||
],
|
||||
);
|
||||
// isCorrect never comes back from the API by design — an examiner
|
||||
// re-editing options re-marks the correct one(s) rather than us
|
||||
// pretending to know what they were.
|
||||
}, [question]);
|
||||
|
||||
const updateText = (index: number, text: BilingualValue) => {
|
||||
setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, text } : o)));
|
||||
};
|
||||
|
||||
const toggleCorrect = (index: number) => {
|
||||
setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, isCorrect: !o.isCorrect } : o)));
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
setDraft((prev) => [...prev, { text: { en: '', am: '' }, isCorrect: false }]);
|
||||
};
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
setDraft((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (draft.length < 2) {
|
||||
notify.error(t('question.options.needAtLeastTwo'));
|
||||
return;
|
||||
}
|
||||
if (!draft.some((o) => o.isCorrect)) {
|
||||
notify.error(t('question.options.needOneCorrect'));
|
||||
return;
|
||||
}
|
||||
if (draft.some((o) => !o.text.en.trim() || !o.text.am.trim())) {
|
||||
notify.error(t('question.options.textRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setOptions({ id: questionId, options: draft }).unwrap();
|
||||
notify.success(t('question.options.saved'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) return <Loader size="sm" />;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Alert icon={<IconInfoCircle size={15} />} color="blue" variant="light">
|
||||
{t('question.options.hint')}
|
||||
</Alert>
|
||||
{draft.map((option, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="flex-end">
|
||||
<IconGripVertical size={16} style={{ opacity: 0.4, marginBottom: 8 }} />
|
||||
<BilingualInput
|
||||
label={t('question.options.optionLabel', { number: index + 1 })}
|
||||
value={option.text}
|
||||
onChange={(v) => updateText(index, v)}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('question.options.correct')}
|
||||
checked={option.isCorrect}
|
||||
onChange={() => toggleCorrect(index)}
|
||||
mb={4}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
mb={4}
|
||||
disabled={draft.length <= 2}
|
||||
onClick={() => removeOption(index)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconPlus size={14} />} onClick={addOption}>
|
||||
{t('question.options.addOption')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isSaving} onClick={handleSave}>
|
||||
{t('question.options.save')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{t('question.options.replaceNotice')}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user