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 33a4dac3721e1ef763c05181813a9939baafbc05 Author: Lukasz Lenart <[email protected]> AuthorDate: Sat Oct 24 15:54:02 2026 +0200 WW-5810 feat(tags): support java.time types in datetextfield DateConverter now converts a java.util.Date object into LocalDate, LocalDateTime or LocalTime in the server's default time zone. That is the value DateTextFieldInterceptor binds, so the JavaTemplates split fields can now fill java.time properties; Date targets are unchanged. The JavaTemplates handler formats java.time values per field instead of casting to Date, leaving a field the type does not have empty. The html5 theme renders the date part of a time-bearing value, which the browser otherwise rejects; LocalDate already rendered as ISO and is now pinned. Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../apache/struts2/components/DateTextField.java | 7 ++- .../struts2/conversion/impl/DateConverter.java | 23 ++++++- .../struts2/components/ConstraintAction.java | 22 +++++++ .../struts2/conversion/impl/DateConverterTest.java | 40 +++++++++++++ .../interceptor/DateTextFieldInterceptorTest.java | 30 ++++++++++ .../struts2/views/jsp/ui/DateTextFieldTest.java | 27 ++++++++- .../struts2/views/jsp/ui/DateTextField-5-html5.txt | 1 + .../struts2/views/jsp/ui/DateTextField-6-html5.txt | 1 + .../views/java/simple/DateTextFieldHandler.java | 24 ++++++-- .../views/java/simple/DateTextFieldTest.java | 70 ++++++++++++++++++++++ 10 files changed, 236 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/org/apache/struts2/components/DateTextField.java b/core/src/main/java/org/apache/struts2/components/DateTextField.java index aaa04410d..4647b36af 100644 --- a/core/src/main/java/org/apache/struts2/components/DateTextField.java +++ b/core/src/main/java/org/apache/struts2/components/DateTextField.java @@ -26,6 +26,8 @@ import org.apache.struts2.views.annotations.StrutsTagAttribute; import java.time.Instant; import java.time.ZoneId; +import java.time.temporal.ChronoField; +import java.time.temporal.TemporalAccessor; import java.util.Date; import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE; @@ -59,8 +61,11 @@ public class DateTextField extends UIBean { addParameter("format", findValue(format, String.class)); } - if (getAttributes().get(ATTR_NAME_VALUE) instanceof Date date) { + Object value = getAttributes().get(ATTR_NAME_VALUE); + if (value instanceof Date date) { addParameter("isoDate", ISO_LOCAL_DATE.format(Instant.ofEpochMilli(date.getTime()).atZone(ZoneId.systemDefault()))); + } else if (value instanceof TemporalAccessor temporal && temporal.isSupported(ChronoField.EPOCH_DAY)) { + addParameter("isoDate", ISO_LOCAL_DATE.format(temporal)); } } diff --git a/core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java b/core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java index 037ca032f..9486b5d35 100644 --- a/core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java +++ b/core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java @@ -30,10 +30,12 @@ import java.lang.reflect.Member; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; +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.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; @@ -147,12 +149,29 @@ public class DateConverter extends DefaultTypeConverter { throw new TypeConversionException("Could not parse date", e); } - } else if (Date.class.isAssignableFrom(value.getClass())) { - result = (Date) value; + } else if (value instanceof Date date) { + return convertDate(date, toType); } return result; } + /** + * {@code DateTextFieldInterceptor} binds a {@link Date} object, so a {@code java.time} property needs it converted + * in the zone the interceptor parsed it in. + */ + protected Object convertDate(Date date, Class<?> toType) { + // java.sql.Date#toInstant() throws, so go through the epoch millis + LocalDateTime dateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault()); + if (LocalDate.class == toType) { + return dateTime.toLocalDate(); + } else if (LocalDateTime.class == toType) { + return dateTime; + } else if (LocalTime.class == toType) { + return dateTime.toLocalTime(); + } + return date; + } + /** * The user defined global date format, see * {@link org.apache.struts2.components.Date#DATETAG_PROPERTY} 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 8f2a5dc37..59dee88bc 100644 --- a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java +++ b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java @@ -21,6 +21,8 @@ package org.apache.struts2.components; import org.apache.struts2.ActionSupport; import org.apache.struts2.interceptor.parameter.StrutsParameter; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Date; public class ConstraintAction extends ActionSupport { @@ -37,6 +39,8 @@ public class ConstraintAction extends ActionSupport { private int priority; private Object attachment; private Date birthday; + private LocalDate dueDate; + private LocalDateTime meetingAt; public String getUsername() { return username; @@ -118,6 +122,24 @@ public class ConstraintAction extends ActionSupport { this.birthday = birthday; } + public LocalDate getDueDate() { + return dueDate; + } + + @StrutsParameter + public void setDueDate(LocalDate dueDate) { + this.dueDate = dueDate; + } + + public LocalDateTime getMeetingAt() { + return meetingAt; + } + + @StrutsParameter + public void setMeetingAt(LocalDateTime meetingAt) { + this.meetingAt = meetingAt; + } + public ConstraintUser getOwner() { return owner; } diff --git a/core/src/test/java/org/apache/struts2/conversion/impl/DateConverterTest.java b/core/src/test/java/org/apache/struts2/conversion/impl/DateConverterTest.java index 5130b9f95..4dda161dc 100644 --- a/core/src/test/java/org/apache/struts2/conversion/impl/DateConverterTest.java +++ b/core/src/test/java/org/apache/struts2/conversion/impl/DateConverterTest.java @@ -31,6 +31,7 @@ import java.text.DateFormat; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.ZoneId; import java.util.Date; import java.util.HashMap; import java.util.Locale; @@ -186,6 +187,45 @@ public class DateConverterTest extends StrutsInternalTestCase { assertTrue(value.toString().startsWith(LOCALTIME_STR)); } + public void testDateObjectToLocalDate() { + Date date = Date.from(LocalDateTime.of(2020, 3, 20, 10, 15).atZone(ZoneId.systemDefault()).toInstant()); + + Object value = new DateConverter().convertValue(ActionContext.of().getContextMap(), null, null, null, date, LocalDate.class); + + assertEquals(LocalDate.of(2020, 3, 20), value); + } + + public void testSqlDateObjectToLocalDate() { + Object value = new DateConverter().convertValue(ActionContext.of().getContextMap(), null, null, null, + java.sql.Date.valueOf(LocalDate.of(2020, 3, 20)), LocalDate.class); + + assertEquals(LocalDate.of(2020, 3, 20), value); + } + + public void testDateObjectToLocalDateTime() { + Date date = Date.from(LocalDateTime.of(2020, 3, 20, 10, 15).atZone(ZoneId.systemDefault()).toInstant()); + + Object value = new DateConverter().convertValue(ActionContext.of().getContextMap(), null, null, null, date, LocalDateTime.class); + + assertEquals(LocalDateTime.of(2020, 3, 20, 10, 15), value); + } + + public void testDateObjectToLocalTime() { + Date date = Date.from(LocalDateTime.of(2020, 3, 20, 10, 15).atZone(ZoneId.systemDefault()).toInstant()); + + Object value = new DateConverter().convertValue(ActionContext.of().getContextMap(), null, null, null, date, LocalTime.class); + + assertEquals(LocalTime.of(10, 15), value); + } + + public void testDateObjectToDateIsUnchanged() { + Date date = new Date(); + + Object value = new DateConverter().convertValue(ActionContext.of().getContextMap(), null, null, null, date, Date.class); + + assertSame(date, value); + } + @Override protected void setUp() throws Exception { super.setUp(); diff --git a/core/src/test/java/org/apache/struts2/interceptor/DateTextFieldInterceptorTest.java b/core/src/test/java/org/apache/struts2/interceptor/DateTextFieldInterceptorTest.java index afc398580..861a31458 100644 --- a/core/src/test/java/org/apache/struts2/interceptor/DateTextFieldInterceptorTest.java +++ b/core/src/test/java/org/apache/struts2/interceptor/DateTextFieldInterceptorTest.java @@ -19,6 +19,7 @@ package org.apache.struts2.interceptor; import java.text.SimpleDateFormat; +import java.time.LocalDate; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -28,6 +29,7 @@ import org.apache.struts2.StrutsInternalTestCase; import org.apache.struts2.ActionContext; import org.apache.struts2.mock.MockActionInvocation; import org.apache.struts2.dispatcher.HttpParameters; +import org.apache.struts2.util.ValueStack; /** * Unit test for DateTextFieldInterceptor. @@ -78,4 +80,32 @@ public class DateTextFieldInterceptorTest extends StrutsInternalTestCase { assertEquals(date, parameters.get("name").getObject()); } + public void testBindsToALocalDateProperty() throws Exception { + param.put("__year_birthday", new String[]{"2000"}); + param.put("__month_birthday", new String[]{"06"}); + param.put("__day_birthday", new String[]{"15"}); + ActionContext.getContext().withParameters(HttpParameters.create(param).build()); + + interceptor.intercept(ai); + + Person person = new Person(); + ValueStack stack = ActionContext.getContext().getValueStack(); + stack.push(person); + stack.setValue("birthday", ai.getInvocationContext().getParameters().get("birthday").getObject()); + + assertEquals(LocalDate.of(2000, 6, 15), person.getBirthday()); + } + + public static class Person { + private LocalDate birthday; + + public LocalDate getBirthday() { + return birthday; + } + + public void setBirthday(LocalDate birthday) { + this.birthday = birthday; + } + } + } diff --git a/core/src/test/java/org/apache/struts2/views/jsp/ui/DateTextFieldTest.java b/core/src/test/java/org/apache/struts2/views/jsp/ui/DateTextFieldTest.java index 0c177f643..b8676f5b7 100644 --- a/core/src/test/java/org/apache/struts2/views/jsp/ui/DateTextFieldTest.java +++ b/core/src/test/java/org/apache/struts2/views/jsp/ui/DateTextFieldTest.java @@ -23,6 +23,7 @@ import org.apache.struts2.components.ConstraintAction; import org.apache.struts2.views.jsp.AbstractUITagTest; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Date; @@ -61,6 +62,26 @@ public class DateTextFieldTest extends AbstractUITagTest { verify(DateTextFieldTag.class.getResource("DateTextField-1-html5.txt")); } + public void testHtml5RendersALocalDateAsIso() throws Exception { + ((ConstraintAction) action).setDueDate(LocalDate.of(2026, 10, 24)); + + DateTextFieldTag tag = html5Tag("dueDate"); + tag.doStartTag(); + tag.doEndTag(); + + verify(DateTextFieldTag.class.getResource("DateTextField-5-html5.txt")); + } + + public void testHtml5RendersTheDatePartOfALocalDateTime() throws Exception { + ((ConstraintAction) action).setMeetingAt(LocalDateTime.of(2026, 10, 24, 10, 15)); + + DateTextFieldTag tag = html5Tag("meetingAt"); + tag.doStartTag(); + tag.doEndTag(); + + verify(DateTextFieldTag.class.getResource("DateTextField-6-html5.txt")); + } + public void testHtml5RendersNoValueWithoutADate() throws Exception { DateTextFieldTag tag = html5Tag(); tag.doStartTag(); @@ -109,10 +130,14 @@ public class DateTextFieldTest extends AbstractUITagTest { } private DateTextFieldTag html5Tag() { + return html5Tag("birthday"); + } + + private DateTextFieldTag html5Tag(String name) { DateTextFieldTag tag = new DateTextFieldTag(); tag.setPageContext(pageContext); tag.setTheme("html5"); - tag.setName("birthday"); + tag.setName(name); return tag; } } diff --git a/core/src/test/resources/org/apache/struts2/views/jsp/ui/DateTextField-5-html5.txt b/core/src/test/resources/org/apache/struts2/views/jsp/ui/DateTextField-5-html5.txt new file mode 100644 index 000000000..693ed003c --- /dev/null +++ b/core/src/test/resources/org/apache/struts2/views/jsp/ui/DateTextField-5-html5.txt @@ -0,0 +1 @@ +<input type="date" name="dueDate" value="2026-10-24" id="dueDate"/> diff --git a/core/src/test/resources/org/apache/struts2/views/jsp/ui/DateTextField-6-html5.txt b/core/src/test/resources/org/apache/struts2/views/jsp/ui/DateTextField-6-html5.txt new file mode 100644 index 000000000..5517aca69 --- /dev/null +++ b/core/src/test/resources/org/apache/struts2/views/jsp/ui/DateTextField-6-html5.txt @@ -0,0 +1 @@ +<input type="date" name="meetingAt" value="2026-10-24" id="meetingAt"/> diff --git a/plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/DateTextFieldHandler.java b/plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/DateTextFieldHandler.java index 7c93a6b1f..007d4f2b5 100644 --- a/plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/DateTextFieldHandler.java +++ b/plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/DateTextFieldHandler.java @@ -20,6 +20,9 @@ package org.apache.struts2.views.java.simple; import java.io.IOException; import java.text.SimpleDateFormat; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.UnsupportedTemporalTypeException; import java.util.Date; import java.util.Map; @@ -42,7 +45,7 @@ public class DateTextFieldHandler extends AbstractTagHandler implements TagGener if (id == null) { id = name; } - Date date = (Date)params.get("nameValue"); + Object value = params.get("nameValue"); if (format != null) { // Verify if it's correct @@ -83,10 +86,7 @@ public class DateTextFieldHandler extends AbstractTagHandler implements TagGener } else { attr.addDefaultToEmpty("name", dateWord.getDescription()); } - if (date != null) { - SimpleDateFormat formatter = new SimpleDateFormat(dateWord.getDateType()); - attr.addIfExists("value", formatter.format(date), false); - } + attr.addIfExists("value", formatValue(value, dateWord), false); super.start("input", attr); super.end("input"); @@ -102,6 +102,20 @@ public class DateTextFieldHandler extends AbstractTagHandler implements TagGener } } + + protected String formatValue(Object value, DateWord dateWord) { + if (value instanceof Date date) { + return new SimpleDateFormat(dateWord.getDateType()).format(date); + } + if (value instanceof TemporalAccessor temporal) { + try { + return DateTimeFormatter.ofPattern(dateWord.getDateType()).format(temporal); + } catch (UnsupportedTemporalTypeException e) { + return null; + } + } + return null; + } diff --git a/plugins/javatemplates/src/test/java/org/apache/struts2/views/java/simple/DateTextFieldTest.java b/plugins/javatemplates/src/test/java/org/apache/struts2/views/java/simple/DateTextFieldTest.java index 1cc501441..07c24bf98 100644 --- a/plugins/javatemplates/src/test/java/org/apache/struts2/views/java/simple/DateTextFieldTest.java +++ b/plugins/javatemplates/src/test/java/org/apache/struts2/views/java/simple/DateTextFieldTest.java @@ -25,6 +25,12 @@ import org.apache.struts2.components.UIBean; import org.apache.struts2.interceptor.csp.CspNonceSource; import org.apache.struts2.interceptor.csp.StrutsCspNonceReader; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Date; + public class DateTextFieldTest extends AbstractCommonAttributesTest { private DateTextField tag; @@ -45,6 +51,70 @@ public class DateTextFieldTest extends AbstractCommonAttributesTest { assertEquals(expected, output); } + public void testRenderDateValue() { + Date date = Date.from(LocalDate.of(2026, 10, 24).atStartOfDay(ZoneId.systemDefault()).toInstant()); + + String output = render("yyyy-MM-dd", date); + + assertEquals(s("<div id='id'>" + year("2026") + "-" + month("10") + "-" + day("24") + "</div>"), output); + } + + public void testRenderLocalDateValue() { + String output = render("yyyy-MM-dd", LocalDate.of(2026, 10, 24)); + + assertEquals(s("<div id='id'>" + year("2026") + "-" + month("10") + "-" + day("24") + "</div>"), output); + } + + public void testRenderLocalDateTimeValue() { + String output = render("yyyy-MM-dd HH:mm", LocalDateTime.of(2026, 10, 24, 9, 5)); + + assertEquals(s("<div id='id'>" + year("2026") + "-" + month("10") + "-" + day("24") + " " + + "<input type='text' class='date_hour' size='2' maxlength='2' id='__hour_id' name='__hour_name' value='09'></input>" + + ":<input type='text' class='date_minute' size='2' maxlength='2' id='__minute_id' name='__minute_name' value='05'></input>" + + "</div>"), output); + } + + public void testRenderLocalTimeValue() { + String output = render("HH:mm", LocalTime.of(9, 5)); + + assertEquals(s("<div id='id'>" + + "<input type='text' class='date_hour' size='2' maxlength='2' id='__hour_id' name='__hour_name' value='09'></input>" + + ":<input type='text' class='date_minute' size='2' maxlength='2' id='__minute_id' name='__minute_name' value='05'></input>" + + "</div>"), output); + } + + public void testRenderLocalDateLeavesTimeFieldsEmpty() { + String output = render("yyyy-MM-dd HH", LocalDate.of(2026, 10, 24)); + + assertEquals(s("<div id='id'>" + year("2026") + "-" + month("10") + "-" + day("24") + " " + + "<input type='text' class='date_hour' size='2' maxlength='2' id='__hour_id' name='__hour_name'></input>" + + "</div>"), output); + } + + private String render(String format, Object value) { + tag.setId("id"); + tag.setName("name"); + tag.setFormat(format); + + tag.evaluateParams(); + map.putAll(tag.getAttributes()); + map.put("nameValue", value); + theme.renderTag(getTagName(), context); + return writer.getBuffer().toString(); + } + + private static String year(String value) { + return "<input type='text' class='date_year' size='4' maxlength='4' id='__year_id' name='__year_name' value='" + value + "'></input>"; + } + + private static String month(String value) { + return "<input type='text' class='date_month' size='2' maxlength='2' id='__month_id' name='__month_name' value='" + value + "'></input>"; + } + + private static String day(String value) { + return "<input type='text' class='date_day' size='2' maxlength='2' id='__day_id' name='__day_name' value='" + value + "'></input>"; + } + @Override public void testRenderTextFieldScriptingAttrs() throws Exception { }
