001// Copyright 2007-2013 The Apache Software Foundation
002//
003// Licensed under the Apache License, Version 2.0 (the "License");
004// you may not use this file except in compliance with the License.
005// You may obtain a copy of the License at
006//
007// http://www.apache.org/licenses/LICENSE-2.0
008//
009// Unless required by applicable law or agreed to in writing, software
010// distributed under the License is distributed on an "AS IS" BASIS,
011// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
012// See the License for the specific language governing permissions and
013// limitations under the License.
014
015package org.apache.tapestry5.corelib.components;
016
017import org.apache.tapestry5.*;
018import org.apache.tapestry5.annotations.*;
019import org.apache.tapestry5.corelib.base.AbstractField;
020import org.apache.tapestry5.corelib.data.BlankOption;
021import org.apache.tapestry5.corelib.data.SecureOption;
022import org.apache.tapestry5.corelib.mixins.RenderDisabled;
023import org.apache.tapestry5.internal.TapestryInternalUtils;
024import org.apache.tapestry5.internal.util.CaptureResultCallback;
025import org.apache.tapestry5.internal.util.SelectModelRenderer;
026import org.apache.tapestry5.ioc.Messages;
027import org.apache.tapestry5.ioc.annotations.Inject;
028import org.apache.tapestry5.ioc.internal.util.InternalUtils;
029import org.apache.tapestry5.services.*;
030import org.apache.tapestry5.services.javascript.JavaScriptSupport;
031import org.apache.tapestry5.util.EnumSelectModel;
032
033import java.util.Collections;
034import java.util.List;
035
036/**
037 * Select an item from a list of values, using an [X]HTML <select> element on the client side. Any validation
038 * decorations will go around the entire <select> element.
039 * <p/>
040 * A core part of this component is the {@link ValueEncoder} (the encoder parameter) that is used to convert between
041 * server-side values and unique client-side strings. In some cases, a {@link ValueEncoder} can be generated automatically from
042 * the type of the value parameter. The {@link ValueEncoderSource} service provides an encoder in these situations; it
043 * can be overridden by binding the encoder parameter, or extended by contributing a {@link ValueEncoderFactory} into the
044 * service's configuration.
045 *
046 * @tapestrydoc
047 */
048@Events(
049        {EventConstants.VALIDATE, EventConstants.VALUE_CHANGED + " when 'zone' parameter is bound"})
050public class Select extends AbstractField
051{
052    public static final String CHANGE_EVENT = "change";
053
054    private class Renderer extends SelectModelRenderer
055    {
056
057        public Renderer(MarkupWriter writer)
058        {
059            super(writer, encoder);
060        }
061
062        @Override
063        protected boolean isOptionSelected(OptionModel optionModel, String clientValue)
064        {
065            return isSelected(clientValue);
066        }
067    }
068
069    /**
070     * A ValueEncoder used to convert the server-side object provided by the
071     * "value" parameter into a unique client-side string (typically an ID) and
072     * back. Note: this parameter may be OMITTED if Tapestry is configured to
073     * provide a ValueEncoder automatically for the type of property bound to
074     * the "value" parameter.
075     *
076     * @see ValueEncoderSource
077     */
078    @Parameter
079    private ValueEncoder encoder;
080
081    /**
082     * Controls whether the submitted value is validated to be one of the values in
083     * the {@link SelectModel}. If "never", then no such validation is performed,
084     * theoretically allowing a selection to be made that was not presented to
085     * the user.  Note that an "always" value here requires the SelectModel to
086     * still exist (or be created again) when the form is submitted, whereas a
087     * "never" value does not.  Defaults to "auto", which causes the validation
088     * to occur only if the SelectModel is present (not null) when the form is
089     * submitted.
090     *
091     * @since 5.4
092     */
093    @Parameter(value = BindingConstants.SYMBOL + ":" + ComponentParameterConstants.VALIDATE_WITH_MODEL)
094    private SecureOption secure;
095
096    /**
097     * The model used to identify the option groups and options to be presented to the user. This can be generated
098     * automatically for Enum types.
099     */
100    @Parameter(required = true, allowNull = false)
101    private SelectModel model;
102
103    /**
104     * Controls whether an additional blank option is provided. The blank option precedes all other options and is never
105     * selected. The value for the blank option is always the empty string, the label may be the blank string; the
106     * label is from the blankLabel parameter (and is often also the empty string).
107     */
108    @Parameter(value = "auto", defaultPrefix = BindingConstants.LITERAL)
109    private BlankOption blankOption;
110
111    /**
112     * The label to use for the blank option, if rendered. If not specified, the container's message catalog is
113     * searched for a key, <code><em>id</em>-blanklabel</code>.
114     */
115    @Parameter(defaultPrefix = BindingConstants.LITERAL)
116    private String blankLabel;
117
118    @Inject
119    private Request request;
120
121    @Environmental
122    private ValidationTracker tracker;
123
124    /**
125     * Performs input validation on the value supplied by the user in the form submission.
126     */
127    @Parameter(defaultPrefix = BindingConstants.VALIDATE)
128    private FieldValidator<Object> validate;
129
130    /**
131     * The value to read or update.
132     */
133    @Parameter(required = true, principal = true, autoconnect = true)
134    private Object value;
135
136    /**
137     * Binding the zone parameter will cause any change of Select's value to be handled as an Ajax request that updates
138     * the
139     * indicated zone. The component will trigger the event {@link EventConstants#VALUE_CHANGED} to inform its
140     * container that Select's value has changed.
141     *
142     * @since 5.2.0
143     */
144    @Parameter(defaultPrefix = BindingConstants.LITERAL)
145    private String zone;
146
147    @Inject
148    private FieldValidationSupport fieldValidationSupport;
149
150    @Environmental
151    private FormSupport formSupport;
152
153    @Inject
154    private JavaScriptSupport javascriptSupport;
155
156    @SuppressWarnings("unused")
157    @Mixin
158    private RenderDisabled renderDisabled;
159
160    private String selectedClientValue;
161
162    private boolean isSelected(String clientValue)
163    {
164        return TapestryInternalUtils.isEqual(clientValue, selectedClientValue);
165    }
166
167    @SuppressWarnings(
168            {"unchecked"})
169    @Override
170    protected void processSubmission(String controlName)
171    {
172        String submittedValue = request.getParameter(controlName);
173
174        tracker.recordInput(this, submittedValue);
175
176        Object selectedValue;
177
178        try
179        {
180            selectedValue = toValue(submittedValue);
181        } catch (ValidationException ex)
182        {
183            // Really, this will just be the logic related to the new (in 5.4) secure
184            // parameter:
185
186            tracker.recordError(this, ex.getMessage());
187            return;
188        }
189
190        putPropertyNameIntoBeanValidationContext("value");
191
192        try
193        {
194            fieldValidationSupport.validate(selectedValue, resources, validate);
195
196            value = selectedValue;
197        } catch (ValidationException ex)
198        {
199            tracker.recordError(this, ex.getMessage());
200        }
201
202        removePropertyNameFromBeanValidationContext();
203    }
204
205    void afterRender(MarkupWriter writer)
206    {
207        writer.end();
208    }
209
210    void beginRender(MarkupWriter writer)
211    {
212        writer.element("select",
213                "name", getControlName(),
214                "id", getClientId(),
215                "class", cssClass);
216
217        putPropertyNameIntoBeanValidationContext("value");
218
219        validate.render(writer);
220
221        removePropertyNameFromBeanValidationContext();
222
223        resources.renderInformalParameters(writer);
224
225        decorateInsideField();
226
227        // Disabled is via a mixin
228
229        if (this.zone != null)
230        {
231            javaScriptSupport.require("t5/core/select");
232
233            Link link = resources.createEventLink(CHANGE_EVENT);
234
235            writer.attributes(
236                    "data-update-zone", zone,
237                    "data-update-url", link);
238        }
239    }
240
241    Object onChange(@RequestParameter(value = "t:selectvalue", allowBlank = true)
242                    final String selectValue) throws ValidationException
243    {
244        final Object newValue = toValue(selectValue);
245
246        CaptureResultCallback<Object> callback = new CaptureResultCallback<Object>();
247
248        this.resources.triggerEvent(EventConstants.VALUE_CHANGED, new Object[]
249                {newValue}, callback);
250
251        this.value = newValue;
252
253        return callback.getResult();
254    }
255
256    protected Object toValue(String submittedValue) throws ValidationException
257    {
258        if (InternalUtils.isBlank(submittedValue))
259        {
260            return null;
261        }
262
263        // can we skip the check for the value being in the model?
264        if (secure == SecureOption.NEVER || (secure == SecureOption.AUTO && model == null))
265        {
266            return encoder.toValue(submittedValue);
267        }
268
269        // for entity types the SelectModel may be unintentionally null when the form is submitted
270        if (model == null)
271        {
272            throw new ValidationException("Model is null when validating submitted option." +
273                    " To fix: persist the SeletModel or recreate it upon form submission," +
274                    " or change the 'secure' parameter.");
275        }
276
277        return findValueInModel(submittedValue);
278    }
279
280    private Object findValueInModel(String submittedValue) throws ValidationException
281    {
282
283        Object asSubmitted = encoder.toValue(submittedValue);
284
285        // The visitor would be nice if it had the option to abort the visit
286        // early.
287
288        if (findInOptions(model.getOptions(), asSubmitted))
289        {
290            return asSubmitted;
291        }
292
293        if (model.getOptionGroups() != null)
294        {
295            for (OptionGroupModel og : model.getOptionGroups())
296            {
297                if (findInOptions(og.getOptions(), asSubmitted))
298                {
299                    return asSubmitted;
300                }
301            }
302        }
303
304        throw new ValidationException("Selected option is not listed in the model.");
305    }
306
307    private boolean findInOptions(List<OptionModel> options, Object asSubmitted)
308    {
309        if (options == null)
310        {
311            return false;
312        }
313
314        // See TAP5-2184: Sometimes the SelectModel option values are Strings even though the
315        // submitted value (decoded by the ValueEncoder) are another type (e.g., numeric). In that case,
316        // pass each OptionModel value through the ValueEncoder for a comparison.
317        boolean alsoCompareDecodedModelValue = !(asSubmitted instanceof String);
318
319        for (OptionModel om : options)
320        {
321            Object modelValue = om.getValue();
322            if (modelValue.equals(asSubmitted))
323            {
324                return true;
325            }
326
327            if (alsoCompareDecodedModelValue && (modelValue instanceof String))
328            {
329                Object decodedModelValue = encoder.toValue(modelValue.toString());
330
331                if (decodedModelValue.equals(asSubmitted))
332                {
333                    return true;
334                }
335            }
336        }
337
338        return false;
339    }
340
341    private static <T> List<T> orEmpty(List<T> list)
342    {
343        if (list == null)
344        {
345            return Collections.emptyList();
346        }
347
348        return list;
349    }
350
351    @SuppressWarnings("unchecked")
352    ValueEncoder defaultEncoder()
353    {
354        return defaultProvider.defaultValueEncoder("value", resources);
355    }
356
357    @SuppressWarnings("unchecked")
358    SelectModel defaultModel()
359    {
360        Class valueType = resources.getBoundType("value");
361
362        if (valueType == null)
363            return null;
364
365        if (Enum.class.isAssignableFrom(valueType))
366            return new EnumSelectModel(valueType, resources.getContainerMessages());
367
368        return null;
369    }
370
371    /**
372     * Computes a default value for the "validate" parameter using {@link FieldValidatorDefaultSource}.
373     */
374    Binding defaultValidate()
375    {
376        return defaultProvider.defaultValidatorBinding("value", resources);
377    }
378
379    Object defaultBlankLabel()
380    {
381        Messages containerMessages = resources.getContainerMessages();
382
383        String key = resources.getId() + "-blanklabel";
384
385        if (containerMessages.contains(key))
386            return containerMessages.get(key);
387
388        return null;
389    }
390
391    /**
392     * Renders the options, including the blank option.
393     */
394    @BeforeRenderTemplate
395    void options(MarkupWriter writer)
396    {
397        selectedClientValue = tracker.getInput(this);
398
399        // Use the value passed up in the form submission, if available.
400        // Failing that, see if there is a current value (via the value parameter), and
401        // convert that to a client value for later comparison.
402
403        if (selectedClientValue == null)
404            selectedClientValue = value == null ? null : encoder.toClient(value);
405
406        if (showBlankOption())
407        {
408            writer.element("option", "value", "");
409            writer.write(blankLabel);
410            writer.end();
411        }
412
413        SelectModelVisitor renderer = new Renderer(writer);
414
415        model.visit(renderer);
416    }
417
418    @Override
419    public boolean isRequired()
420    {
421        return validate.isRequired();
422    }
423
424    private boolean showBlankOption()
425    {
426        switch (blankOption)
427        {
428            case ALWAYS:
429                return true;
430
431            case NEVER:
432                return false;
433
434            default:
435                return !isRequired();
436        }
437    }
438
439    // For testing.
440
441    void setModel(SelectModel model)
442    {
443        this.model = model;
444        blankOption = BlankOption.NEVER;
445    }
446
447    void setValue(Object value)
448    {
449        this.value = value;
450    }
451
452    void setValueEncoder(ValueEncoder encoder)
453    {
454        this.encoder = encoder;
455    }
456
457    void setValidationTracker(ValidationTracker tracker)
458    {
459        this.tracker = tracker;
460    }
461
462    void setBlankOption(BlankOption option, String label)
463    {
464        blankOption = option;
465        blankLabel = label;
466    }
467}