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