How to validate a DateField correctly in Vaadin?

Roger picture Roger · Nov 18, 2013 · Viewed 7.1k times · Source

I'm having some trouble with validating DateFields. In my application I have a table with DateField properties that the users should be able to edit by pressing an edit button. I also have an OK-button that commits the fields and a cancel-button that discards them.

Here's what I want to achieve (of course there are some neat rules that has to be followed):

  • First off, dates can only be changed to the current day up to 9999-12-31.
  • Secondly, I preferably want the validation to be dynamic (as-you-type)
  • The original dates (dates that are already in the table when going into editing mode) can be any date and you should be able to commit these as they are, even if they are in the past.
  • If you change the date to an invalid date (you can still do this "manually", i.e. directly in the field, not in the date picker) or enter an invalid character into the DateField, an error icon should be shown with a message, not letting you commit your changes until you entered a valid date.
  • If you change the date to an invalid date (error icon is shown), and then to a valid date, the error icon should disappear.

The current behaviour that I managed to implement does the following:

  • Allows "original dates" - OK
  • Allows changing to a valid date - OK
  • When changing to an invalid date (can be done "manually", not using the date picker) and press enter in the field, the field is immediately reset to the original date, but the error icon is still shown - NOT ok
  • When entering an invalid character (can be done "manually", not using the date picker) and press enter in the field, a NPE will be thrown while committing, also no error icon is shown - NOT ok
  • When changing to an invalid date and press enter in the field and the back to a valid date and press enter in the field, the error icon is still there - NOT ok
  • When changing to an invalid date and press OK (i.e. commit()), the field first resets to the original date and the changes (i.e. no change at all to the field) are committed - NOT ok

Now, I tried implementing a wrapper so that I could listen for value changes, but DateField don't have convenient methods like TextField has (eg. setTextChangeEventMode and setTextChangeTimeout). I override valueChange to take care of some of the issues, but it only seems gets called when you change to a valid date, not when you change to an invalid date (you also have to press enter every time when not using the date picker)... instead another validate() gets called in the background, resetting setValidationVisible() all the time.

I even tried creating a CustomDateRangeValidator, but found out it wasn't to much help.

Please help me get this straight, I have tried so many things now and I'm running out of options.

Here's my createField method:

createField(){
    // some more code up here...

    if (propertyId.equals("Valid From")) {
        dField.setImmediate(true);

        dField.setRangeStart(new Date());
        dField.setRangeEnd(dateUtil.getDate(9999, 12, 31));
        dField.setDateOutOfRangeMessage("Date out of range!");

        @SuppressWarnings({ "unchecked", "rawtypes" })
        TableDataValidatingWrapper<TextField> wField = new TableDataValidatingWrapper(dField);
        return wField;
    }

    // some more code down here...
}

... and here's my wrapper:

public class TableDataValidatingWrapper<T> extends CustomField<T> {

    private static final long serialVersionUID = 1L;
    protected Field<T> delegate;

    public TableDataValidatingWrapper(final Field<T> delegate) {
        this.delegate = delegate;

        if (delegate instanceof DateField) {
            final DateField dateField = (DateField) delegate;

            dateField.setCaption("");
            dateField.setImmediate(true);
            dateField.setInvalidAllowed(false);
            dateField.setInvalidCommitted(true);
            dateField.setValidationVisible(false);
            dateField.addValueChangeListener(new ValueChangeListener() {

                private static final long serialVersionUID = 1L;

                @Override
                public void valueChange(com.vaadin.data.Property.ValueChangeEvent event) {
                    try {
                        dateField.validate();
                        dateField.setValidationVisible(false);
                    } catch (InvalidValueException ive) {
                        //handle exception
                    } catch (Exception e) {
                        //handle exception
                    }
                }
            });

        }
    }

//some other overridden methods here...
}

Answer

Krayo picture Krayo · Sep 11, 2014

A little complicated but I hope it works (in Vaadin 7).
I use some Apache Commons and Joda-Time helper methods.
Maybe some customization are needed.

public class MyDateField extends CustomField<Date> {

    private static final long serialVersionUID = 1L;
    private static final DateTimeFormatter DTF;

    static {
        DTF = DateTimeFormat.forPattern("yyyy-MM-dd"); // set timezone if needed
    }

