feat: CRUD UI — race form, detail fields, edit/delete actions
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- RaceDetailsPage: show all non-null fields (officialUrl, startTime, clusterSchedule, bibPickup) - RaceDetailsPage: add edit link and delete button with confirmation banner - RaceFormPage: universal create/edit form with validation, auto-generated id for new races - Router: add /races/new and /races/:raceId/edit routes - AppLayout: add navigation link to create new race - CSS: buttons (primary/secondary/danger), form fields, confirm banner, responsive layout Made-with: Cursor
This commit is contained in:
421
frontend/src/pages/RaceFormPage.tsx
Normal file
421
frontend/src/pages/RaceFormPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { ApiError, createRace, getRaceById, updateRace } from "../api";
|
||||
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[«»"]/g, "")
|
||||
.replace(/[^a-zа-яё0-9]+/gi, "-")
|
||||
.replace(/(^-|-$)/g, "")
|
||||
.substring(0, 60);
|
||||
}
|
||||
|
||||
function generateId(date: string, title: string): string {
|
||||
return `${date}-${slugify(title)}`;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "Не указан" },
|
||||
{ value: "planned", label: "Планирую" },
|
||||
{ value: "registered", label: "Зарегистрирован" },
|
||||
{ value: "completed", label: "Пробежал" },
|
||||
];
|
||||
|
||||
interface FormData {
|
||||
date: string;
|
||||
title: string;
|
||||
distanceKm: string;
|
||||
status: string;
|
||||
officialUrl: string;
|
||||
startTime: string;
|
||||
clusterSchedule: string;
|
||||
bibPickup: string;
|
||||
bibNumber: string;
|
||||
finishTime: string;
|
||||
finishPlace: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormData = {
|
||||
date: "",
|
||||
title: "",
|
||||
distanceKm: "",
|
||||
status: "planned",
|
||||
officialUrl: "",
|
||||
startTime: "",
|
||||
clusterSchedule: "",
|
||||
bibPickup: "",
|
||||
bibNumber: "",
|
||||
finishTime: "",
|
||||
finishPlace: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
function raceToFormData(race: Race): FormData {
|
||||
return {
|
||||
date: race.date,
|
||||
title: race.title,
|
||||
distanceKm: String(race.distanceKm),
|
||||
status: race.status ?? "",
|
||||
officialUrl: race.officialUrl ?? "",
|
||||
startTime: race.startTime ?? "",
|
||||
clusterSchedule: race.clusterSchedule ?? "",
|
||||
bibPickup: race.bibPickup ?? "",
|
||||
bibNumber: race.bibNumber ?? "",
|
||||
finishTime: race.finishTime ?? "",
|
||||
finishPlace: race.finishPlace ?? "",
|
||||
notes: race.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function emptyToNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
function validateForm(form: FormData): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!form.date.trim()) {
|
||||
errors.push("Дата обязательна.");
|
||||
}
|
||||
if (!form.title.trim()) {
|
||||
errors.push("Название обязательно.");
|
||||
}
|
||||
const km = parseFloat(form.distanceKm);
|
||||
if (Number.isNaN(km) || km <= 0) {
|
||||
errors.push("Дистанция должна быть положительным числом.");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function RaceFormPage(): JSX.Element {
|
||||
const { raceId } = useParams<{ raceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isEditMode = Boolean(raceId);
|
||||
|
||||
const [form, setForm] = useState<FormData>(EMPTY_FORM);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(isEditMode);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!raceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
async function loadRace(): Promise<void> {
|
||||
try {
|
||||
const race = await getRaceById(raceId!);
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setForm(raceToFormData(race));
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(error instanceof ApiError ? error.message : "Не удалось загрузить данные старта.");
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadRace();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [raceId]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
const errors = validateForm(form);
|
||||
setValidationErrors(errors);
|
||||
if (errors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const statusValue: RaceStatus | null =
|
||||
form.status === "planned" || form.status === "registered" || form.status === "completed"
|
||||
? form.status
|
||||
: null;
|
||||
|
||||
if (isEditMode && raceId) {
|
||||
const payload: UpdateRacePayload = {
|
||||
date: form.date.trim(),
|
||||
title: form.title.trim(),
|
||||
distanceKm: parseFloat(form.distanceKm),
|
||||
status: statusValue,
|
||||
officialUrl: emptyToNull(form.officialUrl),
|
||||
startTime: emptyToNull(form.startTime),
|
||||
clusterSchedule: emptyToNull(form.clusterSchedule),
|
||||
bibPickup: emptyToNull(form.bibPickup),
|
||||
bibNumber: emptyToNull(form.bibNumber),
|
||||
finishTime: emptyToNull(form.finishTime),
|
||||
finishPlace: emptyToNull(form.finishPlace),
|
||||
notes: emptyToNull(form.notes),
|
||||
};
|
||||
|
||||
await updateRace(raceId, payload);
|
||||
navigate(`/races/${raceId}`);
|
||||
} else {
|
||||
const id = generateId(form.date.trim(), form.title.trim());
|
||||
const payload: CreateRacePayload = {
|
||||
id,
|
||||
date: form.date.trim(),
|
||||
title: form.title.trim(),
|
||||
distanceKm: parseFloat(form.distanceKm),
|
||||
status: statusValue,
|
||||
officialUrl: emptyToNull(form.officialUrl),
|
||||
startTime: emptyToNull(form.startTime),
|
||||
clusterSchedule: emptyToNull(form.clusterSchedule),
|
||||
bibPickup: emptyToNull(form.bibPickup),
|
||||
bibNumber: emptyToNull(form.bibNumber),
|
||||
finishTime: emptyToNull(form.finishTime),
|
||||
finishPlace: emptyToNull(form.finishPlace),
|
||||
notes: emptyToNull(form.notes),
|
||||
};
|
||||
|
||||
const created = await createRace(payload);
|
||||
navigate(`/races/${created.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setErrorMessage(error.details.length > 0 ? error.details.join("; ") : error.message);
|
||||
} else {
|
||||
setErrorMessage("Произошла ошибка при сохранении.");
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[form, isEditMode, raceId, navigate],
|
||||
);
|
||||
|
||||
const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="page page--race-form" aria-busy="true">
|
||||
<h1 className="page__title">{pageTitle}</h1>
|
||||
<p className="page__subtitle">Загружаем данные...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page page--race-form">
|
||||
<h1 className="page__title">{pageTitle}</h1>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="page__subtitle page__subtitle--error" role="alert">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
{validationErrors.length > 0 ? (
|
||||
<ul className="form-errors" role="alert">
|
||||
{validationErrors.map((msg) => (
|
||||
<li key={msg} className="form-errors__item">{msg}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<form className="race-form" onSubmit={handleSubmit} noValidate>
|
||||
<fieldset className="race-form__group">
|
||||
<legend className="race-form__legend">Основная информация</legend>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Дата *</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="date"
|
||||
name="date"
|
||||
value={form.date}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Название *</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="title"
|
||||
value={form.title}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Казанский марафон"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Дистанция, км *</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="number"
|
||||
name="distanceKm"
|
||||
value={form.distanceKm}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0.1"
|
||||
step="0.001"
|
||||
placeholder="21.1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Статус</span>
|
||||
<select
|
||||
className="race-form__input"
|
||||
name="status"
|
||||
value={form.status}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="race-form__group">
|
||||
<legend className="race-form__legend">Организация</legend>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Сайт организатора</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="url"
|
||||
name="officialUrl"
|
||||
value={form.officialUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Время старта</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="startTime"
|
||||
value={form.startTime}
|
||||
onChange={handleChange}
|
||||
placeholder="09:30"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Расписание кластеров</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="clusterSchedule"
|
||||
value={form.clusterSchedule}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Выдача номеров</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="bibPickup"
|
||||
value={form.bibPickup}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Стартовый номер</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="bibNumber"
|
||||
value={form.bibNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="1234"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="race-form__group">
|
||||
<legend className="race-form__legend">Результаты</legend>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Финишное время</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="finishTime"
|
||||
value={form.finishTime}
|
||||
onChange={handleChange}
|
||||
placeholder="1:45:30"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Место на финише</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="finishPlace"
|
||||
value={form.finishPlace}
|
||||
onChange={handleChange}
|
||||
placeholder="12/340"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="race-form__group">
|
||||
<legend className="race-form__legend">Дополнительно</legend>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Заметки</span>
|
||||
<textarea
|
||||
className="race-form__input race-form__input--textarea"
|
||||
name="notes"
|
||||
value={form.notes}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div className="race-form__actions">
|
||||
<button className="btn btn--primary" type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Сохраняем…" : isEditMode ? "Сохранить" : "Создать"}
|
||||
</button>
|
||||
<Link
|
||||
className="btn btn--secondary"
|
||||
to={isEditMode && raceId ? `/races/${raceId}` : "/races"}
|
||||
>
|
||||
Отмена
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user