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:
mihretue
2026-08-13 19:28:03 +00:00
parent d98d6a71fe
commit 14b5bb83f9
6 changed files with 214 additions and 0 deletions

View File

@@ -1,10 +1,12 @@
import { baseApi } from '@ema-platform/api';
import type {
Question,
QuestionOption,
ListResponse,
CreateQuestionPayload,
UpdateQuestionPayload,
ReviewQuestionPayload,
SetQuestionOptionsPayload,
} from '../types/question';
const questionApi = baseApi.injectEndpoints({
@@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({
query: (id) => `/questions/${id}`,
providesTags: ['Api'],
}),
/** Same question, with `options` populated — the MCQ authoring editor. */
getQuestionWithOptions: builder.query<Question, string>({
query: (id) => `/questions/${id}?i=options`,
providesTags: ['Api'],
}),
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
query: (body) => ({ url: '/questions', method: 'POST', body }),
invalidatesTags: ['Api'],
@@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({
}),
invalidatesTags: ['Api'],
}),
/** Full replace of a CHOICE question's options + correct-answer set (Phase 2). */
setQuestionOptions: builder.mutation<QuestionOption[], SetQuestionOptionsPayload>({
query: ({ id, ...body }) => ({
url: `/questions/${id}/options`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
@@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({
export const {
useGetQuestionsQuery,
useGetQuestionQuery,
useGetQuestionWithOptionsQuery,
useCreateQuestionMutation,
useUpdateQuestionMutation,
useDeleteQuestionMutation,
useSubmitQuestionMutation,
useReviewQuestionMutation,
useSetQuestionOptionsMutation,
} = questionApi;

View File

@@ -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>
);
}

View File

@@ -30,6 +30,7 @@ import {
useReviewQuestionMutation,
} from '../../api/question-api';
import type { Question, QuestionForm } from '../../types/question';
import { QuestionOptionsEditor } from '../../components/QuestionOptionsEditor';
import { questionColumns } from './columns';
import { questionActionsColumn } from './actions';
@@ -92,6 +93,15 @@ function QuestionForm({
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
</Group>
{editing && form === 'CHOICE' && (
<>
<Text fz="sm" fw={500} mt="sm">{t('question.options.title')}</Text>
<QuestionOptionsEditor questionId={editing.id} />
</>
)}
{!editing && form === 'CHOICE' && (
<Text fz="xs" c="dimmed">{t('question.options.saveFirst')}</Text>
)}
<ModalFooter>
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>

View File

@@ -16,6 +16,17 @@ export type QuestionStatus =
| 'REJECTED'
| 'RETIRED';
/**
* A CHOICE option, as returned by the authoring/QC endpoints. Never carries
* a correctness flag — the API's own answer-key table is never joined into
* this response either, so there's nothing to accidentally serialize here.
*/
export interface QuestionOption {
id: string;
text: LocalePair;
order: number;
}
export interface Question {
id: string;
certificationId: string;
@@ -32,6 +43,8 @@ export interface Question {
submittedAt: string | null;
createdAt: string;
updatedAt: string;
/** Only populated when explicitly requested (`?i=options`). */
options?: QuestionOption[];
}
export interface ReviewQuestionPayload {
@@ -64,3 +77,13 @@ export interface UpdateQuestionPayload {
points?: number;
isActive?: boolean;
}
export interface QuestionOptionInput {
text: LocalePair;
isCorrect: boolean;
}
export interface SetQuestionOptionsPayload {
id: string;
options: QuestionOptionInput[];
}

View File

@@ -738,6 +738,20 @@ export const am: Translations = {
onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።",
error: "ተግባሩ አልተሳካም",
},
options: {
title: "የመልስ አማራጮች",
hint: "ትክክለኛውን አማራጭ ምረጥ/ምረጪ። ማስቀመጥ መላውን የአማራጭ ስብስብ ይተካል።",
optionLabel: "አማራጭ {{number}}",
correct: "ትክክለኛ",
addOption: "አማራጭ ጨምር",
save: "አማራጮችን አስቀምጥ",
saved: "አማራጮች ተቀምጠዋል",
saveFirst: "መጀመሪያ ጥያቄውን አስቀምጥ፣ ከዚያ አማራጮችን ጨምር።",
replaceNotice: "ትክክለኛ መልሶች ከተቀመጡ በኋላ እዚህ አይታዩም — እንደገና ካስተካከልክ/ካስተካከልሽ ዳግም ምረጥ/ምረጪ።",
needAtLeastTwo: "ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልገዋል።",
needOneCorrect: "ቢያንስ አንድ አማራጭ እንደ ትክክለኛ ምረጥ/ምረጪ።",
textRequired: "እያንዳንዱ አማራጭ በሁለቱም ቋንቋዎች ጽሑፍ ያስፈልገዋል።",
},
},
configuration: {

View File

@@ -739,6 +739,21 @@ export const en = {
'Only approved items can be placed on an examination paper.',
error: 'Operation failed',
},
options: {
title: 'Answer Options',
hint: 'Mark every correct option. Saving replaces the entire option set.',
optionLabel: 'Option {{number}}',
correct: 'Correct',
addOption: 'Add option',
save: 'Save options',
saved: 'Options saved',
saveFirst: 'Save the question first, then add its options.',
replaceNotice:
'Correct answers are never shown here once saved — re-mark them if you edit this set again.',
needAtLeastTwo: 'A question needs at least two options.',
needOneCorrect: 'Mark at least one option as correct.',
textRequired: 'Every option needs text in both languages.',
},
},
configuration: {