Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions api/src/main/java/jakarta/faces/convert/DateTimeConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@
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.MonthDay;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.Year;
import java.time.YearMonth;
import java.time.ZonedDateTime;
import java.time.chrono.IsoChronology;
import java.time.format.DateTimeFormatter;
Expand Down Expand Up @@ -190,7 +194,11 @@ public class DateTimeConverter implements Converter<Object>, PartialStateHolder
"localTime", LocalTime::from,
"offsetTime", OffsetTime::from,
"offsetDateTime", OffsetDateTime::from,
"zonedDateTime", ZonedDateTime::from
"zonedDateTime", ZonedDateTime::from,
"instant", Instant::from,
"year", Year::from,
"yearMonth", YearMonth::from,
"monthDay", MonthDay::from
);

private static final Pattern ESCAPED_DATE_TIME_PATTERN = Pattern.compile("'[^']*+'");
Expand Down Expand Up @@ -354,12 +362,30 @@ public String getType() {
* <span class="changed_modified_2_3">Set</span> the type of value to be formatted or parsed. Valid values are <code>both</code>,
* <code>date</code>, <code>time</code> <span class="changed_added_2_3">{@code localDate}, {@code
* localDateTime}, {@code localTime}, {@code offsetTime}, {@code
* offsetDateTime}, or {@code zonedDateTime}. The values starting with "local", "offset" and "zoned" correspond to Java SE 8 Date
* offsetDateTime}, {@code zonedDateTime}<span class="changed_added_5_0">, {@code instant}, {@code year},
* {@code yearMonth}, or {@code monthDay}</span>. The values starting with "local", "offset" and "zoned" correspond to Java SE 8 Date
* Time API classes in package <code>java.time</code> with the name derived by upper casing the first letter. For example,
* <code>java.time.LocalDate</code> for the value <code>"localDate"</code>.</span> An invalid value will cause a
* {@link ConverterException} when <code>getAsObject()</code> or <code>getAsString()</code> is called.
* </p>
*
* <p class="changed_added_5_0">
* The values {@code instant}, {@code year}, {@code yearMonth} and {@code monthDay} correspond in the same way to
* {@code java.time.Instant}, {@code java.time.Year}, {@code java.time.YearMonth} and {@code java.time.MonthDay}. None
* of these types can be formatted or parsed with a localized date/time style, hence the <code>dateStyle</code> and
* <code>timeStyle</code> properties must be ignored for them. When no <code>pattern</code> has been specified, the ISO
* 8601 representation of the type must be used: {@code java.time.format.DateTimeFormatter.ISO_INSTANT} for
* {@code instant}, the pattern {@code "uuuu"} for {@code year}, {@code "uuuu-MM"} for {@code yearMonth} and
* {@code "--MM-dd"} for {@code monthDay}.
* </p>
*
* <p class="changed_added_5_0">
* A {@code java.time.Instant} does not carry a time zone, hence for the type {@code instant} the <code>timezone</code>
* property must be passed to the underlying {@code DateTimeFormatter}, so that a <code>pattern</code> containing date
* or time fields can be resolved. Note that {@code ISO_INSTANT} always renders in UTC, regardless of the
* <code>timezone</code> property.
* </p>
*
* @param type The new date style
*/
public void setType(String type) {
Expand Down Expand Up @@ -407,6 +433,9 @@ public Object getAsObject(FacesContext context, UIComponent component, String va
switch (type) {
case "date":
case "localDate":
case "year":
case "yearMonth":
case "monthDay":
throw new ConverterException(
getMessage(context, DATE_ID, value, parser.formatNow(), getLabel(context, component)),
e);
Expand All @@ -420,6 +449,7 @@ public Object getAsObject(FacesContext context, UIComponent component, String va
case "localDateTime":
case "offsetDateTime":
case "zonedDateTime":
case "instant":
throw new ConverterException(getMessage(context, DATETIME_ID, value, parser.formatNow(),
getLabel(context, component)), e);
}
Expand Down Expand Up @@ -668,6 +698,15 @@ private FormatWrapper getDateFormat(Locale locale, boolean forParsing) {
dtf = DateTimeFormatter.ISO_OFFSET_DATE_TIME.withLocale(locale);
} else if (type.equals("zonedDateTime")) {
dtf = DateTimeFormatter.ISO_ZONED_DATE_TIME.withLocale(locale);
} else if (type.equals("instant")) {
dtf = DateTimeFormatter.ISO_INSTANT.withLocale(locale);
} else if (type.equals("year")) {
// These three patterns represent the ISO 8601 form of the type, as also used by its own parse() and toString().
dtfBuilder = new DateTimeFormatterBuilder().appendPattern("uuuu");
} else if (type.equals("yearMonth")) {
dtfBuilder = new DateTimeFormatterBuilder().appendPattern("uuuu-MM");
} else if (type.equals("monthDay")) {
dtfBuilder = new DateTimeFormatterBuilder().appendPattern("--MM-dd");
} else {
// PENDING(craigmcc) - i18n
throw new IllegalArgumentException("Invalid type: " + type);
Expand All @@ -686,6 +725,10 @@ private FormatWrapper getDateFormat(Locale locale, boolean forParsing) {
}

if (dtf != null) {
if (timeZone != null && "instant".equals(type)) {
dtf = dtf.withZone(timeZone.toZoneId());
}

return new FormatWrapper(dtf, fromJavaTime, forParsing);
}
}
Expand Down
20 changes: 19 additions & 1 deletion api/src/main/vdldoc/faces.core.taglib.xml
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,12 @@
a java.util.TimeZone instance, or a String that is a timezone ID
as described in the javadocs for
java.util.TimeZone.getTimeZone().</p>

<p class="changed_added_5_0">A
<code>java.time.Instant</code> does not carry a time
zone, hence for the type "instant" this attribute is
also used to resolve the date and time fields of an
explicit pattern.</p>
]]></description>
<name>timeZone</name>
<type>java.lang.Object</type>
Expand All @@ -459,13 +465,25 @@
parsed expecting. Valid values are "date", "time",
"both"<span class="changed_added_2_3">, "localDate",
"localDateTime", "localTime", "offsetTime",
"offsetDateTime", and "zonedDateTime". The values starting
"offsetDateTime", "zonedDateTime"</span><span
class="changed_added_5_0">, "instant", "year", "yearMonth",
and "monthDay"</span>. The values starting
with "local", "offset" and "zoned" correspond to Java SE 8
Date Time API classes in package <code>java.time</code> with
the name derived by upper casing the first letter. For
example, <code>java.time.LocalDate</code> for the value
"localDate".</span> Default value is "date".</p>

<p class="changed_added_5_0">The values "instant", "year",
"yearMonth" and "monthDay" correspond in the same way to
<code>java.time.Instant</code>, <code>java.time.Year</code>,
<code>java.time.YearMonth</code> and
<code>java.time.MonthDay</code>. None of these types can be
formatted or parsed with a localized date/time style, hence
the dateStyle and timeStyle attributes are ignored for them.
When no pattern has been specified, the ISO 8601
representation of the type is used.</p>

]]></description>
<name>type</name>
<type>java.lang.String</type>
Expand Down
151 changes: 143 additions & 8 deletions api/src/test/java/jakarta/faces/convert/DateTimeConverterTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,22 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.MonthDay;
import java.time.Year;
import java.time.YearMonth;
import java.time.chrono.IsoChronology;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.FormatStyle;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;

import jakarta.faces.application.Application;
import jakarta.faces.component.UIPanel;
Expand All @@ -52,6 +58,7 @@ public class DateTimeConverterTest {

private FacesContext facesContext;
private MockedStatic<FacesContext> facesContextStatic;
private UIPanel component;

@BeforeEach
public void setUp() {
Expand All @@ -70,6 +77,8 @@ public void setUp() {

facesContextStatic = Mockito.mockStatic(FacesContext.class);
facesContextStatic.when(FacesContext::getCurrentInstance).thenReturn(facesContext);

component = new UIPanel();
}

@AfterEach
Expand All @@ -95,8 +104,6 @@ public void testLocalTimeParsingWithRegularSpaceBeforeAmPm() {
converter.setType("localTime");
converter.setLocale(Locale.US);

UIPanel component = new UIPanel();

// This is what a user would type: regular space (U+0020) before AM.
String userInput = "10:30:00 AM";

Expand All @@ -123,8 +130,6 @@ public void testLocalTimeParsingWithNnbsp() {
converter.setType("localTime");
converter.setLocale(Locale.US);

UIPanel component = new UIPanel();

// Input with NNBSP — this should always work on JDK 21+.
String inputWithNnbsp = "10:30:00\u202fAM";

Expand All @@ -148,8 +153,6 @@ public void testLocalDateTimeParsingWithRegularSpaceBeforeAmPm() {
converter.setType("localDateTime");
converter.setLocale(Locale.US);

UIPanel component = new UIPanel();

// Format a known value to get the expected formatted string, then replace NNBSP with regular space
// to simulate user input.
DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
Expand Down Expand Up @@ -179,8 +182,6 @@ public void testLocalTimeRoundtripWithRegularSpace() {
converter.setType("localTime");
converter.setLocale(Locale.US);

UIPanel component = new UIPanel();

LocalTime originalTime = LocalTime.of(14, 45, 30);

// getAsString produces formatted output (may contain NNBSP on JDK 21+)
Expand All @@ -197,6 +198,140 @@ public void testLocalTimeRoundtripWithRegularSpace() {
assertEquals(originalTime, parsed);
}

/**
* Test that type "instant" formats and parses using the ISO 8601 instant representation, which is what
* {@link Instant#toString()} and {@link Instant#parse(CharSequence)} use.
*
* @see <a href="https://github.com/jakartaee/faces/issues/2211">GitHub issue #2211</a>
*/
@Test
public void testInstant() {
DateTimeConverter converter = createConverter("instant");
Instant instant = Instant.parse("2026-07-30T10:15:30Z");

assertEquals("2026-07-30T10:15:30Z", converter.getAsString(facesContext, component, instant));
assertEquals(instant, converter.getAsObject(facesContext, component, "2026-07-30T10:15:30Z"));
}

/**
* Test that type "instant" resolves the date and time fields of an explicit pattern against the configured time zone,
* as an {@link Instant} does not carry one by itself.
*
* @see <a href="https://github.com/jakartaee/faces/issues/2211">GitHub issue #2211</a>
*/
@Test
public void testInstantWithPatternAndTimeZone() {
DateTimeConverter converter = createConverter("instant");
converter.setPattern("uuuu-MM-dd HH:mm:ss");
converter.setTimeZone(TimeZone.getTimeZone("Europe/Amsterdam"));
Instant instant = Instant.parse("2026-07-30T10:15:30Z");

assertEquals("2026-07-30 12:15:30", converter.getAsString(facesContext, component, instant));
assertEquals(instant, converter.getAsObject(facesContext, component, "2026-07-30 12:15:30"));
}

/**
* Test that type "year" formats and parses using the ISO 8601 year representation, which is what
* {@link Year#toString()} and {@link Year#parse(CharSequence)} use.
*
* @see <a href="https://github.com/jakartaee/faces/issues/2211">GitHub issue #2211</a>
*/
@Test
public void testYear() {
DateTimeConverter converter = createConverter("year");

assertEquals("2026", converter.getAsString(facesContext, component, Year.of(2026)));
assertEquals(Year.of(2026), converter.getAsObject(facesContext, component, "2026"));
}

/**
* Test that type "yearMonth" formats and parses using the ISO 8601 year-month representation, which is what
* {@link YearMonth#toString()} and {@link YearMonth#parse(CharSequence)} use.
*
* @see <a href="https://github.com/jakartaee/faces/issues/2211">GitHub issue #2211</a>
*/
@Test
public void testYearMonth() {
DateTimeConverter converter = createConverter("yearMonth");

assertEquals("2026-07", converter.getAsString(facesContext, component, YearMonth.of(2026, 7)));
assertEquals(YearMonth.of(2026, 7), converter.getAsObject(facesContext, component, "2026-07"));
}

/**
* Test that type "monthDay" formats and parses using the ISO 8601 month-day representation, which is what
* {@link MonthDay#toString()} and {@link MonthDay#parse(CharSequence)} use.
*
* @see <a href="https://github.com/jakartaee/faces/issues/2211">GitHub issue #2211</a>
*/
@Test
public void testMonthDay() {
DateTimeConverter converter = createConverter("monthDay");

assertEquals("--07-30", converter.getAsString(facesContext, component, MonthDay.of(7, 30)));
assertEquals(MonthDay.of(7, 30), converter.getAsObject(facesContext, component, "--07-30"));
}

/**
* Test that type "monthDay" accepts February 29, which is valid without a year.
*
* @see <a href="https://github.com/jakartaee/faces/issues/2211">GitHub issue #2211</a>
*/
@Test
public void testMonthDayAcceptsLeapDay() {
DateTimeConverter converter = createConverter("monthDay");

assertEquals(MonthDay.of(2, 29), converter.getAsObject(facesContext, component, "--02-29"));
}

/**
* Test that the types "year", "yearMonth" and "monthDay" honor an explicit pattern.
*
* @see <a href="https://github.com/jakartaee/faces/issues/2211">GitHub issue #2211</a>
*/
@Test
public void testPattern() {
assertPattern("year", "'FY'uuuu", Year.of(2026), "FY2026");
assertPattern("yearMonth", "MM/uuuu", YearMonth.of(2026, 7), "07/2026");
assertPattern("monthDay", "dd-MM", MonthDay.of(7, 30), "30-07");
}

private void assertPattern(String type, String pattern, Object value, String formatted) {
DateTimeConverter converter = createConverter(type);
converter.setPattern(pattern);

assertEquals(formatted, converter.getAsString(facesContext, component, value));
assertEquals(value, converter.getAsObject(facesContext, component, formatted));
}

/**
* Test that unparseable input for the new types results in a ConverterException rather than in a silent null, which
* is what happens when a type is not covered by the message selection in
* {@link DateTimeConverter#getAsObject(FacesContext, jakarta.faces.component.UIComponent, String)}.
*
* @see <a href="https://github.com/jakartaee/faces/issues/2211">GitHub issue #2211</a>
*/
@Test
public void testUnparseableValueThrowsConverterException() {
assertUnparseable("instant", "2026-07-30");
assertUnparseable("year", "MMXXVI");
assertUnparseable("yearMonth", "2026-13");
assertUnparseable("monthDay", "--02-30");
}

private void assertUnparseable(String type, String value) {
DateTimeConverter converter = createConverter(type);
assertThrows(ConverterException.class, () -> converter.getAsObject(facesContext, component, value),
() -> "Type " + type + " must reject '" + value + '\'');
}

private static DateTimeConverter createConverter(String type) {
DateTimeConverter converter = new DateTimeConverter();
converter.setType(type);
converter.setLocale(Locale.US);
return converter;
}

private static void requireNnbspInPattern(FormatStyle dateStyle, FormatStyle timeStyle) {
String localizedPattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(
dateStyle, timeStyle, IsoChronology.INSTANCE, Locale.US);
Expand Down
3 changes: 3 additions & 0 deletions spec/src/main/asciidoc/ChangeLog.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ pre-Jakarta Faces specification under the JCP.

This release contains the following backwards compatible changes:

* Issue ID {issue_tracker_url}2211[2211] +
New: DateTimeConverter support for java.time Instant, Year, YearMonth and MonthDay

* Issue ID {issue_tracker_url}2173[2173] +
Change: generalize Render Response resource push to allow modern mechanisms such as HTTP 103 Early Hints

Expand Down
Loading
Loading