fix: validate grade input and save modal without reload

- require a score and return a clear message for empty/non-numeric values
- handle unreadable API request bodies instead of falling back to a bare 400
- submit the grade modal via fetch, show errors in place and update the cell
- keep the page scroll position; restore the form feedback tests
This commit is contained in:
2026-10-22 23:29:47 +03:00
parent d287dd3119
commit 2e43f79256
10 changed files with 305 additions and 7 deletions

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.common
import org.springframework.dao.DataIntegrityViolationException import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
@@ -48,6 +49,20 @@ class LmsExceptionHandler {
), ),
) )
/**
* Тело запроса не удалось разобрать: пустая строка вместо числа, битый JSON и т.п.
* Без этого обработчика клиент получал стандартный ответ Spring без detail.
*/
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleNotReadable(): ResponseEntity<ApiError> =
ResponseEntity.badRequest().body(
ApiError(
title = "Bad Request",
status = 400,
detail = "Проверьте формат введённых данных",
),
)
/** Нарушение уникальности/ссылок (гонки двойной отправки) — конфликт, а не 500. */ /** Нарушение уникальности/ссылок (гонки двойной отправки) — конфликт, а не 500. */
@ExceptionHandler(DataIntegrityViolationException::class) @ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrity(): ResponseEntity<ApiError> = fun handleDataIntegrity(): ResponseEntity<ApiError> =

View File

