blob: 39451f782f3559ef2e9243cc85a26e0ca78de5c1 [file] [log] [blame]
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package org.apache.tapestry5.corelib.components;
import org.apache.tapestry5.*;
import org.apache.tapestry5.annotations.*;
import org.apache.tapestry5.corelib.ClientValidation;
import org.apache.tapestry5.corelib.internal.ComponentActionSink;
import org.apache.tapestry5.corelib.internal.FormSupportImpl;
import org.apache.tapestry5.corelib.internal.InternalFormSupport;
import org.apache.tapestry5.dom.Element;
import org.apache.tapestry5.internal.BeanValidationContext;
import org.apache.tapestry5.internal.BeanValidationContextImpl;
import org.apache.tapestry5.internal.InternalConstants;
import org.apache.tapestry5.internal.services.FormControlNameManager;
import org.apache.tapestry5.internal.services.HeartbeatImpl;
import org.apache.tapestry5.internal.util.AutofocusValidationDecorator;
import org.apache.tapestry5.ioc.Location;
import org.apache.tapestry5.ioc.Messages;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.ioc.annotations.Symbol;
import org.apache.tapestry5.ioc.internal.util.InternalUtils;
import org.apache.tapestry5.ioc.internal.util.TapestryException;
import org.apache.tapestry5.ioc.services.PropertyAccess;
import org.apache.tapestry5.ioc.util.ExceptionUtils;
import org.apache.tapestry5.ioc.util.IdAllocator;
import org.apache.tapestry5.json.JSONArray;
import org.apache.tapestry5.runtime.Component;
import org.apache.tapestry5.services.*;
import org.apache.tapestry5.services.compatibility.DeprecationWarning;
import org.apache.tapestry5.services.javascript.JavaScriptSupport;
import org.slf4j.Logger;
import java.io.EOFException;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
/**
* An HTML form, which will enclose other components to render out the various
* types of fields.
*
* A Form triggers many notification events. When it renders, it triggers a
* {@link org.apache.tapestry5.EventConstants#PREPARE_FOR_RENDER} notification, followed by a
* {@link EventConstants#PREPARE} notification.
*
* When the form is submitted, the component triggers several notifications: first a
* {@link EventConstants#PREPARE_FOR_SUBMIT}, then a {@link EventConstants#PREPARE}: these allow the page to update its
* state as necessary to prepare for the form submission.
*
* The Form component then determines if the form was cancelled (see {@link org.apache.tapestry5.corelib.SubmitMode#CANCEL}). If so,
* a {@link EventConstants#CANCELED} event is triggered.
*
* Next come notifications to contained components (or more accurately, the execution of stored {@link ComponentAction}s), to allow each component to retrieve and validate
* submitted values, and update server-side properties. This is based on the {@code t:formdata} query parameter,
* which contains serialized object data (generated when the form initially renders).
*
* Once the form data is processed, the next step is to trigger the
* {@link EventConstants#VALIDATE}, which allows for cross-form validation. After that, either a
* {@link EventConstants#SUCCESS} OR {@link EventConstants#FAILURE} event (depending on whether the
* {@link ValidationTracker} has recorded any errors). Lastly, a {@link EventConstants#SUBMIT} event, for any listeners
* that care only about form submission, regardless of success or failure.
*
* For all of these notifications, the event context is derived from the <strong>context</strong> component parameter. This
* context is encoded into the form's action URI (the parameter is not read when the form is submitted, instead the
* values encoded into the form are used).
*
*
* While rendering, or processing a Form submission, the Form component places a {@link FormSupport} object into the {@linkplain Environment environment},
* so that enclosed components can coordinate with the Form component. It also places a {@link ValidationTracker} into the environment during both render and submission.
* During submission it also pushes a {@link Heartbeat} into the environment, which is {@link org.apache.tapestry5.services.Heartbeat#end() ended} just before
* {@linkplain FormSupport#defer(Runnable) deferred FormSupport operations} are executed.
*
*
* @tapestrydoc
* @see BeanEditForm
* @see Errors
* @see FormFragment
* @see Label
*/
@Events(
{EventConstants.PREPARE_FOR_RENDER, EventConstants.PREPARE, EventConstants.PREPARE_FOR_SUBMIT,
EventConstants.VALIDATE, EventConstants.SUBMIT, EventConstants.FAILURE, EventConstants.SUCCESS, EventConstants.CANCELED})
@SupportsInformalParameters
public class Form implements ClientElement, FormValidationControl
{
/**
* Query parameter name storing form data (the serialized commands needed to
* process a form submission).
*/
public static final String FORM_DATA = "t:formdata";
/**
* Used by {@link Submit}, etc., to identify which particular client-side element (by element id)
* was responsible for the submission. An empty hidden field is created, as needed, to store this value.
* Starting in Tapestry 5.3, this is a JSONArray with two values: the client id followed by the client name.
*
* @since 5.2.0
*/
public static final String SUBMITTING_ELEMENT_ID = "t:submit";
public static final StreamPageContent STREAM_ACTIVE_PAGE_CONTENT = new StreamPageContent().withoutActivation();
/**
* The context for the link (optional parameter). This list of values will
* be converted into strings and included in
* the URI. The strings will be coerced back to whatever their values are
* and made available to event handler
* methods.
*/
@Parameter
private Object[] context;
/**
* The object which will record user input and validation errors. When not using
* the default behavior supplied by the Form component (an immediate re-render of the active
* page when there are form validation errors), it is necessary to bind this parameter
* to a persistent value that can be maintained until the active page is re-rendered. See
* <a href="https://issues.apache.org/jira/browse/TAP5-1808">TAP5-1801</a>.
*/
@Parameter("defaultTracker")
protected ValidationTracker tracker;
@Inject
@Symbol(SymbolConstants.FORM_CLIENT_LOGIC_ENABLED)
private boolean clientLogicDefaultEnabled;
/**
* Controls when client validation occurs on the client, if at all. Defaults to {@link ClientValidation#SUBMIT}.
* {@link ClientValidation#BLUR} was the default, prior to Tapestry 5.4, but is no longer supported.
*/
@Parameter(allowNull = false, defaultPrefix = BindingConstants.LITERAL)
private ClientValidation clientValidation = clientLogicDefaultEnabled ? ClientValidation.SUBMIT
: ClientValidation.NONE;
/**
* If true (the default), then the JavaScript will be added to position the
* cursor into the form. The field to
* receive focus is the first rendered field that is in error, or required,
* or present (in that order of priority).
*
* @see SymbolConstants#FORM_CLIENT_LOGIC_ENABLED
*/
@Parameter
private boolean autofocus = clientLogicDefaultEnabled;
/**
* Binding the zone parameter will cause the form submission to be handled
* as an Ajax request that updates the
* indicated zone. Often a Form will update the same zone that contains it.
*/
@Parameter(defaultPrefix = BindingConstants.LITERAL)
private String zone;
/**
* If true, then the Form's action will be secure (using an absolute URL with the HTTPs scheme) regardless
* of whether the containing page itself is secure or not. This parameter does nothing
* when {@linkplain SymbolConstants#SECURE_ENABLED security is disabled} (which is often
* the case in development mode). This only affects how the Form's action attribute is rendered, there is
* not (currently) a check that the form is actually submitted securely.
*/
@Parameter
private boolean secure;
/**
* Prefix value used when searching for validation messages and constraints.
* The default is the Form component's
* id. This is overridden by {@link org.apache.tapestry5.corelib.components.BeanEditForm}.
*
* @see org.apache.tapestry5.services.FormSupport#getFormValidationId()
*/
@Parameter
private String validationId;
/**
* Object to validate during the form submission process. The default is the Form component's container.
* This parameter should only be used in combination with the Bean Validation Library.
*/
@Parameter
private Object validate;
/**
* When true, the the form will submit as an asynchronous request (via XmlHttpRequest); the event handler methods
* can make use of the {@link org.apache.tapestry5.services.ajax.AjaxResponseRenderer} in order to force content
* updates to the client. This is used as an alternative to placing the form inside a {@link org.apache.tapestry5.corelib.components.Zone}
* and binding the {@code zone} parameter.
*
* @since 5.4
*/
@Parameter
private boolean async = false;
@Inject
private Logger logger;
@Inject
private Environment environment;
@Inject
private ComponentResources resources;
@Inject
private Messages messages;
@Environmental
private JavaScriptSupport javascriptSupport;
@Inject
private Request request;
@Inject
private ComponentSource source;
@Inject
private FormControlNameManager formControlNameManager;
/**
* Starting in 5.4, this is a simple, non-persistent property, with no extra magic tricks.
*/
private ValidationTracker defaultTracker;
@Inject
@Symbol(SymbolConstants.SECURE_ENABLED)
private boolean secureEnabled;
private InternalFormSupport formSupport;
private Element form;
private Element div;
// Collects a stream of component actions. Each action goes in as a UTF
// string (the component
// component id), followed by a ComponentAction
private ComponentActionSink actionSink;
@SuppressWarnings("unchecked")
@Environmental
private TrackableComponentEventCallback eventCallback;
@Inject
private ClientDataEncoder clientDataEncoder;
@Inject
private PropertyAccess propertyAccess;
@Inject
private DeprecationWarning deprecationWarning;
private String clientId;
@Inject
private ComponentSource componentSource;
String defaultValidationId()
{
return resources.getId();
}
Object defaultValidate()
{
return resources.getContainer();
}
/**
* Returns an instance of {@link ValidationTrackerImpl}, lazily creating it as needed. This property
* is the default for the <strong>tracker</strong> parameter; the property (as of Tapestry 5.4) is not
* persistent.
*
* @return per-request cached instance
*/
public ValidationTracker getDefaultTracker()
{
if (defaultTracker == null)
{
defaultTracker = new ValidationTrackerImpl();
}
return defaultTracker;
}
/**
* @deprecated In 5.4; previously used only for testing
*/
public void setDefaultTracker(ValidationTracker defaultTracker)
{
this.defaultTracker = defaultTracker;
}
void setupRender()
{
FormSupport existing = environment.peek(FormSupport.class);
if (existing != null)
{
throw new TapestryException(messages.get("core-form-nesting-not-allowed"), existing, null);
}
if (clientValidation == ClientValidation.BLUR)
{
deprecationWarning.componentParameterValue(resources, "clientValidation", clientValidation, "BLUR is no longer supported, starting in 5.4. Validation will occur as with SUBMIT.");
}
}
void beginRender(MarkupWriter writer)
{
Link link = resources.createFormEventLink(EventConstants.ACTION, context);
String actionURL = secure && secureEnabled ? link.toAbsoluteURI(true) : link.toURI();
actionSink = new ComponentActionSink(logger, clientDataEncoder);
clientId = javascriptSupport.allocateClientId(resources);
// Pre-register some names, to prevent client-side collisions with function names
// attached to the JS Form object.
IdAllocator allocator = new IdAllocator();
preallocateNames(allocator);
formSupport = createRenderTimeFormSupport(clientId, actionSink, allocator);
environment.push(FormSupport.class, formSupport);
environment.push(ValidationTracker.class, tracker);
if (autofocus)
{
ValidationDecorator autofocusDecorator = new AutofocusValidationDecorator(
environment.peek(ValidationDecorator.class), tracker, javascriptSupport);
environment.push(ValidationDecorator.class, autofocusDecorator);
}
// Now that the environment is setup, inform the component or other
// listeners that the form
// is about to render.
resources.triggerEvent(EventConstants.PREPARE_FOR_RENDER, context, null);
resources.triggerEvent(EventConstants.PREPARE, context, null);
// Push BeanValidationContext only after the container had a chance to prepare
environment.push(BeanValidationContext.class, new BeanValidationContextImpl(validate));
// Save the form element for later, in case we want to write an encoding
// type attribute.
form = writer.element("form",
"id", clientId,
"method", "post",
"action", actionURL,
"data-update-zone", zone);
if (clientValidation != ClientValidation.NONE)
{
writer.attributes("data-validate", "submit");
}
if (async)
{
javascriptSupport.require("t5/core/zone");
writer.attributes("data-async-trigger", true);
}
resources.renderInformalParameters(writer);
div = writer.element("div");
for (String parameterName : link.getParameterNames())
{
String[] values = link.getParameterValues(parameterName);
for (String value : values)
{
// The parameter value is expected to be encoded,
// but the input value shouldn't be encoded.
try
{
value = URLDecoder.decode(value, "UTF-8");
} catch (UnsupportedEncodingException e)
{
logger.error("Enable to decode parameter value for parameter {} in form {}",
parameterName, form.getName(), e);
}
writer.element("input", "type", "hidden", "name", parameterName, "value", value);
writer.end();
}
}
writer.end(); // div
environment.peek(Heartbeat.class).begin();
}
/**
* Creates an {@link org.apache.tapestry5.corelib.internal.InternalFormSupport} for
* this Form.
*
* This method may also be invoked as the handler for the "internalCreateRenderTimeFormSupport" event.
*
* @param clientId
* the client-side id for the rendered form
* element
* @param actionSink
* used to collect component actions that will, ultimately, be
* written as the t:formdata hidden
* field
* @param allocator
* used to allocate unique ids
* @return form support object
*/
@OnEvent("internalCreateRenderTimeFormSupport")
InternalFormSupport createRenderTimeFormSupport(String clientId, ComponentActionSink actionSink,
IdAllocator allocator)
{
return new FormSupportImpl(resources, clientId, actionSink,
clientValidation != ClientValidation.NONE, allocator, validationId);
}
void afterRender(MarkupWriter writer)
{
environment.peek(Heartbeat.class).end();
formSupport.executeDeferred();
String encodingType = formSupport.getEncodingType();
if (encodingType != null)
{
form.forceAttributes("enctype", encodingType);
}
writer.end(); // form
div.element("input", "type", "hidden", "name", FORM_DATA, "value", actionSink.getClientData());
div.pop();
if (autofocus)
{
environment.pop(ValidationDecorator.class);
}
}
void cleanupRender()
{
environment.pop(FormSupport.class);
formSupport = null;
environment.pop(ValidationTracker.class);
tracker.clear();
environment.pop(BeanValidationContext.class);
}
@SuppressWarnings(
{"unchecked", "InfiniteLoopStatement"})
Object onAction(EventContext context) throws IOException
{
beforeProcessSubmit(context);
tracker.clear();
formSupport = new FormSupportImpl(resources, validationId);
environment.push(ValidationTracker.class, tracker);
environment.push(FormSupport.class, formSupport);
Heartbeat heartbeat = new HeartbeatImpl();
environment.push(Heartbeat.class, heartbeat);
heartbeat.begin();
boolean didPushBeanValidationContext = false;
try
{
resources.triggerContextEvent(EventConstants.PREPARE_FOR_SUBMIT, context, eventCallback);
if (eventCallback.isAborted())
return true;
resources.triggerContextEvent(EventConstants.PREPARE, context, eventCallback);
if (eventCallback.isAborted())
return true;
if (isFormCancelled())
{
executeStoredActions(true);
resources.triggerContextEvent(EventConstants.CANCELED, context, eventCallback);
if (eventCallback.isAborted())
return true;
}
environment.push(BeanValidationContext.class, new BeanValidationContextImpl(validate));
didPushBeanValidationContext = true;
executeStoredActions(false);
heartbeat.end();
formSupport.executeDeferred();
fireValidateEvent(EventConstants.VALIDATE, context, eventCallback);
if (eventCallback.isAborted())
{
return true;
}
afterValidate();
// Let the listeners know about overall success or failure. Most
// listeners fall into
// one of those two camps.
// If the tracker has no errors, then clear it of any input values
// as well, so that the next page render will be "clean" and show
// true persistent data, not value from the previous form
// submission.
if (!tracker.getHasErrors())
{
tracker.clear();
}
String eventType = tracker.getHasErrors()
? EventConstants.FAILURE
: EventConstants.SUCCESS;
resources.triggerContextEvent(eventType, context, eventCallback);
if (eventCallback.isAborted())
{
return true;
}
// Lastly, tell anyone whose interested that the form is completely
// submitted.
resources.triggerContextEvent(EventConstants.SUBMIT, context, eventCallback);
afterSuccessOrFailure();
if (eventCallback.isAborted())
{
return true;
}
// For traditional request with no validation exceptions, re-render the
// current page immediately, as-is. Prior to Tapestry 5.4, a redirect was
// sent that required that the tracker be persisted across requests.
// See https://issues.apache.org/jira/browse/TAP5-1808
if (tracker.getHasErrors() && !request.isXHR())
{
return STREAM_ACTIVE_PAGE_CONTENT;
}
// The event will not work its way up.
return false;
} finally
{
environment.pop(Heartbeat.class);
environment.pop(FormSupport.class);
environment.pop(ValidationTracker.class);
if (didPushBeanValidationContext)
{
environment.pop(BeanValidationContext.class);
}
}
}
/**
* A hook invoked from {@link #onAction(org.apache.tapestry5.EventContext)} after the
* {@link org.apache.tapestry5.EventConstants#SUBMIT} or {@link org.apache.tapestry5.EventConstants#FAILURE} event has been triggered.
*
* This method will be invoked regardless of whether the submit or failure event was aborted.
*
* This implementation does nothing.
*
* @since 5.4
*/
protected void afterSuccessOrFailure()
{
}
/**
* A hook invoked from {@link #onAction(org.apache.tapestry5.EventContext)} before any other setup.
*
* This implementation does nothing.
*
* @param context
* as passed to {@code onAction()}
* @since 5.4
*/
protected void beforeProcessSubmit(EventContext context)
{
}
/**
* A hook invoked from {@link #onAction(org.apache.tapestry5.EventContext)} after the
* {@link org.apache.tapestry5.EventConstants#VALIDATE} event has been triggered, and
* before the {@link #tracker} has been {@linkplain org.apache.tapestry5.ValidationTracker#clear() cleared}.
*
* Only invoked if the valiate event did not abort (that is, the no event handler method returned a value).
*
* This implementation does nothing.
*
* @since 5.4
*/
protected void afterValidate()
{
}
private boolean isFormCancelled()
{
// The "cancel" query parameter is reserved for this purpose; if it is present then the form was canceled on the
// client side. For image submits, there will be two parameters: "cancel.x" and "cancel.y".
if (request.getParameter(InternalConstants.CANCEL_NAME) != null ||
request.getParameter(InternalConstants.CANCEL_NAME + ".x") != null)
{
return true;
}
// When JavaScript is involved, it's more complicated. In fact, this is part of HLS's desire
// to have all forms submit via XHR when JavaScript is present, since it would provide
// an opportunity to get the submitting element's value into the request properly.
String raw = request.getParameter(SUBMITTING_ELEMENT_ID);
if (InternalUtils.isNonBlank(raw) &&
new JSONArray(raw).getString(1).equals(InternalConstants.CANCEL_NAME))
{
return true;
}
return false;
}
private void fireValidateEvent(String eventName, EventContext context, TrackableComponentEventCallback callback)
{
try
{
resources.triggerContextEvent(eventName, context, callback);
} catch (RuntimeException ex)
{
ValidationException ve = ExceptionUtils.findCause(ex, ValidationException.class, propertyAccess);
if (ve != null)
{
ValidationTracker tracker = environment.peek(ValidationTracker.class);
tracker.recordError(ve.getMessage());
return;
}
throw ex;
}
}
/**
* Pulls the stored actions out of the request, converts them from MIME
* stream back to object stream and then
* objects, and executes them.
*/
private void executeStoredActions(boolean forFormCancel)
{
String[] values = request.getParameters(FORM_DATA);
if (!request.getMethod().equals("POST") || values == null)
throw new RuntimeException(messages.format("core-invalid-form-request", FORM_DATA));
// Due to Ajax there may be multiple values here, so
// handle each one individually.
for (String clientEncodedActions : values)
{
if (InternalUtils.isBlank(clientEncodedActions))
continue;
logger.debug("Processing actions: {}", clientEncodedActions);
ObjectInputStream ois = null;
Component component = null;
try
{
ois = clientDataEncoder.decodeClientData(clientEncodedActions);
while (!eventCallback.isAborted())
{
String componentId = ois.readUTF();
boolean cancelAction = ois.readBoolean();
ComponentAction action = (ComponentAction) ois.readObject();
// Actions are a mix of ordinary actions and cancel actions. Filter out one set or the other
// based on whether the form was submitted or cancelled.
if (forFormCancel != cancelAction)
{
continue;
}
component = source.getComponent(componentId);
logger.debug("Processing: {} {}", componentId, action);
action.execute(component);
component = null;
}
} catch (EOFException ex)
{
// Expected
} catch (Exception ex)
{
Location location = component == null ? null : component.getComponentResources().getLocation();
throw new TapestryException(ex.getMessage(), location, ex);
} finally
{
InternalUtils.close(ois);
}
}
}
public void recordError(String errorMessage)
{
tracker.recordError(errorMessage);
}
public void recordError(Field field, String errorMessage)
{
tracker.recordError(field, errorMessage);
}
public boolean getHasErrors()
{
return tracker.getHasErrors();
}
public boolean isValid()
{
return !tracker.getHasErrors();
}
public void clearErrors()
{
tracker.clear();
}
// For testing:
void setTracker(ValidationTracker tracker)
{
this.tracker = tracker;
}
/**
* Forms use the same value for their name and their id attribute.
*/
public String getClientId()
{
return clientId;
}
private void preallocateNames(IdAllocator idAllocator)
{
for (String name : formControlNameManager.getReservedNames())
{
idAllocator.allocateId(name);
// See https://issues.apache.org/jira/browse/TAP5-1632
javascriptSupport.allocateClientId(name);
}
Component activePage = componentSource.getActivePage();
// This is unlikely but may be possible if people override some of the standard
// exception reporting logic.
if (activePage == null)
return;
ComponentResources activePageResources = activePage.getComponentResources();
try
{
activePageResources.triggerEvent(EventConstants.PREALLOCATE_FORM_CONTROL_NAMES, new Object[]
{idAllocator}, null);
} catch (RuntimeException ex)
{
logger.error(
String.format("Unable to obtain form control names to preallocate: %s",
ExceptionUtils.toMessage(ex)), ex);
}
}
}