/* * Copyright 2000-2016 Vaadin Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package com.vaadin.ui; import java.io.Serializable; import java.lang.reflect.Type; import java.text.SimpleDateFormat; import java.time.LocalDate; import java.time.temporal.Temporal; import java.time.temporal.TemporalAdjuster; import java.util.Calendar; import java.util.Date; import java.util.EventObject; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.Stream; import org.jsoup.nodes.Element; import com.googlecode.gentyref.GenericTypeReflector; import com.vaadin.data.Result; import com.vaadin.data.ValidationResult; import com.vaadin.data.ValueContext; import com.vaadin.data.validator.RangeValidator; import com.vaadin.event.FieldEvents.BlurEvent; import com.vaadin.event.FieldEvents.BlurListener; import com.vaadin.event.FieldEvents.BlurNotifier; import com.vaadin.event.FieldEvents.FocusEvent; import com.vaadin.event.FieldEvents.FocusListener; import com.vaadin.event.FieldEvents.FocusNotifier; import com.vaadin.server.PaintException; import com.vaadin.server.PaintTarget; import com.vaadin.server.UserError; import com.vaadin.shared.Registration; import com.vaadin.shared.ui.datefield.AbstractDateFieldState; import com.vaadin.shared.ui.datefield.DateFieldConstants; import com.vaadin.shared.ui.datefield.DateResolution; import com.vaadin.ui.declarative.DesignAttributeHandler; import com.vaadin.ui.declarative.DesignContext; /** * A date editor component with {@link LocalDate} as an input value. * * @author Vaadin Ltd * * @since 8.0 * * @param <T> * type of date ({@code LocalDate} or {@code LocalDateTime}). * @param <R> * resolution enumeration type * */ public abstract class AbstractDateField<T extends Temporal & TemporalAdjuster & Serializable & Comparable<? super T>, R extends Enum<R>> extends AbstractField<T> implements LegacyComponent, FocusNotifier, BlurNotifier { /** * Value of the field. */ private T value; /** * Specified smallest modifiable unit for the date field. */ private R resolution; /** * Overridden format string */ private String dateFormat; private boolean lenient = false; private String dateString = null; private String currentParseErrorMessage; /** * Was the last entered string parsable? If this flag is false, datefields * internal validator does not pass. */ private boolean uiHasValidDateString = true; /** * Determines if week numbers are shown in the date selector. */ private boolean showISOWeekNumbers = false; private String defaultParseErrorMessage = "Date format not recognized"; private String dateOutOfRangeMessage = "Date is out of allowed range"; /** * Determines whether the ValueChangeEvent should be fired. Used to prevent * firing the event when UI has invalid string until uiHasValidDateString * flag is set */ private boolean preventValueChangeEvent; /* Constructors */ /** * Constructs an empty <code>AbstractDateField</code> with no caption and * specified {@code resolution}. * * @param resolution * initial resolution for the field */ public AbstractDateField(R resolution) { this.resolution = resolution; } /** * Constructs an empty <code>AbstractDateField</code> with caption. * * @param caption * the caption of the datefield. * @param resolution * initial resolution for the field */ public AbstractDateField(String caption, R resolution) { this(resolution); setCaption(caption); } /** * Constructs a new <code>AbstractDateField</code> with the given caption * and initial text contents. * * @param caption * the caption <code>String</code> for the editor. * @param value * the date/time value. * @param resolution * initial resolution for the field */ public AbstractDateField(String caption, T value, R resolution) { this(caption, resolution); setValue(value); } /* Component basic features */ /* * Paints this component. Don't add a JavaDoc comment here, we use the * default documentation from implemented interface. */ @Override public void paintContent(PaintTarget target) throws PaintException { // Adds the locale as attribute final Locale l = getLocale(); if (l != null) { target.addAttribute("locale", l.toString()); } if (getDateFormat() != null) { target.addAttribute("format", getDateFormat()); } if (!isLenient()) { target.addAttribute("strict", true); } target.addAttribute(DateFieldConstants.ATTR_WEEK_NUMBERS, isShowISOWeekNumbers()); target.addAttribute("parsable", uiHasValidDateString); /* * TODO communicate back the invalid date string? E.g. returning back to * app or refresh. */ final T currentDate = getValue(); // Only paint variables for the resolution and up, e.g. Resolution DAY // paints DAY,MONTH,YEAR for (R res : getResolutionsHigherOrEqualTo(getResolution())) { int value = -1; if (currentDate != null) { value = getDatePart(currentDate, res); } target.addVariable(this, getResolutionVariable(res), value); } } /* * Invoked when a variable of the component changes. Don't add a JavaDoc * comment here, we use the default documentation from implemented * interface. */ @Override public void changeVariables(Object source, Map<String, Object> variables) { Set<String> resolutionNames = getResolutions() .map(this::getResolutionVariable).collect(Collectors.toSet()); resolutionNames.retainAll(variables.keySet()); if (!isReadOnly() && (!resolutionNames.isEmpty() || variables.containsKey("dateString"))) { // Old and new dates final T oldDate = getValue(); T newDate = null; // this enables analyzing invalid input on the server final String newDateString = (String) variables.get("dateString"); dateString = newDateString; // Gets the new date in parts boolean hasChanges = false; Map<R, Integer> calendarFields = new HashMap<>(); for (R resolution : getResolutionsHigherOrEqualTo( getResolution())) { // Only handle what the client is allowed to send. The same // resolutions that are painted String variableName = getResolutionVariable(resolution); int value = getDatePart(oldDate, resolution); if (variables.containsKey(variableName)) { Integer newValue = (Integer) variables.get(variableName); if (newValue >= 0) { hasChanges = true; value = newValue; } } calendarFields.put(resolution, value); } // If no new variable values were received, use the previous value if (!hasChanges) { newDate = null; } else { newDate = buildDate(calendarFields); } if (newDate == null && dateString != null && !dateString.isEmpty()) { Result<T> parsedDate = handleUnparsableDateString(dateString); if (parsedDate.isError()) { /* * Saves the localized message of parse error. This can be * overridden in handleUnparsableDateString. The message * will later be used to show a validation error. */ currentParseErrorMessage = parsedDate.getMessage().get(); /* * The value of the DateField should be null if an invalid * value has been given. Not using setValue() since we do * not want to cause the client side value to change. */ uiHasValidDateString = false; /* * Datefield now contains some text that could't be parsed * into date. ValueChangeEvent is fired after the value is * changed and the flags are set */ if (oldDate != null) { /* * Set the logic value to null without firing the * ValueChangeEvent */ preventValueChangeEvent = true; try { setValue(null); } finally { preventValueChangeEvent = false; } /* * Reset the dateString (overridden to null by setValue) */ dateString = newDateString; } /* * If value was changed fire the ValueChangeEvent */ if (oldDate != null) { fireEvent(createValueChange(oldDate, true)); } markAsDirty(); } else { parsedDate.ifOk(value -> setValue(value, true)); /* * Ensure the value is sent to the client if the value is * set to the same as the previous (#4304). Does not repaint * if handleUnparsableDateString throws an exception. In * this case the invalid text remains in the DateField. */ markAsDirty(); } } else if (newDate != oldDate && (newDate == null || !newDate.equals(oldDate))) { setValue(newDate, true); // Don't require a repaint, client // updates itself } else if (!uiHasValidDateString) { // oldDate == // newDate == null // Empty value set, previously contained unparsable date string, // clear related internal fields setValue(null); } } if (variables.containsKey(FocusEvent.EVENT_ID)) { fireEvent(new FocusEvent(this)); } if (variables.containsKey(BlurEvent.EVENT_ID)) { fireEvent(new BlurEvent(this)); } } /** * Sets the start range for this component. If the value is set before this * date (taking the resolution into account), the component will not * validate. If <code>startDate</code> is set to <code>null</code>, any * value before <code>endDate</code> will be accepted by the range * * @param startDate * - the allowed range's start date */ public void setRangeStart(T startDate) { Date date = convertToDate(startDate); if (date != null && getState().rangeEnd != null && date.after(getState().rangeEnd)) { throw new IllegalStateException( "startDate cannot be later than endDate"); } getState().rangeStart = date; } /** * Sets the current error message if the range validation fails. * * @param dateOutOfRangeMessage * - Localizable message which is shown when value (the date) is * set outside allowed range */ public void setDateOutOfRangeMessage(String dateOutOfRangeMessage) { this.dateOutOfRangeMessage = dateOutOfRangeMessage; } /** * Returns current date-out-of-range error message. * * @see #setDateOutOfRangeMessage(String) * @return Current error message for dates out of range. */ public String getDateOutOfRangeMessage() { return dateOutOfRangeMessage; } /** * Gets the resolution. * * @return the date/time field resolution */ public R getResolution() { return resolution; } /** * Sets the resolution of the DateField. * * The default resolution is {@link DateResolution#DAY} since Vaadin 7.0. * * @param resolution * the resolution to set, not {@code null} */ public void setResolution(R resolution) { this.resolution = resolution; markAsDirty(); } /** * Sets the end range for this component. If the value is set after this * date (taking the resolution into account), the component will not * validate. If <code>endDate</code> is set to <code>null</code>, any value * after <code>startDate</code> will be accepted by the range. * * @param endDate * - the allowed range's end date (inclusive, based on the * current resolution) */ public void setRangeEnd(T endDate) { Date date = convertToDate(endDate); if (date != null && getState().rangeStart != null && getState().rangeStart.after(date)) { throw new IllegalStateException( "endDate cannot be earlier than startDate"); } getState().rangeEnd = date; } /** * Returns the precise rangeStart used. * * @return the precise rangeStart used, may be null. */ public T getRangeStart() { return convertFromDate(getState(false).rangeStart); } /** * Returns the precise rangeEnd used. * * @return the precise rangeEnd used, may be null. */ public T getRangeEnd() { return convertFromDate(getState(false).rangeEnd); } /** * Sets formatting used by some component implementations. See * {@link SimpleDateFormat} for format details. * * By default it is encouraged to used default formatting defined by Locale, * but due some JVM bugs it is sometimes necessary to use this method to * override formatting. See Vaadin issue #2200. * * @param dateFormat * the dateFormat to set * * @see com.vaadin.ui.AbstractComponent#setLocale(Locale)) */ public void setDateFormat(String dateFormat) { this.dateFormat = dateFormat; markAsDirty(); } /** * Returns a format string used to format date value on client side or null * if default formatting from {@link Component#getLocale()} is used. * * @return the dateFormat */ public String getDateFormat() { return dateFormat; } /** * Specifies whether or not date/time interpretation in component is to be * lenient. * * @see Calendar#setLenient(boolean) * @see #isLenient() * * @param lenient * true if the lenient mode is to be turned on; false if it is to * be turned off. */ public void setLenient(boolean lenient) { this.lenient = lenient; markAsDirty(); } /** * Returns whether date/time interpretation is to be lenient. * * @see #setLenient(boolean) * * @return true if the interpretation mode of this calendar is lenient; * false otherwise. */ public boolean isLenient() { return lenient; } @Override public T getValue() { return value; } /** * Sets the value of this object. If the new value is not equal to * {@code getValue()}, fires a {@link ValueChangeEvent} . * * @param value * the new value, may be {@code null} */ @Override public void setValue(T value) { /* * First handle special case when the client side component have a date * string but value is null (e.g. unparsable date string typed in by the * user). No value changes should happen, but we need to do some * internal housekeeping. */ if (value == null && !uiHasValidDateString) { /* * Side-effects of doSetValue clears possible previous strings and * flags about invalid input. */ doSetValue(null); markAsDirty(); return; } super.setValue(value); } /** * Checks whether ISO 8601 week numbers are shown in the date selector. * * @return true if week numbers are shown, false otherwise. */ public boolean isShowISOWeekNumbers() { return showISOWeekNumbers; } /** * Sets the visibility of ISO 8601 week numbers in the date selector. ISO * 8601 defines that a week always starts with a Monday so the week numbers * are only shown if this is the case. * * @param showWeekNumbers * true if week numbers should be shown, false otherwise. */ public void setShowISOWeekNumbers(boolean showWeekNumbers) { showISOWeekNumbers = showWeekNumbers; markAsDirty(); } /** * Return the error message that is shown if the user inputted value can't * be parsed into a Date object. If * {@link #handleUnparsableDateString(String)} is overridden and it throws a * custom exception, the message returned by * {@link Exception#getLocalizedMessage()} will be used instead of the value * returned by this method. * * @see #setParseErrorMessage(String) * * @return the error message that the DateField uses when it can't parse the * textual input from user to a Date object */ public String getParseErrorMessage() { return defaultParseErrorMessage; } /** * Sets the default error message used if the DateField cannot parse the * text input by user to a Date field. Note that if the * {@link #handleUnparsableDateString(String)} method is overridden, the * localized message from its exception is used. * * @see #getParseErrorMessage() * @see #handleUnparsableDateString(String) * @param parsingErrorMessage */ public void setParseErrorMessage(String parsingErrorMessage) { defaultParseErrorMessage = parsingErrorMessage; } @Override public Registration addFocusListener(FocusListener listener) { return addListener(FocusEvent.EVENT_ID, FocusEvent.class, listener, FocusListener.focusMethod); } @Override public Registration addBlurListener(BlurListener listener) { return addListener(BlurEvent.EVENT_ID, BlurEvent.class, listener, BlurListener.blurMethod); } @Override @SuppressWarnings("unchecked") public void readDesign(Element design, DesignContext designContext) { super.readDesign(design, designContext); if (design.hasAttr("value") && !design.attr("value").isEmpty()) { Type dateType = GenericTypeReflector.getTypeParameter(getClass(), AbstractDateField.class.getTypeParameters()[0]); if (dateType instanceof Class<?>) { Class<?> clazz = (Class<?>) dateType; T date = (T) DesignAttributeHandler.getFormatter() .parse(design.attr("value"), clazz); // formatting will return null if it cannot parse the string if (date == null) { Logger.getLogger(AbstractDateField.class.getName()) .info("cannot parse " + design.attr("value") + " as date"); } doSetValue(date); } else { throw new RuntimeException("Cannot detect resoluton type " + Optional.ofNullable(dateType).map(Type::getTypeName) .orElse(null)); } } } @Override public void writeDesign(Element design, DesignContext designContext) { super.writeDesign(design, designContext); if (getValue() != null) { design.attr("value", DesignAttributeHandler.getFormatter().format(getValue())); } } @Override protected void fireEvent(EventObject event) { if (event instanceof ValueChangeEvent) { if (!preventValueChangeEvent) { super.fireEvent(event); } } else { super.fireEvent(event); } } /** * This method is called to handle a non-empty date string from the client * if the client could not parse it as a Date. * * By default, an error result is returned whose error message is * {@link #getParseErrorMessage()}. * * This can be overridden to handle conversions, to return a result with * {@code null} value (equivalent to empty input) or to return a custom * error. * * @param dateString * date string to handle * @return result that contains parsed Date as a value or an error */ protected Result<T> handleUnparsableDateString(String dateString) { return Result.error(getParseErrorMessage()); } @Override protected AbstractDateFieldState getState() { return (AbstractDateFieldState) super.getState(); } @Override protected AbstractDateFieldState getState(boolean markAsDirty) { return (AbstractDateFieldState) super.getState(markAsDirty); } @Override protected void doSetValue(T value) { // Also set the internal dateString if (value != null) { dateString = value.toString(); } else { dateString = null; } this.value = value; setComponentError(null); if (!uiHasValidDateString) { // clear component error and parsing flag uiHasValidDateString = true; setComponentError(new UserError(currentParseErrorMessage)); } else { RangeValidator<T> validator = getRangeValidator(); ValidationResult result = validator.apply(value, new ValueContext(this)); if (result.isError()) { setComponentError(new UserError(getDateOutOfRangeMessage())); } } } /** * Returns a date integer value part for the given {@code date} for the * given {@code resolution}. * * @param date * the given date * @param resolution * the resolution to extract a value from the date by * @return the integer value part of the date by the given resolution */ protected abstract int getDatePart(T date, R resolution); /** * Builds date by the given {@code resolutionValues} which is a map whose * keys are resolution and integer values. * <p> * This is the opposite to {@link #getDatePart(Temporal, Enum)}. * * @param resolutionValues * date values to construct a date * @return date built from the given map of date values */ protected abstract T buildDate(Map<R, Integer> resolutionValues); /** * Returns a custom date range validator which is applicable for the type * {@code T}. * * @return the date range validator */ protected abstract RangeValidator<T> getRangeValidator(); /** * Converts {@link Date} to date type {@code T}. * * @param date * a date to convert * @return object of type {@code T} representing the {@code date} */ protected abstract T convertFromDate(Date date); /** * Converts the object of type {@code T} to {@link Date}. * <p> * This is the opposite to {@link #convertFromDate(Date)}. * * @param date * the date of type {@code T} * @return converted date of type {@code Date} */ protected abstract Date convertToDate(T date); private String getResolutionVariable(R resolution) { return resolution.name().toLowerCase(Locale.ENGLISH); } @SuppressWarnings("unchecked") private Stream<R> getResolutions() { Type resolutionType = GenericTypeReflector.getTypeParameter(getClass(), AbstractDateField.class.getTypeParameters()[1]); if (resolutionType instanceof Class<?>) { Class<?> clazz = (Class<?>) resolutionType; return Stream.of(clazz.getEnumConstants()) .map(object -> (R) object); } else { throw new RuntimeException("Cannot detect resoluton type " + Optional.ofNullable(resolutionType).map(Type::getTypeName) .orElse(null)); } } private Iterable<R> getResolutionsHigherOrEqualTo(R resoution) { return getResolutions().skip(resolution.ordinal()) .collect(Collectors.toList()); } }