Front: Add edit training buttons

This commit is contained in:
t0xa 2026-10-23 20:22:06 +03:00
parent dc8cb10eed
commit 65979a3823
7 changed files with 192 additions and 0 deletions

View file

@ -36,6 +36,7 @@
</main>
</div>
<script src="vendor/chart.umd.js"></script>
<script src="vendor/bootstrap.bundle.min.js"></script>
<script src="vendor/dataTables.min.js"></script>
<script src="vendor/dataTables.bootstrap5.min.js"></script>
<script type="module" src="js/exercises.js"></script>

View file

@ -27,6 +27,7 @@
</main>
</div>
<script src="vendor/bootstrap.bundle.min.js"></script>
<script src="vendor/dataTables.min.js"></script>
<script src="vendor/dataTables.bootstrap5.min.js"></script>
<script type="module" src="js/trainings.js"></script>

View file

@ -0,0 +1,46 @@
// Bootstrap modal with the training edit form
import { alert, api, h } from './common.js';
import { editForm } from './edit-form.js';
/** "Изменить" button: opens the modal, the page reloads after a save to show fresh data */
export function editButton(trainingId) {
const button = h('button', { type: 'button', class: 'btn btn-sm btn-outline-secondary' }, 'Изменить');
button.addEventListener('click', () => openEditDialog(trainingId, () => location.reload()));
return button;
}
/** Opens the form for the training; onChange() is called after it was saved or deleted. */
export async function openEditDialog(trainingId, onChange) {
const body = h('div', { class: 'modal-body' }, h('div', { class: 'text-muted' }, 'Загрузка…'));
const element = h('div', { class: 'modal fade', tabindex: '-1', 'aria-labelledby': 'edit-modal-title', 'aria-hidden': 'true' },
h('div', { class: 'modal-dialog modal-lg modal-dialog-scrollable' },
h('div', { class: 'modal-content' },
h('div', { class: 'modal-header' },
h('h2', { class: 'modal-title h5', id: 'edit-modal-title' }, 'Редактирование тренировки'),
h('button', { type: 'button', class: 'btn-close', 'data-bs-dismiss': 'modal', 'aria-label': 'Закрыть' })),
body)));
const modal = new bootstrap.Modal(element);
element.addEventListener('hidden.bs.modal', () => {
modal.dispose();
element.remove();
});
document.body.append(element);
modal.show();
try {
const [training, exerciseNames] = await Promise.all([
api(`/trainings/${trainingId}/`),
api('/exercises/'),
]);
body.replaceChildren(editForm(training, exerciseNames, {
onDone: () => { modal.hide(); onChange(); },
onCancel: () => modal.hide(),
}));
} catch (error) {
body.replaceChildren(alert('danger', error.status === 404
? 'Тренировка не найдена.'
: `Не удалось загрузить тренировку: ${error.message}`));
}
}

128
frontend/js/edit-form.js Normal file
View file

