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:
@@ -2,6 +2,7 @@ package ru.ysu.lms.common
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
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. */
|
||||
@ExceptionHandler(DataIntegrityViolationException::class)
|
||||
fun handleDataIntegrity(): ResponseEntity<ApiError> =
|
||||
|
||||
@@ -353,13 +353,16 @@ class AdminGroupController(
|
||||
redirect: RedirectAttributes,
|
||||
): String =
|
||||
try {
|
||||
val score = form.score.trim()
|
||||
if (score.isEmpty()) throw ApiException(400, "Укажите оценку")
|
||||
if (score.toBigDecimalOrNull() == null) throw ApiException(400, "Оценка должна быть числом")
|
||||
val comments = parseComments(form.commentsJson ?: "[]")
|
||||
api.setGrade(
|
||||
token(session),
|
||||
GradeSetPayload(
|
||||
studentId = form.studentId,
|
||||
labId = form.labId,
|
||||
score = form.score,
|
||||
score = score,
|
||||
comments = comments,
|
||||
generalComment = form.generalComment,
|
||||
),
|
||||
|
||||
@@ -79,14 +79,16 @@ class ApiClient(
|
||||
rawDetail.isNotBlank() && !rawDetail.equals("Request validation failed", ignoreCase = true) -> rawDetail
|
||||
violations.isNotEmpty() -> violations.joinToString("; ") { it.message }
|
||||
fallback != null -> fallback
|
||||
else -> "Ошибка запроса (${ex.statusCode.value()})"
|
||||
else -> fallbackDetail(ex.statusCode.value())
|
||||
}
|
||||
ParsedError(detail, violations)
|
||||
} 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? {
|
||||
val match = DETAIL_PATTERN.find(body)
|
||||
return match
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
@@ -193,6 +193,16 @@
|
||||
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) {
|
||||
var el = form.querySelector('.' + INVALID);
|
||||
if (!el || typeof el.focus !== 'function') return;
|
||||
@@ -223,6 +233,7 @@
|
||||
if (!form) return;
|
||||
|
||||
restoreValues(form, feedback.values);
|
||||
clearAllErrors(form);
|
||||
|
||||
if (form.hidden) form.hidden = false;
|
||||
var dialog = form.closest('dialog');
|
||||
@@ -248,6 +259,8 @@
|
||||
function validationMessage(el) {
|
||||
var v = el.validity || {};
|
||||
if (v.valueMissing) {
|
||||
var requiredMessage = el.getAttribute('data-error-required');
|
||||
if (requiredMessage) return requiredMessage;
|
||||
if (el.type === 'file') return 'Выберите файл';
|
||||
if (el.tagName === 'SELECT') return 'Выберите значение';
|
||||
if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт';
|
||||
@@ -285,6 +298,9 @@
|
||||
document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true);
|
||||
document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true);
|
||||
|
||||
// Для AJAX-отправки форм: показать ответ сервера без перезагрузки страницы.
|
||||
window.lmsApplyFormFeedback = applyFeedback;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
|
||||
setTimeout(function () { applyFeedback(parseFeedback()); }, 0);
|
||||
|
||||
@@ -159,7 +159,8 @@
|
||||
<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" 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>
|
||||
<textarea class="input" id="grade-general" name="generalComment" rows="4"
|
||||
placeholder="Итоговый комментарий по работе в целом…"></textarea></div>
|
||||
@@ -345,7 +346,7 @@
|
||||
<div class="dlg-body" id="assign-modal-body"></div>
|
||||
</dialog>
|
||||
|
||||
<script th:src="@{/js/admin-group.js(v='37')}" defer></script>
|
||||
<script th:src="@{/js/admin-group.js(v='38')}" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<script th:src="@{/js/theme-init.js(v='36')}"></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/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/submit-guard.js(v='36')}" defer></script>
|
||||
</head>
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
74
web/src/test/kotlin/ru/ysu/lms/web/form/FormFeedbackTest.kt
Normal file
74
web/src/test/kotlin/ru/ysu/lms/web/form/FormFeedbackTest.kt
Normal 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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user