Tapestry 5 DateField as 3 Select Dropdowns

Tapestry 5 has an excellent javascript-based date picker component called DateField, which greatly simplifies the task of entering a properly formatted date for the user. The component gets the job done nicely when using a browser with a keyboard and mouse, but is somewhat clunky to use on smaller mobile devices with a touch screen.

I recently worked on a project that primarily targeted mobile devices, and users were reporting difficulties with using it on touch screens. My first attempt at tackling the problem was to use HTML5’s new ‘date’ input type. This worked well on iPads and on Android devices with the Chrome browser, but failed miserably on desktop browsers, as support for this type is still quite spotty. Google’s Chrome and Opera are the only desktop browsers that present a UI, while Firefox and Internet Explorer simply display a text field.

I finally decided to use the tried and tested method of displays 3 selects, one each for date, month and year, and set out to create a component for the task that I could reuse within the app. This turned out to be more difficult that I had anticipated, mostly due to how Tapestry handles form submission and nested components when the component is used inside a loop. Specifically, because client ids for components inside loops are generated on the fly, it was difficult to read the selected values properly from request parameters. I finally managed to hack together a working solution using Tapestry’s submitnotifier notifier component.

Below is the source code for the class file, which goes in the components package:

// 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 ca.jeshurun.blog.example;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;

import javax.inject.Inject;

import org.apache.tapestry5.Binding;
import org.apache.tapestry5.BindingConstants;
import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.FieldValidator;
import org.apache.tapestry5.OptionGroupModel;
import org.apache.tapestry5.OptionModel;
import org.apache.tapestry5.SelectModel;
import org.apache.tapestry5.ValidationTracker;
import org.apache.tapestry5.ValueEncoder;
import org.apache.tapestry5.annotations.Environmental;
import org.apache.tapestry5.annotations.OnEvent;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.annotations.SetupRender;
import org.apache.tapestry5.corelib.base.AbstractField;
import org.apache.tapestry5.internal.OptionModelImpl;
import org.apache.tapestry5.ioc.Messages;
import org.apache.tapestry5.services.ComponentDefaultProvider;
import org.apache.tapestry5.services.Request;
import org.apache.tapestry5.util.AbstractSelectModel;
import org.joda.time.LocalDate;
import org.joda.time.Period;
import org.joda.time.PeriodType;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;

/**
 * 
 * @author Jeshurun Daniel
 * 
 */
public class DropdownCalendarField extends AbstractField {

	private static final String PATTERN = "dd/MM/yyyy";
	private static final ThreadLocal df = new ThreadLocal() {
		@Override
		public SimpleDateFormat get() {
			return new SimpleDateFormat(PATTERN);
		}
	};
	private static final DateTimeFormatter fmt = DateTimeFormat
			.forPattern(PATTERN);

	private static final String MONTHS[] = { "Jan", "Feb", "Mar", "Apr", "May",
			"Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", };
	private static final List MONTHS_LIST = Arrays.asList(MONTHS);

	@Inject
	private ComponentResources resources;

	@Inject
	private Messages messages;

	@Inject
	private ComponentDefaultProvider defaultProvider;

	@Inject
	private Request request;

	@Environmental
	private ValidationTracker tracker;

	@Property
	private int date;

	@Property
	private int month;

	@Property
	private int year;

	@Property
	@Parameter(defaultPrefix = BindingConstants.VALIDATE)
	private FieldValidator validate;

	/**
	 * The value parameter of a DateField must be a {@link java.util.Date}.
	 */
	@Parameter(required = true, principal = true, autoconnect = true)
	private Date value;

	/**
	 * The value parameter of a DateField must be a {@link java.util.Date}.
	 */
	@Parameter(defaultPrefix = BindingConstants.LITERAL)
	private String start;

	/**
	 * The value parameter of a DateField must be a {@link java.util.Date}.
	 */
	@Parameter(defaultPrefix = BindingConstants.LITERAL)
	private String end;

