feat(question): add CHOICE options inline while creating, not after

Create a CHOICE question and the options fields were hidden behind a
'save first' message — had to save, then reopen via Edit, to actually add
options. Added an inline options editor (local state, same bilingual
fields/correct-checkbox as the existing edit-mode one) directly in the
create form. On submit: create the question, then immediately call
setOptions with the drafted options using the new question's id — one
action from the user's point of view, two API calls under the hood.
Edit mode is unchanged, still uses the existing QuestionOptionsEditor
against the real question id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mihretue
2026-08-18 12:00:15 +00:00
parent ae3af7777e
commit df9db6a36e

View File

@@ -13,10 +13,12 @@ import {
Select,
NumberInput,
Textarea,
Checkbox,
ActionIcon,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { IconPlus, IconInfoCircle, IconTrash, IconGripVertical } from '@tabler/icons-react';
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -28,12 +30,87 @@ import {
useDeleteQuestionMutation,
useSubmitQuestionMutation,
useReviewQuestionMutation,
useSetQuestionOptionsMutation,
} from '../../api/question-api';
import type { Question, QuestionForm } from '../../types/question';
import type { Question, QuestionForm, QuestionOptionInput } from '../../types/question';
import { QuestionOptionsEditor } from '../../components/QuestionOptionsEditor';
import { questionColumns } from './columns';
import { questionActionsColumn } from './actions';
type DraftOption = { textEn: string; textAm: string; isCorrect: boolean };
const BLANK_DRAFT_OPTIONS: DraftOption[] = [
{ textEn: '', textAm: '', isCorrect: false },
{ textEn: '', textAm: '', isCorrect: false },
];
/**
* Options for a brand-new CHOICE question, entered inline in the same
* modal — no question id exists yet, so this is pure local state, only
* turned into a real setOptions() call once the question itself is
* created (see QuestionPage.handleSubmit).
*/
function InlineOptionsEditor({
options,
onChange,
}: {
options: DraftOption[];
onChange: (options: DraftOption[]) => void;
}) {
const { t } = useTranslation();
const update = (index: number, patch: Partial<DraftOption>) =>
onChange(options.map((o, i) => (i === index ? { ...o, ...patch } : o)));
return (
<Stack gap="xs">
{options.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.textEn}
onChange={(e) => update(index, { textEn: e.currentTarget.value })}
size="sm"
required
/>
<TextInput
label={t('question.options.optionAm', { number: index + 1 })}
value={option.textAm}
onChange={(e) => update(index, { textAm: e.currentTarget.value })}
size="sm"
required
/>
</Stack>
<Checkbox
label={t('question.options.correct')}
checked={option.isCorrect}
onChange={() => update(index, { isCorrect: !option.isCorrect })}
/>
<ActionIcon
variant="subtle"
color="red"
size="sm"
disabled={options.length <= 2}
onClick={() => onChange(options.filter((_, i) => i !== index))}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
<Button
variant="subtle"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => onChange([...options, { textEn: '', textAm: '', isCorrect: false }])}
>
{t('question.options.addOption')}
</Button>
</Stack>
);
}
function QuestionForm({
editing,
certOptions,
@@ -53,6 +130,7 @@ function QuestionForm({
days: number;
hours: number;
minutes: number;
draftOptions: DraftOption[];
}, isEdit: boolean) => void;
onCancel: () => void;
}) {
@@ -66,6 +144,7 @@ function QuestionForm({
const [days, setDays] = useState(editing?.time?.days ?? 0);
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
const [draftOptions, setDraftOptions] = useState<DraftOption[]>(BLANK_DRAFT_OPTIONS);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -73,8 +152,23 @@ function QuestionForm({
notify.error('Please fill all required fields');
return;
}
if (!editing && form === 'CHOICE') {
if (draftOptions.length < 2) {
notify.error(t('question.options.needAtLeastTwo'));
return;
}
if (!draftOptions.some((o) => o.isCorrect)) {
notify.error(t('question.options.needOneCorrect'));
return;
}
if (draftOptions.some((o) => !o.textEn.trim() || !o.textAm.trim())) {
notify.error(t('question.options.textRequired'));
return;
}
}
onSubmit({
certificationId, titleEn, titleAm, form, points, days, hours, minutes
certificationId, titleEn, titleAm, form, points, days, hours, minutes,
draftOptions: !editing && form === 'CHOICE' ? draftOptions : [],
}, !!editing);
};
@@ -100,7 +194,10 @@ function QuestionForm({
</>
)}
{!editing && form === 'CHOICE' && (
<Text fz="xs" c="dimmed">{t('question.options.saveFirst')}</Text>
<>
<Text fz="sm" fw={500} mt="sm">{t('question.options.title')}</Text>
<InlineOptionsEditor options={draftOptions} onChange={setDraftOptions} />
</>
)}
<ModalFooter>
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
@@ -122,6 +219,7 @@ export function QuestionPage() {
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
const [deleteQ] = useDeleteQuestionMutation();
const [setOptions, { isLoading: isSavingOptions }] = useSetQuestionOptionsMutation();
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
@@ -149,6 +247,7 @@ export function QuestionPage() {
const handleSubmit = async (values: {
certificationId: string; titleEn: string; titleAm: string;
form: string; points: number; days: number; hours: number; minutes: number;
draftOptions: DraftOption[];
}, isEdit: boolean) => {
const title = { en: values.titleEn, am: values.titleAm };
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
@@ -157,7 +256,18 @@ export function QuestionPage() {
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success(t('question.updated'));
} else {
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
const created = await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
// The question needs an id to attach options to — this is the second
// half of one "create" action from the user's point of view, not a
// separate edit step, so it happens right here rather than waiting
// for them to reopen the question later.
if (values.form === 'CHOICE' && values.draftOptions.length) {
const options: QuestionOptionInput[] = values.draftOptions.map((o) => ({
text: { en: o.textEn, am: o.textAm },
isCorrect: o.isCorrect,
}));
await setOptions({ id: created.id, options }).unwrap();
}
notify.success(t('question.created'));
}
resetForm();
@@ -242,7 +352,7 @@ export function QuestionPage() {
<QuestionForm
editing={editing}
certOptions={certOptions}
isSubmitting={isCreating || isUpdating}
isSubmitting={isCreating || isUpdating || isSavingOptions}
onSubmit={handleSubmit}
onCancel={resetForm}
/>