    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 &lt;select&gt; element on the client side. Any validation
    036 * decorations will go around the entire &lt;select&gt; element.
    037 *
    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}