mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-27 21:20:58 +00:00
Options editor used a single toggle-based bilingual field (BilingualInput) that swapped English/Amharic in place via a tiny, easy-to-miss button. Authors were filling English only and never noticing Amharic was empty. - QuestionOptionsEditor: show separate always-visible English/Amharic TextInputs per option instead of the toggle field. - BilingualInput (shared): for other callers still using the toggle, add a tooltip and a red dot indicator when the hidden language is empty, so the gap is visible without switching. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text, TextInput } from '@mantine/core';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react';
|
|
import { 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 updateField = (index: number, lang: keyof BilingualValue, value: string) => {
|
|
setDraft((prev) =>
|
|
prev.map((o, i) => (i === index ? { ...o, text: { ...o.text, [lang]: value } } : 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="center">
|
|
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
|
|
<Stack gap={6} style={{ flex: 1 }}>
|
|
<TextInput
|
|
label={t('question.options.optionEn', { number: index + 1 })}
|
|
value={option.text.en}
|
|
onChange={(e) => updateField(index, 'en', e.currentTarget.value)}
|
|
size="sm"
|
|
required
|
|
/>
|
|
<TextInput
|
|
label={t('question.options.optionAm', { number: index + 1 })}
|
|
value={option.text.am}
|
|
onChange={(e) => updateField(index, 'am', e.currentTarget.value)}
|
|
size="sm"
|
|
required
|
|
/>
|
|
</Stack>
|
|
<Checkbox
|
|
label={t('question.options.correct')}
|
|
checked={option.isCorrect}
|
|
onChange={() => toggleCorrect(index)}
|
|
/>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
size="sm"
|
|
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>
|
|
);
|
|
}
|