Front: Add edit training buttons
This commit is contained in:
parent
dc8cb10eed
commit
65979a3823
7 changed files with 192 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
46
frontend/js/edit-dialog.js
Normal file
46
frontend/js/edit-dialog.js
Normal 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
128
frontend/js/edit-form.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
7
frontend/vendor/bootstrap.bundle.min.js
vendored
Normal file
7
frontend/vendor/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue