This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/struts.git
commit 74778108a1a7a0700970a003106c6bf6cbcdbe54 Author: Lukasz Lenart <[email protected]> AuthorDate: Sun Oct 25 15:34:41 2026 +0100 WW-5811 feat(html5): derive date min/max and temporal required constraints The date control now gets min/max from a range validator whose bounds are LocalDate (localdate) or java.util.Date (date). A Date bound is reduced to its calendar date in the default time zone, the zone the submitted yyyy-MM-dd is parsed in; dropping the time never makes the browser stricter than the server. Years outside 0001-9999 are skipped. A required validator on a temporal control now emits required when the bound property's type converts "" to null (Date and subclasses, Calendar, LocalDate, LocalDateTime, LocalTime, OffsetDateTime), since the empty submit then fails on both sides. A String property keeps "", so it still gets no required. The provider needs the property type for that, so HtmlConstraintProvider gains a default constraintsFor overload carrying it, which delegates to the 7.4.0 method; UIBean calls the new one. Form resolves the type on the object binding writes to (the pushed model, then the action) and walks declared types, so a null intermediate object does not hide it; an indexed or unknown path gives null and no required. month, week, time and datetime-local get no min/max: no built-in converter and range validator pair round-trips their submitted values. Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../java/org/apache/struts2/components/Form.java | 55 +++++++++++ .../struts2/components/HtmlConstraintProvider.java | 14 +++ .../apache/struts2/components/HtmlControlType.java | 7 ++ .../components/StrutsHtmlConstraintProvider.java | 92 ++++++++++++++++-- .../java/org/apache/struts2/components/UIBean.java | 3 +- .../struts2/components/ConstraintAction.java | 18 ++++ .../components/ConstraintAttributesTest.java | 90 +++++++++++++++++ .../StrutsHtmlConstraintProviderTest.java | 107 ++++++++++++++++++++- .../views/jsp/ui/Html5ConstraintRenderingTest.java | 47 ++++++++- .../components/ConstraintAction-validation.xml | 15 +++ 10 files changed, 432 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/org/apache/struts2/components/Form.java b/core/src/main/java/org/apache/struts2/components/Form.java index c97755eb5..8e5ccdbab 100644 --- a/core/src/main/java/org/apache/struts2/components/Form.java +++ b/core/src/main/java/org/apache/struts2/components/Form.java @@ -30,6 +30,8 @@ import org.apache.struts2.config.entities.InterceptorMapping; import org.apache.struts2.inject.Inject; import org.apache.struts2.interceptor.MethodFilterInterceptorUtil; import org.apache.struts2.util.ValueStack; +import org.apache.struts2.util.reflection.ReflectionException; +import org.apache.struts2.util.reflection.ReflectionProvider; import org.apache.struts2.text.CompositeTextProvider; import org.apache.struts2.text.TextProvider; import org.apache.struts2.text.TextProviderFactory; @@ -48,6 +50,8 @@ import org.apache.struts2.dispatcher.mapper.ActionMapping; import org.apache.struts2.views.annotations.StrutsTag; import org.apache.struts2.views.annotations.StrutsTagAttribute; +import java.beans.IntrospectionException; +import java.beans.PropertyDescriptor; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; @@ -143,6 +147,7 @@ public class Form extends ClosingUIBean { private final Map<String, List<Object>> visitedObjects = new HashMap<>(); private Boolean modelDrivenConfigured; protected TextProviderFactory textProviderFactory; + private ReflectionProvider reflectionProvider; public Form(ValueStack stack, HttpServletRequest request, HttpServletResponse response) { super(stack, request, response); @@ -188,6 +193,11 @@ public class Form extends ClosingUIBean { this.textProviderFactory = textProviderFactory; } + @Inject + public void setReflectionProvider(ReflectionProvider reflectionProvider) { + this.reflectionProvider = reflectionProvider; + } + /* * Revised for Portlet actionURL as form action, and add wwAction as hidden @@ -428,6 +438,51 @@ public class Form extends ClosingUIBean { return chain; } + /** + * The declared type of the property a field binds to, found on the object parameter binding would + * write to and then walked through declared types rather than values, so a null intermediate object + * (which binding creates) does not hide it. Null for an indexed or unknown path. + */ + Class<?> getPropertyType(String name) { + if (name == null || name.isEmpty() || StringUtils.containsAny(name, '[', '(')) { + return null; + } + String[] segments = name.split("\\."); + for (Object root : bindingRoots()) { + Class<?> type = declaredType(root.getClass(), segments[0]); + if (type != null) { + for (int i = 1; i < segments.length && type != null; i++) { + type = declaredType(type, segments[i]); + } + return type; + } + } + return null; + } + + private List<Object> bindingRoots() { + Object invoked = currentAction(); + if (invoked == null) { + return new ArrayList<>(getStack().getRoot()); + } + List<Object> roots = new ArrayList<>(2); + Object model = modelPushedAbove(invoked, getStack().getRoot()); + if (model != null) { + roots.add(model); + } + roots.add(invoked); + return roots; + } + + private Class<?> declaredType(Class<?> owner, String property) { + try { + PropertyDescriptor descriptor = reflectionProvider.getPropertyDescriptor(owner, property); + return descriptor == null ? null : descriptor.getPropertyType(); + } catch (IntrospectionException | ReflectionException e) { + return null; + } + } + private Object currentAction() { ActionInvocation invocation = getStack().getActionContext().getActionInvocation(); return invocation == null ? null : invocation.getAction(); diff --git a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java index b6eb36779..e7ffe1578 100644 --- a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java +++ b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java @@ -51,4 +51,18 @@ public interface HtmlConstraintProvider { * @return attribute name to value; never null, possibly empty */ Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action, Object value); + + /** + * What the framework calls. The default ignores {@code propertyType} and delegates to + * {@link #constraintsFor(List, HtmlControlType, Object, Object)}, so a provider written against 7.4.0 + * keeps working unchanged. + * + * @param propertyType the declared type of the property the field binds to; null when it cannot be + * resolved + * @since 7.5.0 + */ + default Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action, + Object value, Class<?> propertyType) { + return constraintsFor(validators, control, action, value); + } } diff --git a/core/src/main/java/org/apache/struts2/components/HtmlControlType.java b/core/src/main/java/org/apache/struts2/components/HtmlControlType.java index c432042f7..72b3d9ec3 100644 --- a/core/src/main/java/org/apache/struts2/components/HtmlControlType.java +++ b/core/src/main/java/org/apache/struts2/components/HtmlControlType.java @@ -80,4 +80,11 @@ public enum HtmlControlType { public boolean supportsRange() { return NUMERIC.contains(this) || TEMPORAL.contains(this); } + + /** + * @since 7.5.0 + */ + public boolean isTemporal() { + return TEMPORAL.contains(this); + } } diff --git a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java index d7aa859fe..f53839f5c 100644 --- a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java +++ b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java @@ -29,6 +29,14 @@ import org.apache.struts2.validator.validators.RequiredStringValidator; import org.apache.struts2.validator.validators.StringLengthFieldValidator; import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.Calendar; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -42,7 +50,8 @@ import java.util.regex.Pattern; * an input's {@code type}</em> — switching a field to {@code type="number"} would reject * {@code 1234,50}, which the framework's locale-aware conversion accepts in a comma-decimal locale, * and the browsers' {@code email}/{@code url} grammars differ from the framework's validators. - * Range constraints are therefore emitted only on a control the developer already made numeric. + * Range constraints are therefore emitted only on a control the developer already made numeric, or + * {@code date}. * * @since 7.4.0 */ @@ -67,12 +76,21 @@ public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { @Override public Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action, Object value) { + return constraintsFor(validators, control, action, value, null); + } + + @Override + public Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action, + Object value, Class<?> propertyType) { Map<String, String> attributes = new LinkedHashMap<>(); if (validators == null || validators.isEmpty() || control == null) { return attributes; } for (Validator validator : validators) { addConstraints(attributes, validator, control, value); + if (validator instanceof RequiredFieldValidator) { + addTemporalRequired(attributes, control, propertyType); + } if (control != HtmlControlType.UNSUPPORTED) { addMessage(attributes, validator, action); } @@ -119,7 +137,11 @@ public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { } else if (validator instanceof DoubleRangeFieldValidator doubleValidator) { addDoubleRange(attributes, doubleValidator, control); } else if (validator instanceof RangeValidatorSupport<?> rangeValidator) { - addRange(attributes, rangeValidator, control); + if (control == HtmlControlType.DATE) { + addDateRange(attributes, rangeValidator); + } else { + addRange(attributes, rangeValidator, control); + } } } @@ -139,7 +161,8 @@ public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { * an empty string rather than omitting the parameter therefore passes server-side while the browser * blocks it — an empty text input, a select with an empty-valued header option, and an unticked * checkbox (CheckboxInterceptor substitutes "false") are all in that group. Only RADIO and FILE omit - * the parameter entirely when empty, so only they agree with the browser. + * the parameter entirely when empty, so only they agree with the browser here; temporal controls are + * decided by property type in {@link #addTemporalRequired}. * <p> * An omitted parameter leaves the property at whatever it already holds, which is the value being * rendered — a primitive's 0, or what {@code prepare()} loaded. The validator itself decides whether @@ -155,6 +178,63 @@ public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { } } + /** + * An empty temporal input submits {@code ""}, which the built-in converters turn into null for the + * types {@link #convertsBlankToNull(Class)} accepts, so {@code required} then fails on both sides. + * + * @since 7.5.0 + */ + protected void addTemporalRequired(Map<String, String> attributes, HtmlControlType control, Class<?> propertyType) { + if (control.isTemporal() && convertsBlankToNull(propertyType)) { + attributes.put(REQUIRED_ATTRIBUTE, REQUIRED_ATTRIBUTE); + } + } + + /** + * A property with its own converter that maps {@code ""} to a value needs a provider overriding this. + * + * @since 7.5.0 + */ + protected boolean convertsBlankToNull(Class<?> propertyType) { + if (propertyType == null) { + return false; + } + return Date.class.isAssignableFrom(propertyType) + || Calendar.class.isAssignableFrom(propertyType) + || LocalDate.class.isAssignableFrom(propertyType) + || LocalDateTime.class.isAssignableFrom(propertyType) + || LocalTime.class.isAssignableFrom(propertyType) + || OffsetDateTime.class.isAssignableFrom(propertyType); + } + + /** + * A {@code java.util.Date} bound becomes its calendar date in the default time zone, the zone the + * submitted {@code yyyy-MM-dd} is parsed in. Dropping the time leaves the browser no stricter than the + * server: a min of 10:00 on the 1st still lets the 1st through client-side. + * + * @since 7.5.0 + */ + protected void addDateRange(Map<String, String> attributes, RangeValidatorSupport<?> validator) { + putIfPresent(attributes, "min", toHtmlDate(validator.getMin())); + putIfPresent(attributes, "max", toHtmlDate(validator.getMax())); + } + + private String toHtmlDate(Object bound) { + LocalDate date; + if (bound instanceof LocalDate localDate) { + date = localDate; + } else if (bound instanceof Date utilDate) { + date = Instant.ofEpochMilli(utilDate.getTime()).atZone(ZoneId.systemDefault()).toLocalDate(); + } else { + return null; + } + // a valid HTML date needs a positive year, and LocalDate writes years past 9999 with a sign + if (date.getYear() < 1 || date.getYear() > 9999) { + return null; + } + return date.toString(); + } + protected void addLength(Map<String, String> attributes, StringLengthFieldValidator validator, HtmlControlType control) { // with trim=true the server measures the trimmed value, so a maxlength taken from it would // stop the user typing input the server would have accepted @@ -193,9 +273,6 @@ public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { protected void addRange(Map<String, String> attributes, RangeValidatorSupport<?> validator, HtmlControlType control) { if (!isNumericRange(control)) { - // Temporal controls support ranges too, but min/max there need per-control ISO - // formatting (date -> yyyy-MM-dd, month -> yyyy-MM, week -> yyyy-'W'ww, time -> HH:mm). - // Deliberately deferred; DateRangeFieldValidator therefore emits nothing for now. return; } // min is guarded by isIntegral; see the comment on that method. The shipped Integer/Short/Long @@ -212,9 +289,6 @@ public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { protected void addDoubleRange(Map<String, String> attributes, DoubleRangeFieldValidator validator, HtmlControlType control) { if (!isNumericRange(control)) { - // Temporal controls support ranges too, but min/max there need per-control ISO - // formatting (date -> yyyy-MM-dd, month -> yyyy-MM, week -> yyyy-'W'ww, time -> HH:mm). - // Deliberately deferred; DateRangeFieldValidator therefore emits nothing for now. return; } // exclusive bounds have no HTML equivalent; omitting them leaves the browser more diff --git a/core/src/main/java/org/apache/struts2/components/UIBean.java b/core/src/main/java/org/apache/struts2/components/UIBean.java index f193230a4..353fd6abd 100644 --- a/core/src/main/java/org/apache/struts2/components/UIBean.java +++ b/core/src/main/java/org/apache/struts2/components/UIBean.java @@ -974,6 +974,7 @@ public abstract class UIBean extends Component { int stackDepth = stack.getRoot().size(); try { List<Validator> validators = form.getFieldValidators(fieldName); + Class<?> propertyType = form.getPropertyType(fieldName); // validation resolves a nested message with every visited object on the stack; both // happen only when the whole chain exists, so ${...} agrees with the object handed over // (restoreStackDepth pops the pushes) @@ -986,7 +987,7 @@ public abstract class UIBean extends Component { } } Map<String, String> constraints = htmlConstraintProvider.constraintsFor( - validators, getControlType(), validated, getAttributes().get(ATTR_NAME_VALUE)); + validators, getControlType(), validated, getAttributes().get(ATTR_NAME_VALUE), propertyType); if (constraints.isEmpty()) { return; } diff --git a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java index 59dee88bc..307554bea 100644 --- a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java +++ b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java @@ -41,6 +41,24 @@ public class ConstraintAction extends ActionSupport { private Date birthday; private LocalDate dueDate; private LocalDateTime meetingAt; + private LocalDate startDate; + private String dateText; + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public String getDateText() { + return dateText; + } + + public void setDateText(String dateText) { + this.dateText = dateText; + } public String getUsername() { return username; diff --git a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java index 2203f1256..b87c3a9a6 100644 --- a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java +++ b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java @@ -417,6 +417,96 @@ public class ConstraintAttributesTest extends AbstractUITagTest { depthBeforeRendering, stack.getRoot().size()); } + public void testTheProviderReceivesTheDeclaredTypeOfTheBoundProperty() throws Exception { + initDispatcherWith("true"); + fieldName = "birthday"; + + assertEquals(java.util.Date.class, propertyTypeHandedToTheProvider()); + } + + /** + * Parameter binding creates the missing {@code user} and {@code address}, so their being null at + * render time must not hide the type of the property the field ends up in. + */ + public void testANestedPropertyTypeIsResolvedThroughNullIntermediateObjects() throws Exception { + initDispatcherWith("true"); + fieldName = "user.address.street"; + + assertEquals(String.class, propertyTypeHandedToTheProvider()); + } + + public void testThePropertyTypeIsReadFromTheModelWhenTheModelDrivenInterceptorPushedIt() throws Exception { + actionOverride = new TemporalModelDrivenAction(); + actionName = "modelDrivenConstraintAction"; + initDispatcherWith("true"); + fieldName = "startDate"; + stack.push(((TemporalModelDrivenAction) action).getModel()); + + assertEquals(java.time.LocalDate.class, propertyTypeHandedToTheProvider()); + } + + public void testAnIndexedFieldHandsTheProviderNoType() throws Exception { + initDispatcherWith("true"); + fieldName = "user.address[0].street"; + + assertNull(propertyTypeHandedToTheProvider()); + } + + public void testAnUnknownFieldHandsTheProviderNoType() throws Exception { + initDispatcherWith("true"); + fieldName = "user.nothing"; + + assertNull(propertyTypeHandedToTheProvider()); + } + + private Class<?> propertyTypeHandedToTheProvider() throws Exception { + List<Class<?>> received = new ArrayList<>(); + TextFieldTag field = startField(null); + ((UIBean) field.getComponent()).setHtmlConstraintProvider(new HtmlConstraintProvider() { + @Override + public Map<String, String> constraintsFor(List<org.apache.struts2.validator.Validator> validators, + HtmlControlType control, Object action, Object value) { + throw new AssertionError("the framework must call the overload carrying the property type"); + } + + @Override + public Map<String, String> constraintsFor(List<org.apache.struts2.validator.Validator> validators, + HtmlControlType control, Object action, Object value, + Class<?> propertyType) { + received.add(propertyType); + return Collections.emptyMap(); + } + }); + + finishField(field); + + assertEquals("the provider must be called exactly once", 1, received.size()); + return received.get(0); + } + + public static class TemporalModelDrivenAction extends ModelDrivenConstraintAction { + + private final TemporalModel model = new TemporalModel(); + + @Override + public ConstraintAction getModel() { + return model; + } + } + + public static class TemporalModel extends ConstraintAction { + + private java.time.LocalDate startDate; + + public java.time.LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(java.time.LocalDate startDate) { + this.startDate = startDate; + } + } + @SuppressWarnings("unchecked") private Map<String, String> renderFieldAndReturnConstraints(String type) throws Exception { TextFieldTag field = startField(type); diff --git a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java index b4e98fe24..92a067aac 100644 --- a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java +++ b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java @@ -25,18 +25,29 @@ import org.apache.struts2.validator.validators.DateRangeFieldValidator; import org.apache.struts2.validator.validators.DoubleRangeFieldValidator; import org.apache.struts2.validator.validators.EmailValidator; import org.apache.struts2.validator.validators.IntRangeFieldValidator; +import org.apache.struts2.validator.validators.LocalDateRangeFieldValidator; import org.apache.struts2.validator.validators.RangeValidatorSupport; import org.apache.struts2.validator.validators.RegexFieldValidator; import org.apache.struts2.validator.validators.RequiredFieldValidator; import org.apache.struts2.validator.validators.RequiredStringValidator; import org.apache.struts2.validator.validators.StringLengthFieldValidator; +import org.junit.After; import org.junit.Before; import org.junit.Test; import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.Calendar; import java.util.Date; +import java.util.GregorianCalendar; import java.util.List; import java.util.Map; +import java.util.TimeZone; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; @@ -45,13 +56,26 @@ import static org.mockito.Mockito.when; public class StrutsHtmlConstraintProviderTest { + private static final TimeZone TOKYO = TimeZone.getTimeZone("Asia/Tokyo"); + private StrutsHtmlConstraintProvider provider; private Object action; + private TimeZone defaultTimeZone; @Before public void setUp() { provider = new StrutsHtmlConstraintProvider(); action = new ActionSupport(); + defaultTimeZone = TimeZone.getDefault(); + } + + @After + public void tearDown() { + TimeZone.setDefault(defaultTimeZone); + } + + private static Date tokyo(LocalDateTime dateTime) { + return Date.from(dateTime.atZone(ZoneId.of("Asia/Tokyo")).toInstant()); } private Map<String, String> constraints(Validator validator, HtmlControlType control) { @@ -334,14 +358,91 @@ public class StrutsHtmlConstraintProviderTest { } @Test - public void dateRangeEmitsNothingBecauseTemporalFormattingIsDeferred() { + public void localDateRangeEmitsIsoBoundsOnADateControl() { + LocalDateRangeFieldValidator validator = new LocalDateRangeFieldValidator(); + validator.setMin(LocalDate.of(2026, 1, 1)); + validator.setMax(LocalDate.of(2026, 12, 31)); + + assertThat(constraints(validator, HtmlControlType.DATE)) + .containsEntry("min", "2026-01-01") + .containsEntry("max", "2026-12-31"); + } + + @Test + public void dateRangeEmitsTheCalendarDateOfItsBoundsInTheServerZone() { + TimeZone.setDefault(TOKYO); DateRangeFieldValidator validator = new DateRangeFieldValidator(); - validator.setMin(new Date(0)); - validator.setMax(new Date(1_000_000L)); + validator.setMin(tokyo(LocalDateTime.of(2026, 1, 1, 5, 0))); + validator.setMax(tokyo(LocalDateTime.of(2026, 12, 31, 23, 30))); + + assertThat(constraints(validator, HtmlControlType.DATE)) + .containsEntry("min", "2026-01-01") + .containsEntry("max", "2026-12-31"); + } + + @Test + public void dateRangeEmitsNothingOnTheOtherTemporalControls() { + LocalDateRangeFieldValidator validator = new LocalDateRangeFieldValidator(); + validator.setMin(LocalDate.of(2026, 1, 1)); + validator.setMax(LocalDate.of(2026, 12, 31)); + + for (HtmlControlType control : List.of(HtmlControlType.MONTH, HtmlControlType.WEEK, + HtmlControlType.TIME, HtmlControlType.DATETIME_LOCAL)) { + assertThat(constraints(validator, control)).as(control.name()).isEmpty(); + } + } + + @Test + public void localDateRangeOmitsABoundOutsideTheYearsHtmlCanWrite() { + LocalDateRangeFieldValidator validator = new LocalDateRangeFieldValidator(); + validator.setMin(LocalDate.of(0, 1, 1)); + validator.setMax(LocalDate.of(10000, 1, 1)); assertThat(constraints(validator, HtmlControlType.DATE)).isEmpty(); } + @Test + public void requiredFieldEmitsRequiredOnATemporalControlBoundToATypeThatConvertsBlankToNull() { + List<Class<?>> types = List.of(Date.class, java.sql.Date.class, Timestamp.class, Calendar.class, + GregorianCalendar.class, LocalDate.class, LocalDateTime.class, LocalTime.class, OffsetDateTime.class); + for (HtmlControlType control : List.of(HtmlControlType.DATE, HtmlControlType.MONTH, HtmlControlType.WEEK, + HtmlControlType.TIME, HtmlControlType.DATETIME_LOCAL)) { + for (Class<?> type : types) { + assertThat(provider.constraintsFor(singletonList(new RequiredFieldValidator()), control, null, null, type)) + .as(control + " bound to " + type.getName()) + .containsEntry("required", "required"); + } + } + } + + @Test + public void requiredFieldEmitsNothingOnADateControlBoundToAString() { + // an empty date input submits "", which a String property keeps and the validator accepts + assertThat(provider.constraintsFor(singletonList(new RequiredFieldValidator()), HtmlControlType.DATE, null, + null, String.class)).isEmpty(); + } + + @Test + public void requiredFieldEmitsNothingOnADateControlWhenThePropertyTypeIsUnknown() { + assertThat(provider.constraintsFor(singletonList(new RequiredFieldValidator()), HtmlControlType.DATE, null, + null, null)).isEmpty(); + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.DATE)).isEmpty(); + } + + @Test + public void requiredFieldEmitsNothingOnATextControlBoundToADate() { + assertThat(provider.constraintsFor(singletonList(new RequiredFieldValidator()), HtmlControlType.TEXT, null, + null, Date.class)).isEmpty(); + } + + @Test + public void aProviderImplementingOnlyTheOriginalMethodStillReceivesTheCall() { + HtmlConstraintProvider original = (validators, control, validated, value) -> Map.of("data-original", "yes"); + + assertThat(original.constraintsFor(List.of(), HtmlControlType.DATE, null, null, Date.class)) + .containsEntry("data-original", "yes"); + } + @Test public void emailValidatorNeverContributesAConstraint() { // the browser's email grammar differs from EmailValidator's, so honouring it could reject diff --git a/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java index a0fa997c4..d6f795124 100644 --- a/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java +++ b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java @@ -144,17 +144,58 @@ public class Html5ConstraintRenderingTest extends AbstractUITagTest { } /** - * An empty date input submits "", which only fails {@code required} when the bound type converts it to null. + * An empty date input submits "", which the converter turns into null for a {@code java.util.Date}, + * so {@code required} fails on both sides. */ - public void testRendersNoRequiredOnADateTextField() throws Exception { + public void testRendersRequiredOnADateTextFieldBoundToADate() throws Exception { String output = renderTag("true", () -> { DateTextFieldTag date = new DateTextFieldTag(); date.setName("birthday"); return date; }); + assertTrue("expected required in: " + output, + output.contains("type=\"date\" name=\"birthday\"") && output.contains("required=\"required\"")); + } + + public void testRendersRequiredAndTheIsoRangeOnADateTextFieldBoundToALocalDate() throws Exception { + String output = renderTag("true", () -> { + DateTextFieldTag date = new DateTextFieldTag(); + date.setName("startDate"); + return date; + }); + + assertTrue("expected required, min and max in: " + output, output.contains("type=\"date\" name=\"startDate\"") + && output.contains("required=\"required\"") && output.contains("min=\"2026-01-01\"") + && output.contains("max=\"2026-12-31\"")); + } + + public void testRendersRequiredAndTheIsoRangeOnATextFieldOfTypeDate() throws Exception { + String output = renderTag("true", () -> { + TextFieldTag field = new TextFieldTag(); + field.setName("startDate"); + field.setType("date"); + return field; + }); + + assertTrue("expected required, min and max in: " + output, output.contains("type=\"date\" name=\"startDate\"") + && output.contains("required=\"required\"") && output.contains("min=\"2026-01-01\"") + && output.contains("max=\"2026-12-31\"")); + } + + /** + * A String property keeps the "" an empty date input submits, which {@code required} accepts. + */ + public void testRendersNoRequiredOnADateInputBoundToAString() throws Exception { + String output = renderTag("true", () -> { + TextFieldTag field = new TextFieldTag(); + field.setName("dateText"); + field.setType("date"); + return field; + }); + assertTrue("expected the required message in: " + output, - output.contains("type=\"date\" name=\"birthday\"") && output.contains("data-msg-required=\"pick a date\"")); + output.contains("type=\"date\" name=\"dateText\"") && output.contains("data-msg-required=\"type a date\"")); assertFalse("expected no required in: " + output, output.contains("required=\"required\"")); } diff --git a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml index 260822407..23754c8ad 100644 --- a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml +++ b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml @@ -63,6 +63,21 @@ <message>attach a file</message> </field-validator> </field> + <field name="startDate"> + <field-validator type="required"> + <message>pick a start date</message> + </field-validator> + <field-validator type="localdate"> + <param name="min">2026-01-01</param> + <param name="max">2026-12-31</param> + <message>start within 2026</message> + </field-validator> + </field> + <field name="dateText"> + <field-validator type="required"> + <message>type a date</message> + </field-validator> + </field> <field name="birthday"> <field-validator type="required"> <message>pick a date</message>
