Browse Source

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>
master
Aleksey Razorvin 1 week ago
parent
commit
b2a3bda01b
  1. 29
      backend/alembic/versions/003_test_versioning.py
  2. 92
      backend/app/api/tests.py
  3. 3
      backend/app/models/test.py
  4. 6
      backend/app/schemas/test.py
  5. 8
      frontend/src/api/tests.ts
  6. 254
      frontend/src/components/TestForm/index.tsx
  7. 256
      frontend/src/pages/TestCreate/index.tsx
  8. 71
      frontend/src/pages/TestEdit/index.tsx

29
backend/alembic/versions/003_test_versioning.py

@ -0,0 +1,29 @@
"""test versioning
Revision ID: 003
Revises: 002
Create Date: 2026-03-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tests",
sa.Column("parent_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=True),
)
op.create_index("ix_tests_parent_id", "tests", ["parent_id"])
def downgrade() -> None:
op.drop_index("ix_tests_parent_id", table_name="tests")
op.drop_column("tests", "parent_id")

92
backend/app/api/tests.py

@ -1,21 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import get_db from app.database import get_db
from app.models.attempt import TestAttempt
from app.models.test import Answer, Question, Test from app.models.test import Answer, Question, Test
from app.schemas.test import TestCreate, TestListItem, TestOut from app.schemas.test import TestCreate, TestListItem, TestOut, TestUpdateResponse
router = APIRouter(prefix="/api/tests", tags=["tests"]) router = APIRouter(prefix="/api/tests", tags=["tests"])
@router.get("", response_model=list[TestListItem]) @router.get("", response_model=list[TestListItem])
async def list_tests(db: AsyncSession = Depends(get_db)): async def list_tests(db: AsyncSession = Depends(get_db)):
# Показываем только последние версии: те, чей id не упоминается как parent_id
parent_ids_subq = select(Test.parent_id).where(Test.parent_id.is_not(None))
result = await db.execute( result = await db.execute(
select(Test) select(Test)
.options(selectinload(Test.questions)) .options(selectinload(Test.questions))
.where(Test.is_active == True) .where(Test.is_active == True, Test.id.not_in(parent_ids_subq))
.order_by(Test.created_at.desc()) .order_by(Test.created_at.desc())
) )
tests = result.scalars().all() tests = result.scalars().all()
@ -68,10 +72,90 @@ async def create_test(data: TestCreate, db: AsyncSession = Depends(get_db)):
await db.commit() await db.commit()
# Перезагружаем с вложенными связями
result = await db.execute( result = await db.execute(
select(Test) select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers)) .options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == test.id) .where(Test.id == test.id)
) )
return result.scalar_one() return result.scalar_one()
@router.put("/{test_id}", response_model=TestUpdateResponse)
async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Test)
.where(Test.id == test_id, Test.is_active == True)
)
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
# Проверяем, есть ли уже попытки прохождения
attempts_count = await db.scalar(
select(func.count()).select_from(TestAttempt).where(TestAttempt.test_id == test_id)
)
if attempts_count == 0:
# Редактируем на месте: обновляем поля, пересоздаём вопросы
test.title = data.title
test.description = data.description
test.passing_score = data.passing_score
test.time_limit = data.time_limit
test.allow_navigation_back = data.allow_navigation_back
await db.execute(delete(Question).where(Question.test_id == test_id))
await db.flush()
for order, q_data in enumerate(data.questions):
question = Question(test_id=test.id, text=q_data.text, order=order)
db.add(question)
await db.flush()
for a_data in q_data.answers:
db.add(Answer(
question_id=question.id,
text=a_data.text,
is_correct=a_data.is_correct,
))
await db.commit()
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == test.id)
)
return {"test": result.scalar_one(), "is_new_version": False}
else:
# Есть попытки — создаём новую версию
new_test = Test(
title=data.title,
description=data.description,
passing_score=data.passing_score,
time_limit=data.time_limit,
allow_navigation_back=data.allow_navigation_back,
version=test.version + 1,
parent_id=test.id,
)
db.add(new_test)
await db.flush()
for order, q_data in enumerate(data.questions):
question = Question(test_id=new_test.id, text=q_data.text, order=order)
db.add(question)
await db.flush()
for a_data in q_data.answers:
db.add(Answer(
question_id=question.id,
text=a_data.text,
is_correct=a_data.is_correct,
))
await db.commit()
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == new_test.id)
)
return {"test": result.scalar_one(), "is_new_version": True}

3
backend/app/models/test.py

@ -18,6 +18,9 @@ class Test(Base):
allow_navigation_back: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) allow_navigation_back: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("tests.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )

6
backend/app/schemas/test.py

@ -65,12 +65,18 @@ class TestOut(BaseModel):
allow_navigation_back: bool allow_navigation_back: bool
is_active: bool is_active: bool
version: int version: int
parent_id: Optional[int]
created_at: datetime created_at: datetime
questions: list[QuestionOut] = [] questions: list[QuestionOut] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class TestUpdateResponse(BaseModel):
test: TestOut
is_new_version: bool
class TestListItem(BaseModel): class TestListItem(BaseModel):
id: int id: int
title: str title: str

8
frontend/src/api/tests.ts

@ -22,10 +22,16 @@ export interface Test {
allow_navigation_back: boolean allow_navigation_back: boolean
is_active: boolean is_active: boolean
version: number version: number
parent_id: number | null
created_at: string created_at: string
questions: Question[] questions: Question[]
} }
export interface UpdateTestResponse {
test: Test
is_new_version: boolean
}
export interface TestListItem { export interface TestListItem {
id: number id: number
title: string title: string
@ -61,4 +67,6 @@ export const testsApi = {
list: () => client.get<TestListItem[]>('/tests'), list: () => client.get<TestListItem[]>('/tests'),
get: (id: number) => client.get<Test>(`/tests/${id}`), get: (id: number) => client.get<Test>(`/tests/${id}`),
create: (data: CreateTestDto) => client.post<Test>('/tests', data), create: (data: CreateTestDto) => client.post<Test>('/tests', data),
update: (id: number, data: CreateTestDto) =>
client.put<UpdateTestResponse>(`/tests/${id}`, data),
} }

254
frontend/src/components/TestForm/index.tsx

@ -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>
)
}

256
frontend/src/pages/TestCreate/index.tsx

@ -1,33 +1,11 @@
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { import { message } from 'antd'
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
Space,
Switch,
Typography,
message,
} from 'antd'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { CreateTestDto, testsApi } from '../../api/tests' import { CreateTestDto, testsApi } from '../../api/tests'
import TestForm, { TestFormValues } from '../../components/TestForm'
const { Title } = Typography
// Начальные данные: 7 пустых вопросов с 3 вариантами ответов каждый
const EMPTY_ANSWER = { text: '', is_correct: false }
const EMPTY_QUESTION = {
text: '',
answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER],
}
const INITIAL_QUESTIONS = Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION }))
export default function TestCreate() { export default function TestCreate() {
const [form] = Form.useForm()
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -44,15 +22,7 @@ export default function TestCreate() {
}, },
}) })
const onFinish = (values: { const onSubmit = (values: 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 }[] }[]
}) => {
createTest({ createTest({
title: values.title, title: values.title,
description: values.description, description: values.description,
@ -64,218 +34,12 @@ export default function TestCreate() {
} }
return ( return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}> <TestForm
<Title level={2}>Создание теста</Title> heading="Создание теста"
onSubmit={onSubmit}
<Form isPending={isPending}
form={form} submitLabel="Создать тест"
layout="vertical" onCancel={() => navigate('/')}
onFinish={onFinish} />
initialValues={{
allow_navigation_back: true,
has_timer: false,
passing_score: 70,
questions: INITIAL_QUESTIONS,
}}
>
{/* ── Основные настройки ── */}
<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}>
Создать тест
</Button>
<Button onClick={() => navigate('/')}>Отмена</Button>
</Space>
</Form.Item>
</Form>
</div>
) )
} }

71
frontend/src/pages/TestEdit/index.tsx

@ -5,29 +5,91 @@ import {
EditOutlined, EditOutlined,
PlayCircleOutlined, PlayCircleOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Descriptions, List, Space, Spin, Tag, Typography } from 'antd' 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 { 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 const { Title, Text } = Typography
export default function TestEdit() { export default function TestEdit() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient()
const [editMode, setEditMode] = useState(false)
const { data: test, isLoading } = useQuery({ const { data: test, isLoading } = useQuery({
queryKey: ['tests', id], queryKey: ['tests', id],
queryFn: () => testsApi.get(Number(id)).then((r) => r.data), 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) { if (isLoading) {
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} /> return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
} }
if (!test) return null 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 ( return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}> <div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}> <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
@ -37,8 +99,7 @@ export default function TestEdit() {
<Space> <Space>
<Button <Button
icon={<EditOutlined />} icon={<EditOutlined />}
disabled onClick={() => setEditMode(true)}
title="Редактирование будет доступно в следующем спринте"
> >
Редактировать Редактировать
</Button> </Button>

Loading…
Cancel
Save