From c7b651011a6e3b5b98f8819cd2edbb1ea2b01537 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 4 May 2026 04:15:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(ux):=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B?= =?UTF-8?q?=20=D1=81=20=D0=B4=D0=B0=D1=82=D0=B0=D0=BC=D0=B8=20validFrom/va?= =?UTF-8?q?lidTo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (defensive): - GlobalExceptionHandler ловит HttpMessageNotReadableException → 400 c понятным сообщением вместо 500 'Internal server error'. - DateTimeParseException → code='invalid_date_format', сообщение с подсказкой ISO формата (например 2026-05-08T14:30:00+03:00). - InvalidFormatException → code='invalid_field_format' с именем сломанного поля. - Раньше падало 500 если фронт отправлял 'YYYY-MM-DD' вместо OffsetDateTime (из-за browser cache на старом bundle). Frontend: - Submit-валидация validFrom < validTo с inline error на validTo поле, переключение на таб 'Идентификация' с фокусом на ошибке. - DateTimeField теперь принимает hint и error props и показывает их под парой DatePicker+TextInput[time]. - Подсказки i18n под полями: 'Когда запись становится активной. Пусто = с момента создания.' / 'Когда перестаёт действовать. Пусто = бессрочно.' - Новая i18n ключ form.error.validToBeforeFrom (RU/EN). --- .../src/components/form/SchemaDrivenForm.tsx | 44 +++++++++++++------ ordinis-admin-ui/src/i18n.ts | 6 +++ .../restapi/error/GlobalExceptionHandler.java | 28 ++++++++++++ 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index daefea3..bac56a4 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -163,6 +163,15 @@ export const SchemaDrivenForm = ({ const idSource = (schema as { 'x-id-source'?: string })['x-id-source'] const submit: SubmitHandler = (values) => { + if (values.validFrom && values.validTo) { + const from = new Date(values.validFrom) + const to = new Date(values.validTo) + if (!isNaN(from.getTime()) && !isNaN(to.getTime()) && from >= to) { + setError('validTo', { type: 'manual', message: t('form.error.validToBeforeFrom') }) + setActiveTab('identity') + return + } + } if (validator) { const ok = validator(values.data) if (!ok && validator.errors && validator.errors.length > 0) { @@ -227,6 +236,7 @@ export const SchemaDrivenForm = ({ render={({ field }) => ( ( void + hint?: string + error?: string } -const DateTimeField = ({ label, value, defaultTime, onChange }: DateTimeFieldProps) => { +const DateTimeField = ({ label, value, defaultTime, onChange, hint, error }: DateTimeFieldProps) => { const dateValue = parseFormDate(value) const timeValue = extractTime(value, defaultTime) return ( -
- onChange(combineDateTime(d, timeValue, value ?? ''))} - /> -
- onChange(combineDateTime(dateValue, e.target.value, value ?? ''))} +
+
+ onChange(combineDateTime(d, timeValue, value ?? ''))} /> +
+ onChange(combineDateTime(dateValue, e.target.value, value ?? ''))} + /> +
+ {hint && !error &&

{hint}

} + {error &&

{error}

}
) } diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 0b03e81..177c386 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -38,7 +38,10 @@ i18n 'form.tabs.extra': 'Дополнительно', 'form.businessKey': 'Бизнес-ключ', 'form.validFrom': 'Действует с', + 'form.validFromHint': 'Когда запись становится активной. Пусто = с момента создания.', 'form.validTo': 'Действует до', + 'form.validToHint': 'Когда перестаёт действовать. Пусто = бессрочно.', + 'form.error.validToBeforeFrom': 'Дата окончания должна быть позже даты начала.', 'form.dataFields': 'Поля записи', 'form.cancel': 'Отмена', 'form.save': 'Сохранить', @@ -152,7 +155,10 @@ i18n 'form.tabs.extra': 'Additional', 'form.businessKey': 'Business key', 'form.validFrom': 'Valid from', + 'form.validFromHint': 'When this record becomes active. Empty = from creation moment.', 'form.validTo': 'Valid to', + 'form.validToHint': 'When it stops being valid. Empty = forever.', + 'form.error.validToBeforeFrom': 'Valid-to must be later than valid-from.', 'form.dataFields': 'Record fields', 'form.cancel': 'Cancel', 'form.save': 'Save', diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java index 7baed2d..9cb6373 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java @@ -1,15 +1,19 @@ package cloud.nstart.terravault.ordinis.restapi.error; import cloud.nstart.terravault.ordinis.validation.ValidationException; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import io.micrometer.tracing.Tracer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.time.format.DateTimeParseException; + import java.util.List; @RestControllerAdvice @@ -45,6 +49,30 @@ public class GlobalExceptionHandler { return ResponseEntity.badRequest().body(body); } + /** + * Невалидный JSON или несовместимый формат поля (напр. дата без времени для OffsetDateTime). + * Возвращаем 400 с понятным сообщением вместо 500. + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity onMessageNotReadable(HttpMessageNotReadableException e) { + String message = "Невалидный JSON в теле запроса"; + String code = "invalid_request_body"; + Throwable root = e.getRootCause() != null ? e.getRootCause() : e.getCause(); + if (root instanceof DateTimeParseException dtpe) { + message = "Поле даты не распарсилось: '" + dtpe.getParsedString() + + "'. Ожидается ISO-8601 datetime, например 2026-05-08T14:30:00+03:00."; + code = "invalid_date_format"; + } else if (e.getCause() instanceof InvalidFormatException ife) { + String field = ife.getPath().isEmpty() + ? "?" + : ife.getPath().get(ife.getPath().size() - 1).getFieldName(); + message = "Поле '" + field + "' не распарсилось. Ожидается " + ife.getTargetType().getSimpleName() + "."; + code = "invalid_field_format"; + } + var body = ApiErrorResponse.of(400, code, message, currentTraceId()); + return ResponseEntity.badRequest().body(body); + } + @ExceptionHandler(Exception.class) public ResponseEntity onGeneric(Exception e) { log.error("Unhandled exception in REST API", e);