@ -0,0 +1,128 @@
// Form for editing one training: date, trainer, exercises and their approaches
import { alert, api, h } from './common.js';
const jsonRequest = (method, body) => ({
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
function field(label, input, className = '') {
return h('div', { class: className }, h('label', { class: 'form-label small text-muted mb-1' }, label), input);
}
function approachRow(approach) {
const row = h('div', { class: 'row g-2 align-items-end approach-row mb-2' },
h('div', { class: 'col-5 col-md-3' },
h('input', { type: 'number', class: 'form-control form-control-sm', name: 'weight', min: 0, step: 'any', required: true, value: approach.weight, 'aria-label': 'Вес, кг' })),
h('div', { class: 'col-auto pb-1' }, '×'),
h('div', { class: 'col-4 col-md-2' },
h('input', { type: 'number', class: 'form-control form-control-sm', name: 'reps', min: 1, step: 1, required: true, value: approach.reps, 'aria-label': 'Повторения' })),
h('div', { class: 'col-auto' },
h('button', { type: 'button', class: 'btn btn-sm btn-outline-danger', title: 'Убрать подход' }, '×')));
row.querySelector('button').addEventListener('click', () => row.remove());
return row;
}
function exerciseCard(exercise) {
const approaches = h('div', {}, ...exercise.approaches.map(approachRow));
const addApproach = h('button', { type: 'button', class: 'btn btn-sm btn-outline-secondary' }, '+ подход');
addApproach.addEventListener('click', () => {
// a new approach starts as a copy of the last one: sets usually repeat
const last = approaches.lastElementChild;
approaches.append(approachRow({
weight: last ? last.querySelector('[name=weight]').value : 0,
reps: last ? last.querySelector('[name=reps]').value : 10,
}));
});
const remove = h('button', { type: 'button', class: 'btn btn-sm btn-outline-danger', title: 'Убрать упражнение' }, 'Убрать упражнение');
const card = h('div', { class: 'card exercise-card' },
h('div', { class: 'card-body' },
h('div', { class: 'row g-2 align-items-end mb-3' },
field('Упражнение', h('input', { type: 'text', class: 'form-control', name: 'name', list: 'edit-exercise-names', maxlength: 255, required: true, value: exercise.name }), 'col-12 col-md-6'),
h('div', { class: 'col-auto form-check ms-2 mb-2' },
h('input', { type: 'checkbox', class: 'form-check-input', name: 'splitted_weight', id: `per-arm-${Math.random().toString(36).slice(2)}`, checked: exercise.splitted_weight }),
h('label', { class: 'form-check-label' }, 'Вес на каждую руку')),
h('div', { class: 'col-auto ms-md-auto' }, remove)),
h('div', { class: 'small text-muted mb-1' }, 'Подходы: вес, кг (0 — без веса) × повторения'),
approaches,
addApproach));
// the checkbox label must point to its checkbox
const checkbox = card.querySelector('[name=splitted_weight]');
card.querySelector('.form-check-label').setAttribute('for', checkbox.id);
remove.addEventListener('click', () => card.remove());
return card;
}
function readForm(form) {
return {
date: form.elements.date.value,
trainer: form.elements.trainer.value,
exercises: [...form.querySelectorAll('.exercise-card')].map((card) => ({
name: card.querySelector('[name=name]').value,
splitted_weight: card.querySelector('[name=splitted_weight]').checked,
approaches: [...card.querySelectorAll('.approach-row')].map((row) => ({
weight: Number(row.querySelector('[name=weight]').value),
reps: Number(row.querySelector('[name=reps]').value),
})),
})),
};
}
/** onDone() is called after the training was saved or deleted, onCancel() on "Отмена" */
export function editForm(training, exerciseNames, { onDone, onCancel }) {
const exercisesBox = h('div', {}, ...training.exercises.map(exerciseCard));
const addExercise = h('button', { type: 'button', class: 'btn btn-outline-secondary mb-3' }, '+ упражнение');
addExercise.addEventListener('click', () => {
const card = exerciseCard({ name: '', splitted_weight: false, approaches: [{ weight: 0, reps: 10 }] });
exercisesBox.append(card);
card.querySelector('[name=name]').focus();
});
const status = h('div', {});
const save = h('button', { type: 'submit', class: 'btn btn-primary' }, 'Сохранить');
const cancel = h('button', { type: 'button', class: 'btn btn-outline-secondary' }, 'Отмена');
cancel.addEventListener('click', onCancel);
const remove = h('button', { type: 'button', class: 'btn btn-outline-danger ms-auto' }, 'Удалить тренировку');
const form = h('form', {},
h('datalist', { id: 'edit-exercise-names' }, ...exerciseNames.map((item) => h('option', { value: item.name }))),
h('div', { class: 'row g-3 mb-3' },
field('Дата', h('input', { type: 'date', class: 'form-control', name: 'date', required: true, value: training.date }), 'col-6 col-md-3'),
field('Тренер', h('input', { type: 'text', class: 'form-control', name: 'trainer', maxlength: 100, value: training.trainer || '' }), 'col-6 col-md-3')),
exercisesBox,
addExercise,
h('div', { class: 'd-flex gap-2 align-items-center' },
save,
cancel,
remove),
status);
form.addEventListener('submit', async (event) => {
event.preventDefault();
save.disabled = true;
try {
await api(`/trainings/${training.id}/`, jsonRequest('PUT', readForm(form)));
onDone();
} catch (error) {
status.replaceChildren(alert('danger', `Не удалось сохранить: ${error.message}`));
save.disabled = false;
}
});
remove.addEventListener('click', async () => {
if (!confirm('Удалить тренировку вместе с упражнениями и подходами?')) {
return;
}
try {
await api(`/trainings/${training.id}/`, jsonRequest('DELETE'));
onDone();
} catch (error) {
status.replaceChildren(alert('danger', `Не удалось удалить: ${error.message}`));
}
});
return form;
}

View file

@ -1,4 +1,5 @@
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, tip } from './common.js';
import { editButton } from './edit-dialog.js';
import { createTable } from './table.js';
const HINTS = {
@ -115,6 +116,7 @@ function progressView(progress) {
{ title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` },
{ title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` },
]),
{ title: '', cell: (row) => row.training_id ? editButton(row.training_id) : null },
];
return [

View file

@ -1,6 +1,7 @@
// One table for all trainings: a row per exercise
import { alert, approachChips, formatDate, h, perArmBadge } from './common.js';
import { editButton } from './edit-dialog.js';
import { createTable } from './table.js';
export function trainingsView(trainings) {
@ -14,6 +15,8 @@ export function trainingsView(trainings) {
? training.exercises.map((exercise) => ({ training, exercise }))
: [{ training, exercise: null }]);
// Trainings from the parser preview are not saved yet: nothing to edit
const editable = trainings.every((training) => training.id);
const exerciseCount = rows.filter((row) => row.exercise).length;
return h('div', {},
@ -34,6 +37,10 @@ export function trainingsView(trainings) {
: h('span', { class: 'text-muted fst-italic' }, 'Упражнения не записаны'),
},
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...(row.exercise ? approachChips(row.exercise.approaches) : [])) },
...(editable ? [{
title: '',
cell: (row) => editButton(row.training.id),
}] : []),
],
}));
}

File diff suppressed because one or more lines are too long