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