	private LocalDate rangeStart;
	private LocalDate rangeEnd;

	@SetupRender
	void setupRender() {
		if (value != null) {
			LocalDate lDate = LocalDate.fromDateFields(value);
			this.date = lDate.getDayOfMonth();
			this.month = lDate.getMonthOfYear();
			this.year = lDate.getYear();
		}

		if (start != null) {
			try {
				rangeStart = LocalDate.fromDateFields(df.get().parse(start));
			} catch (ParseException e) {
			}
		}
		if (rangeStart == null) {
			rangeStart = LocalDate.fromDateFields(
					new Date(System.currentTimeMillis())).minusYears(25);
		}

		if (end != null) {
			try {
				rangeEnd = LocalDate.fromDateFields(df.get().parse(end));
			} catch (ParseException e) {
			}
		}
		if (rangeEnd == null) {
			rangeEnd = LocalDate.fromDateFields(new Date(System
					.currentTimeMillis()));
		}
	}

	/**
	 * Computes a default value for the "validate" parameter using
	 * {@link ComponentDefaultProvider}.
	 */
	final Binding defaultValidate() {
		return defaultProvider.defaultValidatorBinding("value", resources);
	}

	public SelectModel getDateSelectModel() {
		if (new Period(rangeStart, rangeEnd, PeriodType.days()).getDays() > 30) {
			return createRangeSelectModel(1, 31, false);
		}
		return createRangeSelectModel(rangeStart.getDayOfMonth(),
				rangeEnd.getDayOfMonth(), false);
	}

	public SelectModel getMonthSelectModel() {
		if (new Period(rangeStart, rangeEnd, PeriodType.months()).getMonths() > 12) {
			return createRangeSelectModel(1, 12, true);
		}
		return createRangeSelectModel(rangeStart.getMonthOfYear(),
				rangeEnd.getMonthOfYear(), true);
	}

	public ValueEncoder getMonthEncoder() {
		return new ValueEncoder() {
			@Override
			public String toClient(Integer month) {
				return MONTHS[month - 1];
			}

			@Override
			public Integer toValue(String month) {
				return MONTHS_LIST.indexOf(month) + 1;
			}
		};
	}

	public SelectModel getYearSelectModel() {
		return createRangeSelectModel(rangeStart.getYear(), rangeEnd.getYear(),
				false);
	}

	@Override
	protected void processSubmission(String controlName) {
	}

	@OnEvent(value = "AfterSubmit")
	void afterSubmit() {
		try {
			if (date != 0 && month != 0 && year != 0)
				value = fmt.parseDateTime(date + "/" + month + "/" + year)
						.toDate();
		} catch (Exception ex) {
			tracker.recordError(
					this,
					messages.format("date-value-not-parseable", date + "/"
							+ month + "/" + year));
		}
	}

	private SelectModel createRangeSelectModel(final int start, final int end,
			final boolean isMonth) {
		return new AbstractSelectModel() {

			@Override
			public List getOptions() {
				List options = new ArrayList();
				for (int i = start; i <= end; i++) {
					if (isMonth)
						options.add(new OptionModelImpl(MONTHS[i - 1], i));
					else
						options.add(new OptionModelImpl(String.valueOf(i)));
				}
				return options;
			}

			@Override
			public List getOptionGroups() {
				return null;
			}
		};
	}

}


And the source of the template file which would be under the same package, possibly in a different source folder:


To use it, simply include the component in your page template:


where

  • value is bound to a property of type java.util.Date in the page, where the value of the selection will be stored
  • validate is an implementation of org.apache.tapestry5.ValidationTracker that is passed to each individual select within the component
  • start is a java.lang.String in dd/MM/yyyy format indicating the first selectable date. Defaults to 25 years from the current date.
  • end is a java.lang.String in dd/MM/yyyy format indicating the last selectable date. Defaults to the current date.

Happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *

18 − eleven =