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