/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.wicket.extensions.yui.calendar;
import java.lang.reflect.Method;
import java.text.DateFormatSymbols;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import org.apache.wicket.Application;
import org.apache.wicket.Component;
import org.apache.wicket.WicketRuntimeException;
import org.apache.wicket.ajax.AjaxEventBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.behavior.Behavior;
import org.apache.wicket.datetime.markup.html.form.DateTextField;
import org.apache.wicket.extensions.yui.YuiLib;
import org.apache.wicket.markup.html.IHeaderResponse;
import org.apache.wicket.markup.html.form.AbstractTextComponent.ITextFormatProvider;
import org.apache.wicket.request.Response;
import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.handler.resource.ResourceReferenceRequestHandler;
import org.apache.wicket.request.resource.JavaScriptResourceReference;
import org.apache.wicket.request.resource.PackageResourceReference;
import org.apache.wicket.request.resource.ResourceReference;
import org.apache.wicket.util.convert.IConverter;
import org.apache.wicket.util.convert.converter.DateConverter;
import org.apache.wicket.util.lang.Objects;
import org.apache.wicket.util.string.Strings;
import org.apache.wicket.util.template.PackageTextTemplate;
import org.apache.wicket.util.template.TextTemplate;
import org.joda.time.DateTime;
/**
* Pops up a YUI calendar component so that the user can select a date. On selection, the date is
* set in the component it is coupled to, after which the popup is closed again. This behavior can
* only be used with components that either implement {@link ITextFormatProvider} or that use
* {@link DateConverter} configured with an instance of {@link SimpleDateFormat} (like Wicket's
* default configuration has).<br/>
*
* To use, simply add a new instance to your component, which would typically a TextField, like
* {@link DateTextField}.<br/>
*
* The CalendarNavigator can be configured by overriding {@link #configure(Map)} and setting the
* property or by returning <code>true</code> for {@link #enableMonthYearSelection()}.
*
* @see <a
* href="http://developer.yahoo.com/yui/calendar/">http://developer.yahoo.com/yui/calendar/</a>
*
* @author eelcohillenius
*/
public class DatePicker extends Behavior
{
private static Method GETINSTANCEMETHOD = null;
static
{
try
{
// try to use JDK 6 DateFormatSymbols.getInstance(Locale)
GETINSTANCEMETHOD = DateFormatSymbols.class.getMethod("getInstance",
new Class[] { Locale.class });
}
catch (Exception e)
{
// ignore
}
}
/**
* Exception thrown when the bound component does not produce a format this date picker can work
* with.
*/
private static final class UnableToDetermineFormatException extends WicketRuntimeException
{
private static final long serialVersionUID = 1L;
public UnableToDetermineFormatException()
{
super("This behavior can only be added to components that either implement " +
ITextFormatProvider.class.getName() +
" AND produce a non-null format, or that use" +
" converters that this DatePicker can use to determine" +
" the pattern being used. Alternatively, you can extend " +
" the DatePicker and override getDatePattern to provide your own.");
}
}
/**
* Format to be used when configuring YUI calendar. Can be used when using the
* "selected" property.
*/
// See wicket-1988: SimpleDateFormat is not thread safe. Do not use static final
// See wicket-2525: SimpleDateFormat consumes a lot of memory
public static String FORMAT_DATE = "MM/dd/yyyy";
/**
* For specifying which page (month/year) to show in the calendar, use this format for the date.
* This is to be used together with the property "pagedate"
*/
// See wicket-1988: SimpleDateFormat is not thread safe. Do not use static final
// See wicket-2525: SimpleDateFormat consumes a lot of memory
public static String FORMAT_PAGEDATE = "MM/yyyy";
private static final ResourceReference YUI = new JavaScriptResourceReference(YuiLib.class, "");
private static final ResourceReference WICKET_DATE = new JavaScriptResourceReference(
DatePicker.class, "wicket-date.js");
private static final long serialVersionUID = 1L;
/** The target component. */
private Component component;
private boolean showOnFieldClick = false;
/**
* A setting that decides whether to close the date picker when the user clicks somewhere else
* on the document.
*/
private boolean autoHide = false;
/**
* Construct.
*/
public DatePicker()
{
}
/**
* {@inheritDoc}
*/
@Override
public void bind(final Component component)
{
this.component = component;
checkComponentProvidesDateFormat(component);
component.setOutputMarkupId(true);
}
/**
* {@inheritDoc}
*/
@Override
public void afterRender(final Component component)
{
super.afterRender(component);
// Append the span and img icon right after the rendering of the
// component. Not as pretty as working with a panel etc, but works
// for behaviors and is more efficient
Response response = component.getResponse();
response.write("\n<span class=\"yui-skin-sam\"> <span style=\"");
if (renderOnLoad())
{
response.write("display:block;");
}
else
{
response.write("display:none;");
response.write("position:absolute;");
}
response.write("z-index: 99999;\" id=\"");
response.write(getEscapedComponentMarkupId());
response.write("Dp\"></span><img style=\"");
response.write(getIconStyle());
response.write("\" id=\"");
response.write(getIconId());
response.write("\" src=\"");
CharSequence iconUrl = getIconUrl();
response.write(Strings.escapeMarkup(iconUrl != null ? iconUrl.toString() : ""));
response.write("\" alt=\"");
CharSequence alt = getIconAltText();
response.write(Strings.escapeMarkup((alt != null) ? alt.toString() : ""));
response.write("\" title=\"");
CharSequence title = getIconTitle();
response.write(Strings.escapeMarkup((title != null) ? title.toString() : ""));
response.write("\"/>");
if (renderOnLoad())
{
response.write("<br style=\"clear:left;\"/>");
}
response.write("</span>");
}
/**
* Controls whether or not datepicker will contribute YUI libraries to the page as part of its
* rendering lifecycle.
*
* There may be cases when the user wants to use their own version of YUI contribution code, in
* those cases this method should be overridden to return <code>false</code>.
*
* @return a flag whether to contribute YUI libraries to the page. {@code true} by default.
*/
protected boolean includeYUILibraries()
{
return true;
}
/**
* {@inheritDoc}
*/
@Override
public void renderHead(Component component, IHeaderResponse response)
{
if (includeYUILibraries())
{
YuiLib.load(response);
}
renderHeadInit(response);
// variables for the initialization script
Map<String, Object> variables = new HashMap<String, Object>();
String widgetId = getEscapedComponentMarkupId();
variables.put("componentId", getComponentMarkupId());
variables.put("widgetId", widgetId);
variables.put("datePattern", getDatePattern());
variables.put("fireChangeEvent", notifyComponentOnDateSelected());
variables.put("alignWithIcon", alignWithIcon());
variables.put("hideOnSelect", hideOnSelect());
variables.put("showOnFieldClick", showOnFieldClick());
variables.put("autoHide", autoHide());
String script = getAdditionalJavaScript();
if (script != null)
{
variables.put("additionalJavascript",
Strings.replaceAll(script, "${calendar}", "YAHOO.wicket." + widgetId + "DpJs"));
}
// print out the initialization properties
Map<String, Object> p = new LinkedHashMap<String, Object>();
configure(p, response, variables);
if (!p.containsKey("navigator") && enableMonthYearSelection())
{
p.put("navigator", Boolean.TRUE);
}
if (enableMonthYearSelection() && p.containsKey("pages") &&
Objects.longValue(p.get("pages")) > 1)
{
throw new IllegalStateException(
"You cannot use a CalendarGroup with month/year selection!");
}
// ${calendarInit}
StringBuilder calendarInit = new StringBuilder();
appendMapping(p, calendarInit);
variables.put("calendarInit", calendarInit.toString());
// render initialization script with the variables interpolated
TextTemplate datePickerJs = new PackageTextTemplate(DatePicker.class, "DatePicker.js");
datePickerJs.interpolate(variables);
response.renderOnDomReadyJavaScript(datePickerJs.asString());
// remove previously generated markup (see onRendered) via javascript in
// ajax requests to not render the yui calendar multiple times
if (AjaxRequestTarget.get() != null)
{
String escapedComponentMarkupId = getEscapedComponentMarkupId();
String javascript = "var e = Wicket.$('" + escapedComponentMarkupId + "Dp" +
"'); if (e != null && typeof(e.parentNode) != 'undefined' && " +
"typeof(e.parentNode.parentNode != 'undefined')) {" +
"e.parentNode.parentNode.removeChild(e.parentNode);" + "YAHOO.wicket." +
escapedComponentMarkupId + "DpJs.destroy(); delete YAHOO.wicket." +
escapedComponentMarkupId + "DpJs;}";
response.renderJavaScript(javascript, null);
}
}
/**
* Renders yui & wicket calendar js module loading. It is done only once per page.
*
* @param response
* header response
*/
protected void renderHeadInit(IHeaderResponse response)
{
String key = "DatePickerInit.js";
if (response.wasRendered(key))
{
return;
}
// variables for YUILoader
Map<String, Object> variables = new HashMap<String, Object>();
variables.put("basePath",
Strings.stripJSessionId(RequestCycle.get().urlFor(YUI, null).toString()) + "/");
variables.put("wicketDatePath", RequestCycle.get().urlFor(WICKET_DATE, null));
if (Application.get().usesDevelopmentConfig())
{
variables.put("filter", "filter: \"RAW\",");
variables.put("allowRollup", false);
}
else
{
variables.put("filter", "");
variables.put("allowRollup", true);
}
TextTemplate template = new PackageTextTemplate(DatePicker.class, key);
response.renderOnDomReadyJavaScript(template.asString(variables));
response.markRendered(key);
}
/**
* Check that this behavior can get a date format out of the component it is coupled to. It
* checks whether {@link #getDatePattern()} produces a non-null value. If that method returns
* null, and exception will be thrown
*
* @param component
* the component this behavior is being coupled to
* @throws UnableToDetermineFormatException
* if this date picker is unable to determine a format.
*/
private final void checkComponentProvidesDateFormat(final Component component)
{
if (getDatePattern() == null)
{
throw new UnableToDetermineFormatException();
}
}
/**
* Set widget property if the array is null and has a length greater than 0.
*
* @param widgetProperties
* @param key
* @param array
*/
private void setWidgetProperty(final Map<String, Object> widgetProperties, final String key,
final String[] array)
{
if (array != null && array.length > 0)
{
widgetProperties.put(key, array);
}
}
/**
* Whether to position the date picker relative to the trigger icon.
*
* @return If true, the date picker is aligned with the left position of the icon, and with the
* top right under. If false, the date picker will skip positioning and will let you do
* the positioning yourself. Returns true by default.
*/
protected boolean alignWithIcon()
{
return true;
}
/**
* Gives overriding classes the option of adding (or even changing/ removing) configuration
* properties for the javascript widget. See <a
* href="http://developer.yahoo.com/yui/calendar/">the widget's documentation</a> for the
* available options. If you want to override/ remove properties, you should call
* super.configure(properties) first. If you don't call that, be aware that you will have to
* call {@link #localize(Map)} manually if you like localized strings to be added.
*
* @param widgetProperties
* the current widget properties
* @param response
* the header response
* @param initVariables
* variables passed to the Wicket.DateTime.init() js method
*/
protected void configure(final Map<String, Object> widgetProperties,
final IHeaderResponse response, final Map<String, Object> initVariables)
{
widgetProperties.put("close", true);
// localize date fields
localize(widgetProperties, response, initVariables);
Object modelObject = component.getDefaultModelObject();
// null and cast check
if (modelObject instanceof Date)
{
Date date = (Date)modelObject;
widgetProperties.put("selected", new SimpleDateFormat(FORMAT_DATE).format(date));
widgetProperties.put("pagedate", new SimpleDateFormat(FORMAT_PAGEDATE).format(date));
}
}
/**
* Filter all empty elements (workaround for {@link DateFormatSymbols} returning arrays with
* empty elements).
*
* @param stringArray
* array to filter
* @return filtered array (without null or empty string elements)
*/
protected final String[] filterEmpty(String[] stringArray)
{
if (stringArray == null)
{
return null;
}
List<String> list = new ArrayList<String>(stringArray.length);
for (String string : stringArray)
{
if (!Strings.isEmpty(string))
{
list.add(string);
}
}
return list.toArray(new String[list.size()]);
}
/**
* Gets the id of the component that the calendar widget will get attached to.
*
* @return The DOM id of the component
*/
protected final String getComponentMarkupId()
{
return component.getMarkupId();
}
/**
* Gets the date pattern to use for putting selected values in the coupled component.
*
* @return The date pattern
*/
protected String getDatePattern()
{
String format = null;
if (component instanceof ITextFormatProvider)
{
format = ((ITextFormatProvider)component).getTextFormat();
// it is possible that components implement ITextFormatProvider but
// don't provide a format
}
if (format == null)
{
IConverter<?> converter = component.getConverter(DateTime.class);
if (!(converter instanceof DateConverter))
{
converter = component.getConverter(Date.class);
}
format = ((SimpleDateFormat)((DateConverter)converter).getDateFormat(component.getLocale())).toPattern();
}
return format;
}
/**
* Gets the escaped DOM id that the calendar widget will get attached to. All non word
* characters (\W) will be removed from the string.
*
* @return The DOM id of the calendar widget - same as the component's markup id + 'Dp'}
*/
protected final String getEscapedComponentMarkupId()
{
return component.getMarkupId().replaceAll("\\W", "");
}
/**
* Gets the id of the icon that triggers the popup.
*
* @return The id of the icon
*/
protected final String getIconId()
{
return getEscapedComponentMarkupId() + "Icon";
}
/**
* Gets the style of the icon that triggers the popup.
*
* @return The style of the icon, e.g. 'cursor: point' etc.
*/
protected String getIconStyle()
{
return "cursor: pointer; border: none;";
}
/**
* Gets the title attribute of the datepicker icon
*
* @return text
*/
protected CharSequence getIconTitle()
{
return "";
}
/**
* Gets the icon alt text for the datepicker icon
*
* @return text
*/
protected CharSequence getIconAltText()
{
return "";
}
/**
* Gets the url for the popup button. Users can override to provide their own icon URL.
*
* @return the url to use for the popup button/ icon
*/
protected CharSequence getIconUrl()
{
return RequestCycle.get().urlFor(
new ResourceReferenceRequestHandler(new PackageResourceReference(DatePicker.class,
"icon1.gif")));
}
/**
* Gets the locale that should be used to configure this widget.
*
* @return By default the locale of the bound component.
*/
protected Locale getLocale()
{
return component.getLocale();
}
/**
* Configure the localized strings for the datepicker widget. This implementation uses
* {@link DateFormatSymbols} and some slight string manupilation to get the strings for months
* and week days. Also, the first week day is set according to the {@link Locale} returned by
* {@link #getLocale()}. It should work well for most locales.
* <p>
* This method is called from {@link #configureWidgetProperties(Map)} and can be overridden if
* you want to customize setting up the localized strings but are happy with the rest of
* {@link #configureWidgetProperties(Map)}'s behavior. Note that you can call (overridable)
* method {@link #getLocale()} to get the locale that should be used for setting up the widget.
* </p>
* <p>
* See YUI Calendar's <a href="http://developer.yahoo.com/yui/examples/calendar/germany/1.html">
* German</a> and <a
* href="http://developer.yahoo.com/yui/examples/calendar/japan/1.html">Japanese</a> examples
* for more info.
* </p>
*
* @param widgetProperties
* the current widget properties
* @param response
* the header response
* @param initVariables
* variables passed to the Wicket.DateTime.init() js method
*/
protected void localize(Map<String, Object> widgetProperties, IHeaderResponse response,
Map<String, Object> initVariables)
{
Locale locale = getLocale();
String key = "wicketCalendarI18n[\"" + locale.toString() + "\"]";
initVariables.put("i18n", key);
if (response.wasRendered(key))
{
return;
}
DateFormatSymbols dfSymbols = null;
if (GETINSTANCEMETHOD != null)
{
// try to use JDK 6 DateFormatSymbols.getInstance(Locale)
try
{
dfSymbols = (DateFormatSymbols)GETINSTANCEMETHOD.invoke(null, locale);
}
catch (Exception e)
{
// ignore
}
}
if (dfSymbols == null)
{
dfSymbols = new DateFormatSymbols(locale);
}
Map<String, Object> i18nVariables = new LinkedHashMap<String, Object>();
setWidgetProperty(i18nVariables, "MONTHS_SHORT", filterEmpty(dfSymbols.getShortMonths()));
setWidgetProperty(i18nVariables, "MONTHS_LONG", filterEmpty(dfSymbols.getMonths()));
setWidgetProperty(i18nVariables, "WEEKDAYS_MEDIUM",
filterEmpty(dfSymbols.getShortWeekdays()));
setWidgetProperty(i18nVariables, "WEEKDAYS_LONG", filterEmpty(dfSymbols.getWeekdays()));
i18nVariables.put("START_WEEKDAY", Calendar.getInstance(locale).getFirstDayOfWeek() - 1);
if (Locale.SIMPLIFIED_CHINESE.equals(locale) || Locale.TRADITIONAL_CHINESE.equals(locale))
{
setWidgetProperty(i18nVariables, "WEEKDAYS_1CHAR",
filterEmpty(substring(dfSymbols.getShortWeekdays(), 2, 1)));
i18nVariables.put("WEEKDAYS_SHORT",
filterEmpty(substring(dfSymbols.getShortWeekdays(), 2, 1)));
}
else
{
setWidgetProperty(i18nVariables, "WEEKDAYS_1CHAR",
filterEmpty(substring(dfSymbols.getShortWeekdays(), 0, 1)));
setWidgetProperty(i18nVariables, "WEEKDAYS_SHORT",
filterEmpty(substring(dfSymbols.getShortWeekdays(), 0, 2)));
}
StringBuilder i18n = new StringBuilder(key);
i18n.append('=');
appendMapping(i18nVariables, i18n);
i18n.append(';');
response.renderOnDomReadyJavaScript(i18n.toString());
response.wasRendered(key);
}
/**
* Whether to notify the associated component when a date is selected. Notifying is done by
* calling the associated component's onchange JavaScript event handler. You can for instance
* attach an {@link AjaxEventBehavior} to that component to get a call back to the server. The
* default is true.
*
* @return if true, notifies the associated component when a date is selected
*/
protected boolean notifyComponentOnDateSelected()
{
return true;
}
/**
* Makes a copy of the provided array and for each element copy the substring 0..len to the new
* array
*
* @param array
* array to copy from
* @param len
* size of substring for each element to copy
* @return copy of the array filled with substrings.
*/
protected final String[] substring(final String[] array, final int len)
{
return substring(array, 0, len);
}
/**
* Makes a copy of the provided array and for each element copy the substring 0..len to the new
* array
*
* @param array
* array to copy from
* @param start
* start position of the substring
* @param len
* size of substring for each element to copy
* @return copy of the array filled with substrings.
*/
protected final String[] substring(final String[] array, final int start, final int len)
{
if (array != null)
{
String[] copy = new String[array.length];
for (int i = 0; i < array.length; i++)
{
String el = array[i];
if (el != null)
{
if (el.length() > (start + len))
{
copy[i] = el.substring(start, start + len);
}
else
{
copy[i] = el;
}
}
}
return copy;
}
return null;
}
/**
* Indicates whether plain text is rendered or two select boxes are used to allow direct
* selection of month and year.
*
* @return <code>true</code> if select boxes should be rendered to allow month and year
* selection.<br/>
* <code>false</code> to render just plain text.
*/
protected boolean enableMonthYearSelection()
{
return false;
}
/**
* Indicates whether the calendar should be hidden after a date was selected.
*
* @return <code>true</code> (default) if the calendar should be hidden after the date selection <br/>
* <code>false</code> if the calendar should remain visible after the date selection.
*/
protected boolean hideOnSelect()
{
return true;
}
/**
* Indicates whether the calendar should be shown when corresponding text input is clicked.
*
* @return <code>true</code> <br/>
* <code>false</code> (default)
*/
protected boolean showOnFieldClick()
{
return showOnFieldClick;
}
/**
* @param show
* a flag indicating whether to show the picker on click event
* @return {@code this} instance to be able to chain calls
* @see {@link #showOnFieldClick()}
*/
public DatePicker setShowOnFieldClick(boolean show)
{
showOnFieldClick = show;
return this;
}
/**
* Indicates whether the calendar should be hidden when the user clicks on an area of the
* document outside of the dialog.
*
* @return <code>true</code> <br/>
* <code>false</code> (default)
*/
protected boolean autoHide()
{
return autoHide;
}
/**
* @param autoHide
* a flag indicating whether to hide the picker on click event
* @return {@code this} instance to be able to chain calls
* @see {@link #autoHide()}
*/
public DatePicker setAutoHide(boolean autoHide)
{
this.autoHide = autoHide;
return this;
}
/**
* Indicates whether the calendar should be rendered after it has been loaded.
*
* @return <code>true</code> if the calendar should be rendered after it has been loaded.<br/>
* <code>false</code> (default) if it's initially hidden.
*/
protected boolean renderOnLoad()
{
return false;
}
/**
* Override this method to further customize the YUI Calendar with additional JavaScript code.
* The code returned by this method is executed right after the Calendar has been constructed
* and initialized. To refer to the actual Calendar DOM object, use <code>${calendar}</code> in
* your code.<br/>
* See <a href="http://developer.yahoo.com/yui/calendar/">the widget's documentation</a> for
* more information about the YUI Calendar.<br/>
* Example:
*
* <pre>
* protected String getAdditionalJavaScript()
* {
* return "${calendar}.addRenderer(\"10/3\", ${calendar}.renderCellStyleHighlight1);";
* }
* </pre>
*
* @return a String containing additional JavaScript code
*/
protected String getAdditionalJavaScript()
{
return "";
}
/**
* {@inheritDoc}
*/
@Override
public boolean isEnabled(final Component component)
{
return component.isEnabledInHierarchy();
}
/**
*
* @param map
* the key-value pairs to be serialized
* @param json
* the buffer holding the constructed json
*/
private void appendMapping(final Map<String, Object> map, final StringBuilder json)
{
json.append("{");
for (Iterator<Entry<String, Object>> i = map.entrySet().iterator(); i.hasNext();)
{
Entry<String, Object> entry = i.next();
json.append(entry.getKey());
Object value = entry.getValue();
if (value instanceof CharSequence)
{
json.append(":\"");
json.append(Strings.toEscapedUnicode(value.toString()));
json.append("\"");
}
else if (value instanceof CharSequence[])
{
json.append(":[");
CharSequence[] valueArray = (CharSequence[])value;
for (int j = 0; j < valueArray.length; j++)
{
CharSequence tmpValue = valueArray[j];
if (j > 0)
{
json.append(",");
}
if (tmpValue != null)
{
json.append("\"");
json.append(Strings.toEscapedUnicode(tmpValue.toString()));
json.append("\"");
}
}
json.append("]");
}
else if (value instanceof Map)
{
json.append(":");
@SuppressWarnings("unchecked")
Map<String, Object> nmap = (Map<String, Object>)value;
appendMapping(nmap, json);
}
else
{
json.append(":");
json.append(Strings.toEscapedUnicode(String.valueOf(value)));
}
if (i.hasNext())
{
json.append(",");
}
}
json.append("}");
}
}