feat: Sprint 3 — test editing with versioning

Backend:
- migration 003: add parent_id to tests table
- PUT /api/tests/{id}: edit in place if no attempts, create new version otherwise
- GET /api/tests: show only latest versions (no successor)

Frontend:
- TestForm: extracted reusable form component
- TestCreate: refactored to use TestForm
- TestEdit: full edit mode with pre-populated form, version redirect on new version

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aleksey Razorvin
2026-03-21 13:28:06 +05:00
parent 2b5dc379e1
commit b2a3bda01b
8 changed files with 464 additions and 255 deletions
+254
View File
@@ -0,0 +1,254 @@
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
import {
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
Space,
Switch,
Typography,
} from 'antd'
const { Title } = Typography
const EMPTY_ANSWER = { text: '', is_correct: false }
const EMPTY_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] }
export interface TestFormValues {
title: string
description?: string
passing_score: number
has_timer: boolean
time_limit?: number
allow_navigation_back: boolean
questions: { text: string; answers: { text: string; is_correct: boolean }[] }[]
}
interface TestFormProps {
heading: string
initialValues?: Partial<TestFormValues>
onSubmit: (values: TestFormValues) => void
isPending: boolean
submitLabel: string
onCancel: () => void
}
export default function TestForm({
heading,
initialValues,
onSubmit,
isPending,
submitLabel,
onCancel,
}: TestFormProps) {
const [form] = Form.useForm<TestFormValues>()
const defaultValues: Partial<TestFormValues> = {
allow_navigation_back: true,
has_timer: false,
passing_score: 70,
questions: Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })),
...initialValues,
}
return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
<Title level={2}>{heading}</Title>
<Form form={form} layout="vertical" onFinish={onSubmit} initialValues={defaultValues}>
{/* ── Основные настройки ── */}
<Card title="Основные настройки" style={{ marginBottom: 16 }}>
<Form.Item
name="title"
label="Название теста"
rules={[{ required: true, message: 'Введите название теста' }]}
>
<Input placeholder="Например: Пожарная безопасность 2026" />
</Form.Item>
<Form.Item name="description" label="Описание (необязательно)">
<Input.TextArea rows={2} placeholder="Краткое описание теста" />
</Form.Item>
<Form.Item
name="passing_score"
label="Порог зачёта"
rules={[{ required: true, message: 'Укажите порог' }]}
>
<InputNumber min={0} max={100} addonAfter="%" style={{ width: 140 }} />
</Form.Item>
<Form.Item label="Ограничение по времени">
<Space align="center">
<Form.Item name="has_timer" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev.has_timer !== cur.has_timer}
>
{({ getFieldValue }) =>
getFieldValue('has_timer') ? (
<Form.Item
name="time_limit"
noStyle
rules={[{ required: true, message: 'Укажите время' }]}
>
<InputNumber min={1} addonAfter="мин" style={{ width: 150 }} />
</Form.Item>
) : (
<span style={{ color: '#999' }}>без ограничения</span>
)
}
</Form.Item>
</Space>
</Form.Item>
<Form.Item
name="allow_navigation_back"
label="Разрешить возврат к предыдущему вопросу"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Card>
{/* ── Вопросы ── */}
<Form.List
name="questions"
rules={[
{
validator: async (_, questions) => {
if (!questions || questions.length < 7) {
return Promise.reject(new Error('Минимум 7 вопросов'))
}
},
},
]}
>
{(questionFields, { add: addQuestion, remove: removeQuestion }, { errors }) => (
<>
{questionFields.map(({ key, name: qName }, index) => (
<Card
key={key}
title={`Вопрос ${index + 1}`}
extra={
questionFields.length > 7 ? (
<MinusCircleOutlined
style={{ color: '#ff4d4f', fontSize: 16 }}
onClick={() => removeQuestion(qName)}
/>
) : null
}
style={{ marginBottom: 16 }}
>
<Form.Item
name={[qName, 'text']}
rules={[{ required: true, message: 'Введите текст вопроса' }]}
>
<Input.TextArea rows={2} placeholder="Текст вопроса" />
</Form.Item>
<Form.List
name={[qName, 'answers']}
rules={[
{
validator: async (_, answers) => {
if (!answers || answers.length < 3) {
return Promise.reject(new Error('Минимум 3 варианта ответа'))
}
if (!answers.some((a: { is_correct: boolean }) => a?.is_correct)) {
return Promise.reject(
new Error('Отметьте хотя бы один правильный ответ'),
)
}
},
},
]}
>
{(
answerFields,
{ add: addAnswer, remove: removeAnswer },
{ errors: answerErrors },
) => (
<>
{answerFields.map(({ key: ak, name: aName }) => (
<Space
key={ak}
style={{ display: 'flex', marginBottom: 8 }}
align="start"
>
<Form.Item
name={[aName, 'is_correct']}
valuePropName="checked"
initialValue={false}
style={{ marginBottom: 0 }}
>
<Checkbox />
</Form.Item>
<Form.Item
name={[aName, 'text']}
rules={[{ required: true, message: 'Введите вариант ответа' }]}
style={{ marginBottom: 0, flex: 1 }}
>
<Input placeholder="Вариант ответа" style={{ width: 440 }} />
</Form.Item>
{answerFields.length > 3 && (
<MinusCircleOutlined
style={{ color: '#ff4d4f' }}
onClick={() => removeAnswer(aName)}
/>
)}
</Space>
))}
<Form.ErrorList errors={answerErrors} />
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={() => addAnswer({ text: '', is_correct: false })}
style={{ marginTop: 4 }}
>
Добавить вариант
</Button>
</>
)}
</Form.List>
</Card>
))}
<Form.ErrorList errors={errors} />
<Button
type="dashed"
block
icon={<PlusOutlined />}
style={{ marginBottom: 24 }}
onClick={() =>
addQuestion({ text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] })
}
>
Добавить вопрос
</Button>
</>
)}
</Form.List>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={isPending}>
{submitLabel}
</Button>
<Button onClick={onCancel}>Отмена</Button>
</Space>
</Form.Item>
</Form>
</div>
)
}