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