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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="vendor/chart.umd.js"></script>
|
<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.min.js"></script>
|
||||||
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||||
<script type="module" src="js/exercises.js"></script>
|
<script type="module" src="js/exercises.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="vendor/bootstrap.bundle.min.js"></script>
|
||||||
<script src="vendor/dataTables.min.js"></script>
|
<script src="vendor/dataTables.min.js"></script>
|
||||||
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||||
<script type="module" src="js/trainings.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 { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, tip } from './common.js';
|
||||||
|
import { editButton } from './edit-dialog.js';
|
||||||
import { createTable } from './table.js';
|
import { createTable } from './table.js';
|
||||||
|
|
||||||
const HINTS = {
|
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.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: 'Всего повторов', 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 [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// One table for all trainings: a row per exercise
|
// One table for all trainings: a row per exercise
|
||||||
|
|
||||||
import { alert, approachChips, formatDate, h, perArmBadge } from './common.js';
|
import { alert, approachChips, formatDate, h, perArmBadge } from './common.js';
|
||||||
|
import { editButton } from './edit-dialog.js';
|
||||||
import { createTable } from './table.js';
|
import { createTable } from './table.js';
|
||||||
|
|
||||||
export function trainingsView(trainings) {
|
export function trainingsView(trainings) {
|
||||||
|
|
@ -14,6 +15,8 @@ export function trainingsView(trainings) {
|
||||||
? training.exercises.map((exercise) => ({ training, exercise }))
|
? training.exercises.map((exercise) => ({ training, exercise }))
|
||||||
: [{ training, exercise: null }]);
|
: [{ 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;
|
const exerciseCount = rows.filter((row) => row.exercise).length;
|
||||||
|
|
||||||
return h('div', {},
|
return h('div', {},
|
||||||
|
|
@ -34,6 +37,10 @@ export function trainingsView(trainings) {
|
||||||
: h('span', { class: 'text-muted fst-italic' }, 'Упражнения не записаны'),
|
: h('span', { class: 'text-muted fst-italic' }, 'Упражнения не записаны'),
|
||||||
},
|
},
|
||||||
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...(row.exercise ? approachChips(row.exercise.approaches) : [])) },
|
{ 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