diff --git a/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsExceptionHandler.kt b/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsExceptionHandler.kt index b2aff1b..f666e61 100644 --- a/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsExceptionHandler.kt +++ b/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsExceptionHandler.kt @@ -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 = + ResponseEntity.badRequest().body( + ApiError( + title = "Bad Request", + status = 400, + detail = "Проверьте формат введённых данных", + ), + ) + /** Нарушение уникальности/ссылок (гонки двойной отправки) — конфликт, а не 500. */ @ExceptionHandler(DataIntegrityViolationException::class) fun handleDataIntegrity(): ResponseEntity = diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index 3fd6d23..db606cd 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -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, ), diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index 3e0df22..dd156db 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -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 diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index c3c2321..33f2eae 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -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 () { diff --git a/web/src/main/resources/static/js/form-feedback.js b/web/src/main/resources/static/js/form-feedback.js index 71d7f33..cc923fa 100644 --- a/web/src/main/resources/static/js/form-feedback.js +++ b/web/src/main/resources/static/js/form-feedback.js @@ -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); diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index aefeda4..7906274 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -159,7 +159,8 @@
-
+
@@ -345,7 +346,7 @@
- + diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 9c194b6..3f7230a 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -27,7 +27,7 @@ - + diff --git a/web/src/test/kotlin/ru/ysu/lms/web/config/FormFeedbackFlashTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/config/FormFeedbackFlashTest.kt new file mode 100644 index 0000000..2a298e0 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/config/FormFeedbackFlashTest.kt @@ -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()) + } + } + } +} diff --git a/web/src/test/kotlin/ru/ysu/lms/web/config/FormFeedbackInterceptorTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/config/FormFeedbackInterceptorTest.kt new file mode 100644 index 0000000..6d8f03c --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/config/FormFeedbackInterceptorTest.kt @@ -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]) + } +} diff --git a/web/src/test/kotlin/ru/ysu/lms/web/form/FormFeedbackTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/form/FormFeedbackTest.kt new file mode 100644 index 0000000..d66e6b0 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/form/FormFeedbackTest.kt @@ -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): 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(), 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"]) + } +}