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