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