@@ -353,13 +353,16 @@ class AdminGroupController(
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
val score = form.score.trim()
if (score.isEmpty()) throw ApiException(400, "Укажите оценку")
if (score.toBigDecimalOrNull() == null) throw ApiException(400, "Оценка должна быть числом")
val comments = parseComments(form.commentsJson ?: "[]") val comments = parseComments(form.commentsJson ?: "[]")
api.setGrade( api.setGrade(
token(session), token(session),
GradeSetPayload( GradeSetPayload(
studentId = form.studentId, studentId = form.studentId,
labId = form.labId, labId = form.labId,
score = form.score, score = score,
comments = comments, comments = comments,
generalComment = form.generalComment, generalComment = form.generalComment,
), ),

View File

@@ -79,14 +79,16 @@ class ApiClient(
rawDetail.isNotBlank() && !rawDetail.equals("Request validation failed", ignoreCase = true) -> rawDetail rawDetail.isNotBlank() && !rawDetail.equals("Request validation failed", ignoreCase = true) -> rawDetail
violations.isNotEmpty() -> violations.joinToString("; ") { it.message } violations.isNotEmpty() -> violations.joinToString("; ") { it.message }
fallback != null -> fallback fallback != null -> fallback
else -> "Ошибка запроса (${ex.statusCode.value()})" else -> fallbackDetail(ex.statusCode.value())
} }
ParsedError(detail, violations) ParsedError(detail, violations)
} catch (ignore: Exception) { } catch (ignore: Exception) {
ParsedError(fallback ?: "Ошибка запроса (${ex.statusCode.value()})", emptyList()) ParsedError(fallback ?: fallbackDetail(ex.statusCode.value()), emptyList())
} }
} }
private fun fallbackDetail(status: Int): String = if (status == 400) "Проверьте введённые данные" else "Ошибка запроса ($status)"
private fun detailFromBody(body: String): String? { private fun detailFromBody(body: String): String? {
val match = DETAIL_PATTERN.find(body) val match = DETAIL_PATTERN.find(body)
return match return match

View File

@@ -615,8 +615,95 @@ function focusLab() {
}); });
} }
/* ---------- Оценка: отправка без перезагрузки страницы ----------
* Модалка не закрывается при ошибке, значения остаются на месте,
* позиция прокрутки страницы не меняется. */
function submitGradeForm(form) {
if (form.dataset.saving === '1') return;
form.dataset.saving = '1';
var btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'fetch' }
})
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
})
.then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
// Сервер вернул форму с ошибкой — показываем её без перезагрузки.
var feedbackNode = doc.getElementById('form-feedback');
if (feedbackNode) {
var feedback = null;
try {
feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null');
} catch (e) {
feedback = null;
}
if (feedback && typeof window.lmsApplyFormFeedback === 'function') {
window.lmsApplyFormFeedback(feedback);
}
var errorToast = doc.querySelector('.toast-item.toast-error .toast-text');
showToast(errorToast
? errorToast.textContent.trim()
: ((feedback && feedback.general) || 'Не удалось сохранить оценку'), 'error');
return;
}
var student = form.querySelector('[name="studentId"]').value;
var lab = form.querySelector('[name="labId"]').value;
var fresh = doc.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
var current = document.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
var notice = doc.querySelector('.toast-item.toast-success .toast-text');
var errorWithoutFeedback = doc.querySelector('.toast-item.toast-error .toast-text');
if (errorWithoutFeedback) {
showToast(errorWithoutFeedback.textContent.trim(), 'error');
return;
}
if (!fresh && !notice) {
showToast('Не удалось сохранить оценку. Обновите страницу и попробуйте ещё раз.', 'error');
return;
}
if (fresh && current) {
['data-score', 'data-general-comment', 'data-comments', 'title'].forEach(function (attr) {
if (fresh.hasAttribute(attr)) current.setAttribute(attr, fresh.getAttribute(attr));
else current.removeAttribute(attr);
});
var freshLabel = fresh.querySelector('span');
var currentLabel = current.querySelector('span');
if (freshLabel && currentLabel) currentLabel.textContent = freshLabel.textContent;
current.classList.toggle('none', fresh.classList.contains('none'));
}
var openDialog = form.closest('dialog');
if (openDialog) {
var staleToasts = openDialog.querySelector('.toast-host');
if (staleToasts) staleToasts.remove();
}
if (window.closeModal) window.closeModal('modal-grade');
showToast(notice ? notice.textContent.trim() : 'Оценка сохранена', 'success');
})
.catch(function (e) {
showToast('Не удалось сохранить оценку: ' + e.message, 'error');
})
.finally(function () {
form.dataset.saving = '';
if (btn) btn.disabled = false;
});
}
document.addEventListener('submit', function (ev) { document.addEventListener('submit', function (ev) {
if (ev.target && ev.target.id === 'grade-form') csSerialize(); if (!ev.target || ev.target.id !== 'grade-form') return;
csSerialize();
ev.preventDefault();
submitGradeForm(ev.target);
}, true); }, true);
window.addEventListener('DOMContentLoaded', function () { window.addEventListener('DOMContentLoaded', function () {

View File

@@ -193,6 +193,16 @@
form.insertBefore(box, form.firstChild); form.insertBefore(box, form.firstChild);
} }
function clearAllErrors(form) {
form.querySelectorAll('.' + FIELD_ERROR).forEach(function (node) { node.remove(); });
form.querySelectorAll('.' + INVALID).forEach(function (el) {
el.classList.remove(INVALID);
el.removeAttribute('aria-invalid');
});
var summary = form.querySelector('.' + SUMMARY);
if (summary) summary.remove();
}
function focusFirstInvalid(form) { function focusFirstInvalid(form) {
var el = form.querySelector('.' + INVALID); var el = form.querySelector('.' + INVALID);
if (!el || typeof el.focus !== 'function') return; if (!el || typeof el.focus !== 'function') return;
@@ -223,6 +233,7 @@
if (!form) return; if (!form) return;
restoreValues(form, feedback.values); restoreValues(form, feedback.values);
clearAllErrors(form);
if (form.hidden) form.hidden = false; if (form.hidden) form.hidden = false;
var dialog = form.closest('dialog'); var dialog = form.closest('dialog');
@@ -248,6 +259,8 @@
function validationMessage(el) { function validationMessage(el) {
var v = el.validity || {}; var v = el.validity || {};
if (v.valueMissing) { if (v.valueMissing) {
var requiredMessage = el.getAttribute('data-error-required');
if (requiredMessage) return requiredMessage;
if (el.type === 'file') return 'Выберите файл'; if (el.type === 'file') return 'Выберите файл';
if (el.tagName === 'SELECT') return 'Выберите значение'; if (el.tagName === 'SELECT') return 'Выберите значение';
if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт'; if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт';
@@ -285,6 +298,9 @@
document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true); document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true);
document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true); document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true);
// Для AJAX-отправки форм: показать ответ сервера без перезагрузки страницы.
window.lmsApplyFormFeedback = applyFeedback;
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку // после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
setTimeout(function () { applyFeedback(parseFeedback()); }, 0); setTimeout(function () { applyFeedback(parseFeedback()); }, 0);

View File

@@ -159,7 +159,8 @@
<input type="hidden" name="commentsJson" id="grade-comments" value="[]"> <input type="hidden" name="commentsJson" id="grade-comments" value="[]">
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div> <div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
<div><label class="label" for="grade-score">Балл (0–5)</label> <div><label class="label" for="grade-score">Балл (0–5)</label>
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div> <input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1" required
data-error-required="Укажите оценку"></div>
<div><label class="label" for="grade-general">Общий комментарий по лабораторной</label> <div><label class="label" for="grade-general">Общий комментарий по лабораторной</label>
<textarea class="input" id="grade-general" name="generalComment" rows="4" <textarea class="input" id="grade-general" name="generalComment" rows="4"
placeholder="Итоговый комментарий по работе в целом…"></textarea></div> placeholder="Итоговый комментарий по работе в целом…"></textarea></div>
@@ -345,7 +346,7 @@
<div class="dlg-body" id="assign-modal-body"></div> <div class="dlg-body" id="assign-modal-body"></div>
</dialog> </dialog>
<script th:src="@{/js/admin-group.js(v='37')}" defer></script> <script th:src="@{/js/admin-group.js(v='38')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -27,7 +27,7 @@
<script th:src="@{/js/theme-init.js(v='36')}"></script> <script th:src="@{/js/theme-init.js(v='36')}"></script>
<script th:src="@{/js/app.js(v='36')}" defer></script> <script th:src="@{/js/app.js(v='36')}" defer></script>
<script th:src="@{/js/ui-state.js(v='36')}" defer></script> <script th:src="@{/js/ui-state.js(v='36')}" defer></script>
<script th:src="@{/js/form-feedback.js(v='37')}" defer></script> <script th:src="@{/js/form-feedback.js(v='38')}" defer></script>
<script th:src="@{/js/md-content.js(v='36')}" defer></script> <script th:src="@{/js/md-content.js(v='36')}" defer></script>
<script th:src="@{/js/submit-guard.js(v='36')}" defer></script> <script th:src="@{/js/submit-guard.js(v='36')}" defer></script>
</head> </head>

View File

@@ -0,0 +1,51 @@
package ru.ysu.lms.web.config
import org.hamcrest.Matchers.notNullValue
import org.junit.jupiter.api.Test
import org.springframework.stereotype.Controller
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import org.springframework.web.servlet.support.SessionFlashMapManager
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.form.FormFeedback
import tools.jackson.databind.ObjectMapper
class FormFeedbackFlashTest {
@Controller
private class SubmitController {
@PostMapping("/submit")
fun submit(
@RequestParam title: String,
redirect: RedirectAttributes,
): String {
// Контроллер поймал ошибку сервиса и возвращает пользователя на страницу.
val message = "Не удалось сохранить «$title»"
ApiException(400, message)
redirect.addFlashAttribute("error", message)
return "redirect:/done"
}
}
@Test
fun `feedback survives redirect through flash map`() {
val mockMvc =
MockMvcBuilders
.standaloneSetup(SubmitController())
.addInterceptors(FormFeedbackInterceptor(ObjectMapper()))
.setFlashMapManager(SessionFlashMapManager())
.build()
mockMvc
.post("/submit") { param("title", "Лабораторная") }
.andExpect {
status { is3xxRedirection() }
redirectedUrl("/done")
flash {
attribute(FormFeedback.MODEL_ATTR, notNullValue())
}
}
}
}

View File

@@ -0,0 +1,49 @@
package ru.ysu.lms.web.config
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.web.servlet.DispatcherServlet
import org.springframework.web.servlet.FlashMap
import org.springframework.web.servlet.ModelAndView
import ru.ysu.lms.web.form.FormFeedback
import tools.jackson.databind.ObjectMapper
class FormFeedbackInterceptorTest {
private val mapper = ObjectMapper()
private val interceptor = FormFeedbackInterceptor(mapper)
private val feedback =
FormFeedback(
action = "/admin/labs/create",
values = mapOf("title" to listOf("Лабораторная")),
errors = mapOf("title" to "Укажите название"),
general = "Проверьте поля",
)
@Test
fun `puts feedback into flash map on redirect`() {
val request = MockHttpServletRequest("POST", "/admin/labs/create")
request.setAttribute(FormFeedback.REQUEST_ATTR, feedback)
val flashMap = FlashMap()
request.setAttribute(DispatcherServlet.OUTPUT_FLASH_MAP_ATTRIBUTE, flashMap)
interceptor.postHandle(request, MockHttpServletResponse(), Any(), ModelAndView("redirect:/admin/labs"))
val json = flashMap[FormFeedback.MODEL_ATTR] as? String
assertNotNull(json)
assertEquals("/admin/labs/create", mapper.readTree(json).path("action").asString(""))
}
@Test
fun `puts feedback into model on direct render`() {
val request = MockHttpServletRequest("POST", "/login")
request.setAttribute(FormFeedback.REQUEST_ATTR, feedback)
val modelAndView = ModelAndView("login")
interceptor.postHandle(request, MockHttpServletResponse(), Any(), modelAndView)
assertNotNull(modelAndView.model[FormFeedback.MODEL_ATTR])
}
}

View File

@@ -0,0 +1,74 @@
package ru.ysu.lms.web.form
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import ru.ysu.lms.common.FieldViolation
import ru.ysu.lms.web.api.ApiException
class FormFeedbackTest {
@AfterEach
fun tearDown() {
RequestContextHolder.resetRequestAttributes()
}
private fun request(vararg params: Pair<String, String>): MockHttpServletRequest =
MockHttpServletRequest("POST", "/admin/labs/create").apply {
params.forEach { (name, value) -> addParameter(name, value) }
}
@Test
fun `captures values and field violations`() {
val request = request("title" to "Лабораторная", "number" to "0", "_csrf" to "token")
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
ApiException(400, "Request validation failed", listOf(FieldViolation("title", "Укажите название")))
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
assertNotNull(feedback)
assertEquals("/admin/labs/create", feedback!!.action)
assertEquals(listOf("Лабораторная"), feedback.values["title"])
assertEquals("Укажите название", feedback.errors["title"])
assertFalse(feedback.values.containsKey("_csrf"))
}
@Test
fun `does not keep passwords`() {
val request = request("login" to "admin", "password" to "secret")
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
ApiException(401, "Неверный логин или пароль")
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
assertEquals(listOf("admin"), feedback!!.values["login"])
assertEquals(emptyList<String>(), feedback.values["password"])
}
@Test
fun `infers field from domain message`() {
val request = request("score" to "7")
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
ApiException(400, "Оценка должна быть в диапазоне 0–5")
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
assertEquals("Оценка должна быть в диапазоне 0–5", feedback!!.errors["score"], feedback.toString())
}
@Test
fun `marks both file field names`() {
val request = request()
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
ApiException(400, "Выберите файл")
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
assertEquals("Выберите файл", feedback!!.errors["file"])
assertEquals("Выберите файл", feedback.errors["files"])
}
}