001 // Copyright 2006, 2007, 2008, 2009, 2010, 2011 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 015 package org.apache.tapestry5.corelib.components; 016 017 import org.apache.tapestry5.*; 018 import org.apache.tapestry5.annotations.*; 019 import org.apache.tapestry5.corelib.ClientValidation; 020 import org.apache.tapestry5.corelib.internal.ComponentActionSink; 021 import org.apache.tapestry5.corelib.internal.FormSupportImpl; 022 import org.apache.tapestry5.corelib.internal.InternalFormSupport; 023 import org.apache.tapestry5.dom.Element; 024 import org.apache.tapestry5.internal.*; 025 import org.apache.tapestry5.internal.services.HeartbeatImpl; 026 import org.apache.tapestry5.internal.util.AutofocusValidationDecorator; 027 import org.apache.tapestry5.ioc.Location; 028 import org.apache.tapestry5.ioc.Messages; 029 import org.apache.tapestry5.ioc.annotations.Inject; 030 import org.apache.tapestry5.ioc.annotations.Symbol; 031 import org.apache.tapestry5.ioc.internal.util.InternalUtils; 032 import org.apache.tapestry5.ioc.internal.util.TapestryException; 033 import org.apache.tapestry5.ioc.services.PropertyAccess; 034 import org.apache.tapestry5.ioc.util.ExceptionUtils; 035 import org.apache.tapestry5.ioc.util.IdAllocator; 036 import org.apache.tapestry5.json.JSONArray; 037 import org.apache.tapestry5.json.JSONObject; 038 import org.apache.tapestry5.runtime.Component; 039 import org.apache.tapestry5.services.*; 040 import org.apache.tapestry5.services.javascript.InitializationPriority; 041 import org.apache.tapestry5.services.javascript.JavaScriptSupport; 042 import org.slf4j.Logger; 043 044 import java.io.EOFException; 045 import java.io.IOException; 046 import java.io.ObjectInputStream; 047 048 /** 049 * An HTML form, which will enclose other components to render out the various 050 * types of fields. 051 * <p> 052 * A Form triggers many notification events. When it renders, it triggers a 053 * {@link org.apache.tapestry5.EventConstants#PREPARE_FOR_RENDER} notification, followed by a 054 * {@link EventConstants#PREPARE} notification.</p> 055 * <p> 056 * When the form is submitted, the component triggers several notifications: first a 057 * {@link EventConstants#PREPARE_FOR_SUBMIT}, then a {@link EventConstants#PREPARE}: these allow the page to update its 058 * state as necessary to prepare for the form submission.</p> 059 * <p> 060 * The Form component then determines if the form was cancelled (see {@link org.apache.tapestry5.corelib.SubmitMode#CANCEL}). If so, 061 * a {@link EventConstants#CANCELED} event is triggered.</p> 062 * <p> 063 * Next come notifications to contained components (or more accurately, the execution of stored {@link ComponentAction}s), to allow each component to retrieve and validate 064 * submitted values, and update server-side properties. This is based on the {@code t:formdata} query parameter, 065 * which contains serialized object data (generated when the form initially renders). 066 * </p> 067 * <p>Once the form data is processed, the next step is to trigger the 068 * {@link EventConstants#VALIDATE}, which allows for cross-form validation. After that, either a 069 * {@link EventConstants#SUCCESS} OR {@link EventConstants#FAILURE} event (depending on whether the 070 * {@link ValidationTracker} has recorded any errors). Lastly, a {@link EventConstants#SUBMIT} event, for any listeners 071 * that care only about form submission, regardless of success or failure.</p> 072 * <p> 073 * For all of these notifications, the event context is derived from the <strong>context</strong> component parameter. This 074 * context is encoded into the form's action URI (the parameter is not read when the form is submitted, instead the 075 * values encoded into the form are used). 076 * </p> 077 * <p> 078 * While rendering, or processing a Form submission, the Form component places a {@link FormSupport} object into the {@linkplain Environment environment}, 079 * so that enclosed components can coordinate with the Form component. 080 * </p> 081 * 082 * @tapestrydoc 083 * @see BeanEditForm 084 * @see Errors 085 * @see FormFragment 086 * @see Label 087 */ 088 @Events( 089 {EventConstants.PREPARE_FOR_RENDER, EventConstants.PREPARE, EventConstants.PREPARE_FOR_SUBMIT, 090 EventConstants.VALIDATE, EventConstants.SUBMIT, EventConstants.FAILURE, EventConstants.SUCCESS, EventConstants.CANCELED}) 091 @SupportsInformalParameters 092 public class Form implements ClientElement, FormValidationControl 093 { 094 /** 095 * Query parameter name storing form data (the serialized commands needed to 096 * process a form submission). 097 */ 098 public static final String FORM_DATA = "t:formdata"; 099 100 /** 101 * Used by {@link Submit}, etc., to identify which particular client-side element (by element id) 102 * was responsible for the submission. An empty hidden field is created, as needed, to store this value. 103 * Starting in Tapestry 5.3, this is a JSONArray with two values: the client id followed by the client name. 104 * 105 * @since 5.2.0 106 */ 107 public static final String SUBMITTING_ELEMENT_ID = "t:submit"; 108 109 /** 110 * The context for the link (optional parameter). This list of values will 111 * be converted into strings and included in 112 * the URI. The strings will be coerced back to whatever their values are 113 * and made available to event handler 114 * methods. 115 */ 116 @Parameter 117 private Object[] context; 118 119 /** 120 * The object which will record user input and validation errors. The object 121 * must be persistent between requests 122 * (since the form submission and validation occurs in a component event 123 * request and the subsequent render occurs 124 * in a render request). The default is a persistent property of the Form 125 * component and this is sufficient for 126 * nearly all purposes (except when a Form is rendered inside a loop). 127 */ 128 @Parameter("defaultTracker") 129 private ValidationTracker tracker; 130 131 @Inject 132 @Symbol(SymbolConstants.FORM_CLIENT_LOGIC_ENABLED) 133 private boolean clientLogicDefaultEnabled; 134 135 /** 136 * Controls when client validation occurs on the client, if at all. Defaults to {@link ClientValidation#BLUR}. 137 */ 138 @Parameter(allowNull = false, defaultPrefix = BindingConstants.LITERAL) 139 private ClientValidation clientValidation = clientLogicDefaultEnabled ? ClientValidation.BLUR 140 : ClientValidation.NONE; 141 142 /** 143 * If true (the default), then the JavaScript will be added to position the 144 * cursor into the form. The field to 145 * receive focus is the first rendered field that is in error, or required, 146 * or present (in that order of priority). 147 * 148 * @see SymbolConstants#FORM_CLIENT_LOGIC_ENABLED 149 */ 150 @Parameter 151 private boolean autofocus = clientLogicDefaultEnabled; 152 153 /** 154 * Binding the zone parameter will cause the form submission to be handled 155 * as an Ajax request that updates the 156 * indicated zone. Often a Form will update the same zone that contains it. 157 */ 158 @Parameter(defaultPrefix = BindingConstants.LITERAL) 159 private String zone; 160 161 /** 162 * If true, then the Form's action will be secure (using an absolute URL with the HTTPs scheme) regardless 163 * of whether the containing page itself is secure or not. This parameter does nothing 164 * when {@linkplain SymbolConstants#SECURE_ENABLED security is disabled} (which is often 165 * the case in development mode). This only affects how the Form's action attribute is rendered, there is 166 * not (currently) a check that the form is actually submitted securely. 167 */ 168 @Parameter 169 private boolean secure; 170 171 /** 172 * Prefix value used when searching for validation messages and constraints. 173 * The default is the Form component's 174 * id. This is overridden by {@link org.apache.tapestry5.corelib.components.BeanEditForm}. 175 * 176 * @see org.apache.tapestry5.services.FormSupport#getFormValidationId() 177 */ 178 @Parameter 179 private String validationId; 180 181 /** 182 * Object to validate during the form submission process. The default is the Form component's container. 183 * This parameter should only be used in combination with the Bean Validation Library. 184 */ 185 @Parameter 186 private Object validate; 187 188 @Inject 189 private Logger logger; 190 191 @Inject 192 private Environment environment; 193 194 @Inject 195 private ComponentResources resources; 196 197 @Inject 198 private Messages messages; 199 200 @Environmental 201 private JavaScriptSupport javascriptSupport; 202 203 @Environmental 204 private JavaScriptSupport jsSupport; 205 206 @Inject 207 private Request request; 208 209 @Inject 210 private ComponentSource source; 211 212 @Inject 213 @Symbol(InternalSymbols.PRE_SELECTED_FORM_NAMES) 214 private String preselectedFormNames; 215 216 @Persist(PersistenceConstants.FLASH) 217 private ValidationTracker defaultTracker; 218 219 @Inject 220 @Symbol(SymbolConstants.SECURE_ENABLED) 221 private boolean secureEnabled; 222 223 private InternalFormSupport formSupport; 224 225 private Element form; 226 227 private Element div; 228 229 // Collects a stream of component actions. Each action goes in as a UTF 230 // string (the component 231 // component id), followed by a ComponentAction 232 233 private ComponentActionSink actionSink; 234 235 @Environmental 236 private ClientBehaviorSupport clientBehaviorSupport; 237 238 @SuppressWarnings("unchecked") 239 @Environmental 240 private TrackableComponentEventCallback eventCallback; 241 242 @Inject 243 private ClientDataEncoder clientDataEncoder; 244 245 @Inject 246 private PropertyAccess propertyAccess; 247 248 private String clientId; 249 250 // Set during rendering or submit processing to be the 251 // same as the VT pushed into the Environment 252 private ValidationTracker activeTracker; 253 254 String defaultValidationId() 255 { 256 return resources.getId(); 257 } 258 259 Object defaultValidate() 260 { 261 return resources.getContainer(); 262 } 263 264 /** 265 * Returns a wrapped version of the tracker parameter (which is usually bound to the 266 * defaultTracker persistent field). 267 * If tracker is currently null, a new instance of {@link ValidationTrackerImpl} is created. 268 * The tracker is then wrapped, such that the tracker parameter 269 * is only updated the first time an error is recorded into the tracker (this will typically 270 * propagate to the defaultTracker 271 * persistent field and be stored into the session). This means that if no errors are recorded, 272 * the tracker parameter is not updated and (in the default case) no data is stored into the 273 * session. 274 * 275 * @return a tracker ready to receive data (possibly a previously stored tracker with field 276 * input and errors) 277 * @see <a href="https://issues.apache.org/jira/browse/TAP5-979">TAP5-979</a> 278 */ 279 private ValidationTracker getWrappedTracker() 280 { 281 ValidationTracker innerTracker = tracker == null ? new ValidationTrackerImpl() : tracker; 282 283 ValidationTracker wrapper = new ValidationTrackerWrapper(innerTracker) 284 { 285 private boolean saved = false; 286 287 private void save() 288 { 289 if (!saved) 290 { 291 tracker = getDelegate(); 292 293 saved = true; 294 } 295 } 296 297 @Override 298 public void recordError(Field field, String errorMessage) 299 { 300 super.recordError(field, errorMessage); 301 302 save(); 303 } 304 305 @Override 306 public void recordError(String errorMessage) 307 { 308 super.recordError(errorMessage); 309 310 save(); 311 } 312 }; 313 314 return wrapper; 315 } 316 317 public ValidationTracker getDefaultTracker() 318 { 319 return defaultTracker; 320 } 321 322 public void setDefaultTracker(ValidationTracker defaultTracker) 323 { 324 this.defaultTracker = defaultTracker; 325 } 326 327 void setupRender() 328 { 329 FormSupport existing = environment.peek(FormSupport.class); 330 331 if (existing != null) 332 throw new TapestryException(messages.get("nesting-not-allowed"), existing, null); 333 } 334 335 void beginRender(MarkupWriter writer) 336 { 337 Link link = resources.createFormEventLink(EventConstants.ACTION, context); 338 339 String actionURL = secure && secureEnabled ? link.toAbsoluteURI(true) : link.toURI(); 340 341 actionSink = new ComponentActionSink(logger, clientDataEncoder); 342 343 clientId = javascriptSupport.allocateClientId(resources); 344 345 // Pre-register some names, to prevent client-side collisions with function names 346 // attached to the JS Form object. 347 348 IdAllocator allocator = new IdAllocator(); 349 350 preallocateNames(allocator); 351 352 formSupport = createRenderTimeFormSupport(clientId, actionSink, allocator); 353 354 addJavaScriptInitialization(); 355 356 if (zone != null) 357 linkFormToZone(link); 358 359 activeTracker = getWrappedTracker(); 360 361 environment.push(FormSupport.class, formSupport); 362 environment.push(ValidationTracker.class, activeTracker); 363 364 if (autofocus) 365 { 366 ValidationDecorator autofocusDecorator = new AutofocusValidationDecorator( 367 environment.peek(ValidationDecorator.class), activeTracker, jsSupport); 368 environment.push(ValidationDecorator.class, autofocusDecorator); 369 } 370 371 // Now that the environment is setup, inform the component or other 372 // listeners that the form 373 // is about to render. 374 375 resources.triggerEvent(EventConstants.PREPARE_FOR_RENDER, context, null); 376 377 resources.triggerEvent(EventConstants.PREPARE, context, null); 378 379 // Push BeanValidationContext only after the container had a chance to prepare 380 environment.push(BeanValidationContext.class, new BeanValidationContextImpl(validate)); 381 382 // Save the form element for later, in case we want to write an encoding 383 // type attribute. 384 385 form = writer.element("form", "id", clientId, "method", "post", "action", actionURL); 386 387 if ((zone != null || clientValidation != ClientValidation.NONE) && !request.isXHR()) 388 writer.attributes("onsubmit", MarkupConstants.WAIT_FOR_PAGE); 389 390 resources.renderInformalParameters(writer); 391 392 div = writer.element("div", "class", CSSClassConstants.INVISIBLE); 393 394 for (String parameterName : link.getParameterNames()) 395 { 396 String value = link.getParameterValue(parameterName); 397 398 writer.element("input", "type", "hidden", "name", parameterName, "value", value); 399 writer.end(); 400 } 401 402 writer.end(); // div 403 404 environment.peek(Heartbeat.class).begin(); 405 } 406 407 private void addJavaScriptInitialization() 408 { 409 JSONObject validateSpec = new JSONObject().put("blur", clientValidation == ClientValidation.BLUR).put("submit", 410 clientValidation != ClientValidation.NONE); 411 412 JSONObject spec = new JSONObject("formId", clientId).put("validate", validateSpec); 413 414 javascriptSupport.addInitializerCall(InitializationPriority.EARLY, "formEventManager", spec); 415 } 416 417 @HeartbeatDeferred 418 private void linkFormToZone(Link link) 419 { 420 clientBehaviorSupport.linkZone(clientId, zone, link); 421 } 422 423 /** 424 * Creates an {@link org.apache.tapestry5.corelib.internal.InternalFormSupport} for 425 * this Form. This method is used 426 * by {@link org.apache.tapestry5.corelib.components.FormInjector}. 427 * <p/> 428 * This method may also be invoked as the handler for the "internalCreateRenderTimeFormSupport" event. 429 * 430 * @param clientId the client-side id for the rendered form 431 * element 432 * @param actionSink used to collect component actions that will, ultimately, be 433 * written as the t:formdata hidden 434 * field 435 * @param allocator used to allocate unique ids 436 * @return form support object 437 */ 438 @OnEvent("internalCreateRenderTimeFormSupport") 439 InternalFormSupport createRenderTimeFormSupport(String clientId, ComponentActionSink actionSink, 440 IdAllocator allocator) 441 { 442 return new FormSupportImpl(resources, clientId, actionSink, clientBehaviorSupport, 443 clientValidation != ClientValidation.NONE, allocator, validationId); 444 } 445 446 void afterRender(MarkupWriter writer) 447 { 448 environment.peek(Heartbeat.class).end(); 449 450 formSupport.executeDeferred(); 451 452 String encodingType = formSupport.getEncodingType(); 453 454 if (encodingType != null) 455 form.forceAttributes("enctype", encodingType); 456 457 writer.end(); // form 458 459 div.element("input", "type", "hidden", "name", FORM_DATA, "value", actionSink.getClientData()); 460 461 if (autofocus) 462 environment.pop(ValidationDecorator.class); 463 } 464 465 void cleanupRender() 466 { 467 environment.pop(FormSupport.class); 468 469 formSupport = null; 470 471 environment.pop(ValidationTracker.class); 472 473 activeTracker = null; 474 475 environment.pop(BeanValidationContext.class); 476 } 477 478 @SuppressWarnings( 479 {"unchecked", "InfiniteLoopStatement"}) 480 @Log 481 Object onAction(EventContext context) throws IOException 482 { 483 activeTracker = getWrappedTracker(); 484 485 activeTracker.clear(); 486 487 formSupport = new FormSupportImpl(resources, validationId); 488 489 environment.push(ValidationTracker.class, activeTracker); 490 environment.push(FormSupport.class, formSupport); 491 492 Heartbeat heartbeat = new HeartbeatImpl(); 493 494 environment.push(Heartbeat.class, heartbeat); 495 496 heartbeat.begin(); 497 498 boolean didPushBeanValidationContext = false; 499 500 try 501 { 502 resources.triggerContextEvent(EventConstants.PREPARE_FOR_SUBMIT, context, eventCallback); 503 504 if (eventCallback.isAborted()) 505 return true; 506 507 resources.triggerContextEvent(EventConstants.PREPARE, context, eventCallback); 508 if (eventCallback.isAborted()) 509 return true; 510 511 if (isFormCancelled()) 512 { 513 resources.triggerContextEvent(EventConstants.CANCELED, context, eventCallback); 514 if (eventCallback.isAborted()) 515 return true; 516 } 517 518 environment.push(BeanValidationContext.class, new BeanValidationContextImpl(validate)); 519 520 didPushBeanValidationContext = true; 521 522 executeStoredActions(); 523 524 heartbeat.end(); 525 526 formSupport.executeDeferred(); 527 528 fireValidateEvent(EventConstants.VALIDATE, context, eventCallback); 529 530 if (eventCallback.isAborted()) 531 return true; 532 533 // Let the listeners know about overall success or failure. Most 534 // listeners fall into 535 // one of those two camps. 536 537 // If the tracker has no errors, then clear it of any input values 538 // as well, so that the next page render will be "clean" and show 539 // true persistent data, not value from the previous form 540 // submission. 541 542 if (!activeTracker.getHasErrors()) 543 activeTracker.clear(); 544 545 resources.triggerContextEvent(activeTracker.getHasErrors() ? EventConstants.FAILURE 546 : EventConstants.SUCCESS, context, eventCallback); 547 548 // Lastly, tell anyone whose interested that the form is completely 549 // submitted. 550 551 if (eventCallback.isAborted()) 552 return true; 553 554 resources.triggerContextEvent(EventConstants.SUBMIT, context, eventCallback); 555 556 return eventCallback.isAborted(); 557 } finally 558 { 559 environment.pop(Heartbeat.class); 560 environment.pop(FormSupport.class); 561 562 environment.pop(ValidationTracker.class); 563 564 if (didPushBeanValidationContext) 565 { 566 environment.pop(BeanValidationContext.class); 567 } 568 569 activeTracker = null; 570 } 571 } 572 573 private boolean isFormCancelled() 574 { 575 // The "cancel" query parameter is reserved for this purpose; if it is present then the form was canceled on the 576 // client side. For image submits, there will be two parameters: "cancel.x" and "cancel.y". 577 578 if (request.getParameter(InternalConstants.CANCEL_NAME) != null || 579 request.getParameter(InternalConstants.CANCEL_NAME + ".x") != null) 580 { 581 return true; 582 } 583 584 // When JavaScript is involved, it's more complicated. In fact, this is part of HLS's desire 585 // to have all forms submit via XHR when JavaScript is present, since it would provide 586 // an opportunity to get the submitting element's value into the request properly. 587 588 String raw = request.getParameter(SUBMITTING_ELEMENT_ID); 589 590 if (raw != null && 591 new JSONArray(raw).getString(1).equals(InternalConstants.CANCEL_NAME)) 592 { 593 return true; 594 } 595 596 return false; 597 } 598 599 600 private void fireValidateEvent(String eventName, EventContext context, TrackableComponentEventCallback callback) 601 { 602 try 603 { 604 resources.triggerContextEvent(eventName, context, callback); 605 } catch (RuntimeException ex) 606 { 607 ValidationException ve = ExceptionUtils.findCause(ex, ValidationException.class, propertyAccess); 608 609 if (ve != null) 610 { 611 ValidationTracker tracker = environment.peek(ValidationTracker.class); 612 613 tracker.recordError(ve.getMessage()); 614 615 return; 616 } 617 618 throw ex; 619 } 620 } 621 622 /** 623 * Pulls the stored actions out of the request, converts them from MIME 624 * stream back to object stream and then 625 * objects, and executes them. 626 */ 627 private void executeStoredActions() 628 { 629 String[] values = request.getParameters(FORM_DATA); 630 631 if (!request.getMethod().equals("POST") || values == null) 632 throw new RuntimeException(messages.format("invalid-request", FORM_DATA)); 633 634 // Due to Ajax (FormInjector) there may be multiple values here, so 635 // handle each one individually. 636 637 for (String clientEncodedActions : values) 638 { 639 if (InternalUtils.isBlank(clientEncodedActions)) 640 continue; 641 642 logger.debug("Processing actions: {}", clientEncodedActions); 643 644 ObjectInputStream ois = null; 645 646 Component component = null; 647 648 try 649 { 650 ois = clientDataEncoder.decodeClientData(clientEncodedActions); 651 652 while (!eventCallback.isAborted()) 653 { 654 String componentId = ois.readUTF(); 655 ComponentAction action = (ComponentAction) ois.readObject(); 656 657 component = source.getComponent(componentId); 658 659 logger.debug("Processing: {} {}", componentId, action); 660 661 action.execute(component); 662 663 component = null; 664 } 665 } catch (EOFException ex) 666 { 667 // Expected 668 } catch (Exception ex) 669 { 670 Location location = component == null ? null : component.getComponentResources().getLocation(); 671 672 throw new TapestryException(ex.getMessage(), location, ex); 673 } finally 674 { 675 InternalUtils.close(ois); 676 } 677 } 678 } 679 680 public void recordError(String errorMessage) 681 { 682 getActiveTracker().recordError(errorMessage); 683 } 684 685 public void recordError(Field field, String errorMessage) 686 { 687 getActiveTracker().recordError(field, errorMessage); 688 } 689 690 public boolean getHasErrors() 691 { 692 return getActiveTracker().getHasErrors(); 693 } 694 695 public boolean isValid() 696 { 697 return !getActiveTracker().getHasErrors(); 698 } 699 700 private ValidationTracker getActiveTracker() 701 { 702 return activeTracker != null ? activeTracker : getWrappedTracker(); 703 } 704 705 public void clearErrors() 706 { 707 getActiveTracker().clear(); 708 } 709 710 // For testing: 711 712 void setTracker(ValidationTracker tracker) 713 { 714 this.tracker = tracker; 715 } 716 717 /** 718 * Forms use the same value for their name and their id attribute. 719 */ 720 public String getClientId() 721 { 722 return clientId; 723 } 724 725 @Inject 726 private ComponentSource componentSource; 727 728 private void preallocateNames(IdAllocator idAllocator) 729 { 730 for (String name : TapestryInternalUtils.splitAtCommas(preselectedFormNames)) 731 { 732 idAllocator.allocateId(name); 733 // See https://issues.apache.org/jira/browse/TAP5-1632 734 javascriptSupport.allocateClientId(name); 735 736 } 737 738 Component activePage = componentSource.getActivePage(); 739 740 // This is unlikely but may be possible if people override some of the standard 741 // exception reporting logic. 742 743 if (activePage == null) 744 return; 745 746 ComponentResources activePageResources = activePage.getComponentResources(); 747 748 try 749 { 750 751 activePageResources.triggerEvent(EventConstants.PREALLOCATE_FORM_CONTROL_NAMES, new Object[] 752 {idAllocator}, null); 753 } catch (RuntimeException ex) 754 { 755 logger.error( 756 String.format("Unable to obtrain form control names to preallocate: %s", 757 InternalUtils.toMessage(ex)), ex); 758 } 759 } 760 }