diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx index 1ee40bc5d..519c937f5 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx @@ -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) => + onChange(options.map((o, i) => (i === index ? { ...o, ...patch } : o))); + + return ( + + {options.map((option, index) => ( + + + + update(index, { textEn: e.currentTarget.value })} + size="sm" + required + /> + update(index, { textAm: e.currentTarget.value })} + size="sm" + required + /> + + update(index, { isCorrect: !option.isCorrect })} + /> + onChange(options.filter((_, i) => i !== index))} + > + + + + ))} + + + ); +} + 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(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' && ( - {t('question.options.saveFirst')} + <> + {t('question.options.title')} + + )} @@ -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() {