mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user