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