001// Licensed under the Apache License, Version 2.0 (the "License");
002// you may not use this file except in compliance with the License.
003// You may obtain a copy of the License at
004//
005// http://www.apache.org/licenses/LICENSE-2.0
006//
007// Unless required by applicable law or agreed to in writing, software
008// distributed under the License is distributed on an "AS IS" BASIS,
009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
010// See the License for the specific language governing permissions and
011// limitations under the License.
012
013package org.apache.tapestry5.corelib.components;
014
015import org.apache.tapestry5.*;
016import org.apache.tapestry5.annotations.Events;
017import org.apache.tapestry5.annotations.Import;
018import org.apache.tapestry5.annotations.Parameter;
019import org.apache.tapestry5.annotations.RequestParameter;
020import org.apache.tapestry5.commons.Messages;
021import org.apache.tapestry5.corelib.base.AbstractField;
022import org.apache.tapestry5.dom.Element;
023import org.apache.tapestry5.ioc.annotations.Inject;
024import org.apache.tapestry5.ioc.annotations.Symbol;
025import org.apache.tapestry5.ioc.internal.util.InternalUtils;
026import org.apache.tapestry5.json.JSONObject;
027import org.apache.tapestry5.services.ComponentDefaultProvider;
028import org.apache.tapestry5.services.compatibility.DeprecationWarning;
029
030import java.text.DateFormat;
031import java.text.ParseException;
032import java.text.SimpleDateFormat;
033import java.util.Date;
034import java.util.Locale;
035
036/**
037 * A component used to collect a provided date from the user using a client-side JavaScript calendar. Non-JavaScript
038 * clients can simply type into a text field.
039 *
040 * One aspect here is that, because client-side JavaScript formatting and parsing is so limited, we (currently)
041 * use Ajax to send the user's input to the server for parsing (before raising the popup) and formatting (after closing
042 * the popup). Weird and inefficient, but easier than writing client-side JavaScript for that purpose.
043 *
044 * Tapestry's DateField component is a wrapper around <a
045 * href="http://webfx.eae.net/dhtml/datepicker/datepicker.html">WebFX DatePicker</a>.
046 *
047 * @tapestrydoc
048 * @see Form
049 * @see TextField
050 */
051// TODO: More testing; see https://issues.apache.org/jira/browse/TAPESTRY-1844
052@Import(stylesheet = "${tapestry.datepicker}/css/datepicker.css",
053        module = "t5/core/datefield")
054@Events(EventConstants.VALIDATE)
055public class DateField extends AbstractField
056{
057    /**
058     * The value parameter of a DateField must be a {@link java.util.Date}.
059     */
060    @Parameter(required = true, principal = true, autoconnect = true)
061    private Date value;
062
063    /**
064     * The format used to format <em>and parse</em> dates. This is typically specified as a string which is coerced to a
065     * DateFormat. You should be aware that using a date format with a two digit year is problematic: Java (not
066     * Tapestry) may get confused about the century.
067     */
068    @Parameter(required = true, allowNull = false, defaultPrefix = BindingConstants.LITERAL)
069    private DateFormat format;
070
071    /**
072     * Allows the type of field to be output; normally this is "text", but can be updated to "date" or "datetime"
073     * as per the HTML 5 specification.
074     *
075     * @since 5.4
076     */
077    @Parameter(allowNull = false, defaultPrefix = BindingConstants.LITERAL, value = "text")
078    private String type;
079
080    /**
081     * When the <code>format</code> parameter isn't used, this parameter defines whether the
082     * <code>DateFormat</code> created by this component will be lenient or not.
083     * The default value of this parameter is the value of the {@link SymbolConstants#LENIENT_DATE_FORMAT}
084     * symbol.
085     *
086     * @see DateFormat#setLenient(boolean)
087     * @see SymbolConstants#LENIENT_DATE_FORMAT
088     * @since 5.4
089     */
090    @Parameter(principal = true)
091    private boolean lenient;
092
093    /**
094     * If true, then the text field will be hidden, and only the icon for the date picker will be visible. The default
095     * is false.
096     */
097    @Parameter
098    private boolean hideTextField;
099
100    /**
101     * The object that will perform input validation (which occurs after translation). The translate binding prefix is
102     * generally used to provide this object in a declarative fashion.
103     */
104    @Parameter(defaultPrefix = BindingConstants.VALIDATE)
105    @SuppressWarnings("unchecked")
106    private FieldValidator<Object> validate;
107
108    /**
109     * Icon used for the date field trigger button. This was used in Tapestry 5.3 and earlier and is now ignored.
110     *
111     * @deprecated Deprecated in 5.4 with no replacement. The component leverages the Twitter Bootstrap glyphicons support.
112     */
113    @Parameter(defaultPrefix = BindingConstants.ASSET)
114    private Asset icon;
115
116    /**
117     * Used to override the component's message catalog.
118     *
119     * @since 5.2.0.0
120     * @deprecated Since 5.4; override the global message key "core-date-value-not-parsable" instead (see {@link org.apache.tapestry5.services.messages.ComponentMessagesSource})
121     */
122    @Parameter("componentResources.messages")
123    private Messages messages;
124
125    @Inject
126    private Locale locale;
127
128    @Inject
129    private DeprecationWarning deprecationWarning;
130
131    @Inject
132    @Symbol(SymbolConstants.LENIENT_DATE_FORMAT)
133    private boolean lenientDateFormatSymbolValue;
134
135    private static final String RESULT = "result";
136
137    private static final String ERROR = "error";
138    private static final String INPUT_PARAMETER = "input";
139
140    void pageLoaded()
141    {
142        deprecationWarning.ignoredComponentParameters(resources, "icon");
143    }
144
145    DateFormat defaultFormat()
146    {
147        DateFormat shortDateFormat = DateFormat.getDateInstance(DateFormat.SHORT, locale);
148
149        if (shortDateFormat instanceof SimpleDateFormat)
150        {
151            SimpleDateFormat simpleDateFormat = (SimpleDateFormat) shortDateFormat;
152
153            String pattern = simpleDateFormat.toPattern();
154
155            String revised = pattern.replaceAll("([^y])yy$", "$1yyyy");
156
157            final SimpleDateFormat revisedDateFormat = new SimpleDateFormat(revised);
158            revisedDateFormat.setLenient(lenient);
159            return revisedDateFormat;
160        }
161
162        return shortDateFormat;
163    }
164
165    /**
166     * Computes a default value for the "validate" parameter using {@link ComponentDefaultProvider}.
167     */
168    final Binding defaultValidate()
169    {
170        return defaultProvider.defaultValidatorBinding("value", resources);
171    }
172
173    final boolean defaultLenient()
174    {
175        return lenientDateFormatSymbolValue;
176    }
177
178    /**
179     * Ajax event handler, used when initiating the popup. The client sends the input value form the field to the server
180     * to parse it according to the server-side format. The response contains a "result" key of the formatted date in a
181     * format acceptable to the JavaScript Date() constructor. Alternately, an "error" key indicates the the input was
182     * not formatted correct.
183     */
184    JSONObject onParse(@RequestParameter(INPUT_PARAMETER)
185                       String input)
186    {
187        JSONObject response = new JSONObject();
188
189        try
190        {
191            Date date = format.parse(input);
192
193            response.put(RESULT, new SimpleDateFormat("yyyy-MM-dd").format(date));
194        } catch (ParseException ex)
195        {
196            response.put(ERROR, ex.getMessage());
197        }
198
199        return response;
200    }
201
202    /**
203     * Ajax event handler, used after the client-side popup completes. The client sends the date, formatted as
204     * milliseconds since the epoch, to the server, which reformats it according to the server side format and returns
205     * the result.
206     * @throws ParseException
207     */
208    JSONObject onFormat(@RequestParameter(INPUT_PARAMETER)
209                        String input) throws ParseException
210    {
211        JSONObject response = new JSONObject();
212
213        try
214        {
215            Date date = new SimpleDateFormat("yyyy-MM-dd").parse(input);
216
217            response.put(RESULT, format.format(date));
218        } catch (NumberFormatException ex)
219        {
220            response.put(ERROR, ex.getMessage());
221        }
222
223        return response;
224    }
225
226    void beginRender(MarkupWriter writer)
227    {
228        String value = validationTracker.getInput(this);
229
230        if (value == null)
231        {
232            value = formatCurrentValue();
233        }
234
235        String clientId = getClientId();
236
237        writer.element("div",
238                "data-component-type", "core/DateField",
239                "data-parse-url", resources.createEventLink("parse").toString(),
240                "data-format-url", resources.createEventLink("format").toString());
241
242        if (!hideTextField)
243        {
244            writer.attributes("class", "input-group");
245        }
246
247        Element field = writer.element("input",
248
249                "type", type,
250
251                "class", cssClass,
252
253                "name", getControlName(),
254
255                "id", clientId,
256
257                "value", value);
258
259        if (hideTextField)
260        {
261            field.attribute("class", "hide");
262        }
263
264        writeDisabled(writer);
265
266        putPropertyNameIntoBeanValidationContext("value");
267
268        validate.render(writer);
269
270        removePropertyNameFromBeanValidationContext();
271
272        resources.renderInformalParameters(writer);
273
274        decorateInsideField();
275
276        writer.end();   // input
277
278        if (!hideTextField)
279        {
280            writer.element("span", "class", "input-group-btn");
281        }
282
283        writer.element("button",
284                "type", "button",
285                "class", "btn btn-default",
286                "alt", "[Show]");
287
288        writer.element("span", "class", "glyphicon glyphicon-calendar");
289        writer.end(); // span
290
291        writer.end(); // button
292
293        if (!hideTextField)
294        {
295            writer.end();        // span.input-group-btn
296        }
297
298        writer.end(); // outer div
299    }
300
301    private void writeDisabled(MarkupWriter writer)
302    {
303        if (isDisabled())
304            writer.attributes("disabled", "disabled");
305    }
306
307    private String formatCurrentValue()
308    {
309        if (value == null)
310            return "";
311
312        return format.format(value);
313    }
314
315    @Override
316    protected void processSubmission(String controlName)
317    {
318        String value = request.getParameter(controlName);
319
320        validationTracker.recordInput(this, value);
321
322        Date parsedValue = null;
323
324        try
325        {
326            if (InternalUtils.isNonBlank(value))
327                parsedValue = format.parse(value);
328        } catch (ParseException ex)
329        {
330            validationTracker.recordError(this, messages.format("core-date-value-not-parseable", value));
331            return;
332        }
333
334        putPropertyNameIntoBeanValidationContext("value");
335        try
336        {
337            fieldValidationSupport.validate(parsedValue, resources, validate);
338
339            this.value = parsedValue;
340        } catch (ValidationException ex)
341        {
342            validationTracker.recordError(this, ex.getMessage());
343        }
344
345        removePropertyNameFromBeanValidationContext();
346    }
347
348    void injectResources(ComponentResources resources)
349    {
350        this.resources = resources;
351    }
352
353    @Override
354    public boolean isRequired()
355    {
356        return validate.isRequired();
357    }
358}