feat(ux): улучшение работы с датами validFrom/validTo
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).
This commit is contained in:
@@ -163,6 +163,15 @@ export const SchemaDrivenForm = ({
|
||||
const idSource = (schema as { 'x-id-source'?: string })['x-id-source']
|
||||
|
||||
const submit: SubmitHandler<FormValues> = (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 }) => (
|
||||
<DateTimeField
|
||||
label={t('form.validFrom')}
|
||||
hint={t('form.validFromHint')}
|
||||
value={field.value as string | undefined}
|
||||
defaultTime="00:00"
|
||||
onChange={field.onChange}
|
||||
@@ -239,6 +249,8 @@ export const SchemaDrivenForm = ({
|
||||
render={({ field }) => (
|
||||
<DateTimeField
|
||||
label={t('form.validTo')}
|
||||
hint={t('form.validToHint')}
|
||||
error={(formState.errors.validTo as { message?: string } | undefined)?.message}
|
||||
value={field.value as string | undefined}
|
||||
defaultTime="23:59"
|
||||
onChange={field.onChange}
|
||||
@@ -594,26 +606,32 @@ type DateTimeFieldProps = {
|
||||
value: string | undefined
|
||||
defaultTime: string
|
||||
onChange: (iso: string) => 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 (
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
||||
<DatePicker
|
||||
label={label}
|
||||
value={dateValue}
|
||||
onChange={(d) => onChange(combineDateTime(d, timeValue, value ?? ''))}
|
||||
/>
|
||||
<div className="w-28">
|
||||
<TextInput
|
||||
label="HH:mm"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => onChange(combineDateTime(dateValue, e.target.value, value ?? ''))}
|
||||
<div>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
||||
<DatePicker
|
||||
label={label}
|
||||
value={dateValue}
|
||||
onChange={(d) => onChange(combineDateTime(d, timeValue, value ?? ''))}
|
||||
/>
|
||||
<div className="w-28">
|
||||
<TextInput
|
||||
label="HH:mm"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => onChange(combineDateTime(dateValue, e.target.value, value ?? ''))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hint && !error && <p className="text-2xs text-carbon/60 mt-1">{hint}</p>}
|
||||
{error && <p className="text-2xs text-mars mt-1">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+28
@@ -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<ApiErrorResponse> 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<ApiErrorResponse> onGeneric(Exception e) {
|
||||
log.error("Unhandled exception in REST API", e);
|
||||
|
||||
Reference in New Issue
Block a user