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
+66 -5
View File
@@ -5,29 +5,91 @@ import {
EditOutlined,
PlayCircleOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Card, Descriptions, List, Space, Spin, Tag, Typography } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Descriptions, List, Space, Spin, Tag, Typography, message } from 'antd'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Answer, testsApi } from '../../api/tests'
import { Answer, CreateTestDto, testsApi } from '../../api/tests'
import TestForm, { TestFormValues } from '../../components/TestForm'
const { Title, Text } = Typography
export default function TestEdit() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [editMode, setEditMode] = useState(false)
const { data: test, isLoading } = useQuery({
queryKey: ['tests', id],
queryFn: () => testsApi.get(Number(id)).then((r) => r.data),
})
const { mutate: updateTest, isPending } = useMutation({
mutationFn: (data: CreateTestDto) => testsApi.update(Number(id), data).then((r) => r.data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['tests'] })
if (result.is_new_version) {
message.success(`Создана новая версия теста (v${result.test.version})`)
navigate(`/tests/${result.test.id}/edit`)
} else {
message.success('Тест обновлён')
queryClient.invalidateQueries({ queryKey: ['tests', id] })
setEditMode(false)
}
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || 'Ошибка при сохранении теста')
},
})
if (isLoading) {
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
}
if (!test) return null
// Режим редактирования — показываем форму с предзаполненными данными
if (editMode) {
const initialValues: TestFormValues = {
title: test.title,
description: test.description ?? undefined,
passing_score: test.passing_score,
has_timer: test.time_limit !== null,
time_limit: test.time_limit ?? undefined,
allow_navigation_back: test.allow_navigation_back,
questions: test.questions.map((q) => ({
text: q.text,
answers: q.answers.map((a) => ({ text: a.text, is_correct: a.is_correct })),
})),
}
const onSubmit = (values: TestFormValues) => {
updateTest({
title: values.title,
description: values.description,
passing_score: values.passing_score,
time_limit: values.has_timer ? values.time_limit : undefined,
allow_navigation_back: values.allow_navigation_back ?? true,
questions: values.questions,
})
}
return (
<TestForm
heading={`Редактирование теста — v${test.version}`}
initialValues={initialValues}
onSubmit={onSubmit}
isPending={isPending}
submitLabel="Сохранить"
onCancel={() => setEditMode(false)}
/>
)
}
// Режим просмотра (вид автора)
return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
@@ -37,8 +99,7 @@ export default function TestEdit() {
<Space>
<Button
icon={<EditOutlined />}
disabled
title="Редактирование будет доступно в следующем спринте"
onClick={() => setEditMode(true)}
>
Редактировать
</Button>