    private TextField tf = new TextField();
    private DateField df = new DateField();
    private Date original;
    private Date minDay = new Date();
    private Date maxDay = new DateTime(9999, 12, 31, 23, 59).toDate();
    private boolean isInnerChange;
    private Date convertedDate;

    @Override
    protected Component initContent() {
        tf.setConverter(InnerConverter.INSTANCE);
        tf.setTextChangeEventMode(TextChangeEventMode.EAGER); // or LAZY
        tf.addTextChangeListener(new TextChangeListener() {
            private static final long serialVersionUID = 1L;

            @Override
            public void textChange(TextChangeEvent event) {
                int pos = tf.getCursorPosition();
                if (isValid(event.getText())) {
                    df.setComponentError(null);
                    isInnerChange = true;
                    df.setValue(convertedDate);
                } else {
                    df.setComponentError(InnerErrorMessage.INSTANCE);
                }
                tf.setCursorPosition(pos);
            }
        });
        df.setStyleName("truncated-date-field");
        df.addValueChangeListener(new Property.ValueChangeListener() {
            private static final long serialVersionUID = 1L;

            @Override
            public void valueChange(Property.ValueChangeEvent event) {
                if (!isInnerChange) {
                    Date d = df.getValue();
                    df.setComponentError(isValid(d) ? null : InnerErrorMessage.INSTANCE);
                    tf.setValue(d == null ? "" : DTF.print(d.getTime()));
                }
                isInnerChange = false;
            }
        });
        return new HorizontalLayout(tf, df);
    }

    @Override
    public void setPropertyDataSource( @SuppressWarnings("rawtypes") Property newDS) {
        tf.setPropertyDataSource(newDS);
        if (newDS != null && getType().isAssignableFrom(newDS.getType())) {
            original = (Date) newDS.getValue();
        } else {
            original = null;
        }
        df.setValue(original);
    }

    @Override
    public void commit() throws SourceException, InvalidValueException {
        ErrorMessage em = df.getComponentError();
        if (em != null) {
            throw new InvalidValueException(em.getFormattedHtmlMessage());
        }
        tf.commit();
    }

    @Override
    public Class<? extends Date> getType() {
        return Date.class;
    }

    private boolean isValid(String s) {
        s = StringUtils.trimToNull(s);
        if (s == null) {
            convertedDate = null;
            return true;
        }
        try {
            return isValid(DTF.parseDateTime(s).toDate());
        } catch (Exception e) {
            return false;
        }
    }

    private boolean isValid(Date d) {
        if (d == null || DateUtils.truncatedEquals(original, d, Calendar.DAY_OF_MONTH)) {
            convertedDate = d;
            return true;
        }
        if (DateUtils.truncatedCompareTo(minDay, d, Calendar.DAY_OF_MONTH) <= 0
                && DateUtils.truncatedCompareTo(maxDay, d, Calendar.DAY_OF_MONTH) >= 0) {
            convertedDate = d;
            return true;
        }
        return false;
    }

    // other methods if needed

    private static class InnerErrorMessage implements ErrorMessage {

        private static final long serialVersionUID = 1L;
        private static final InnerErrorMessage INSTANCE = new InnerErrorMessage();

        @Override
        public String getFormattedHtmlMessage() {
            return "Invalid date!";
        }

        @Override
        public ErrorLevel getErrorLevel() {
            return ErrorLevel.ERROR;
        }

        private Object readResolve() {
            return INSTANCE; // preserves singleton property
        }

    }

    private static class InnerConverter implements Converter<String, Date> {

        private static final long serialVersionUID = 1L;
        private static final InnerConverter INSTANCE = new InnerConverter();

        @Override
        public Date convertToModel(String value, Class<? extends Date> targetType, Locale locale)
                throws ConversionException {
            String s = StringUtils.trimToNull(value);
            if (s == null) {
                return null;
            }
            try {
                return DTF.parseDateTime(s).toDate();
            } catch (Exception e) {
                throw new ConversionException(e);
            }
        }

        @Override
        public String convertToPresentation(Date value, Class<? extends String> targetType, Locale locale)
                throws ConversionException {
            return value == null ? "" : DTF.print(value.getTime());
        }

        @Override
        public Class<Date> getModelType() {
            return Date.class;
        }

        @Override
        public Class<String> getPresentationType() {
            return String.class;
        }

        private Object readResolve() {
            return INSTANCE; // preserves singleton property
        }

    }

}

Into your styles.css:

.truncated-date-field > input.v-datefield-textfield {
    display: none;
}