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