Ajax Forms in a Loop

To demonstrate a server-side error, change any First Name to Acme.
Highlight zone updates? This shows you which zones are updated by the Ajax response.
Note that it upsets our hover highlighting due to TAP5-2014.

Id First Name Last Name Region Start Date Action
1 sdfsadsad sdfsdffsad West Coast 5/26/13
4 test Bandaa East Coast 2/29/08
2 Test kumar West Coast 2/21/13
3 toto Momma East Coast 2/11/07
5 xwang Spoon12 West Coast 2/29/08
Here's the sequence when you Edit, Save, or Cancel: Notes: References: Loop, Zone, Ajax and Zones, EventLink, Request, @Inject, @InjectComponent.

Home

The source for Person, PersonFinderService, @EJB handling, etc. is shown in the @EJB example.

AjaxFormsInALoop.tml


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a doctype to allow us to use special characters like &nbsp; 
     We use a "strict" DTD to make IE follow the alignment rules. -->
     
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd">
<head>
    <link rel="stylesheet" type="text/css" href="${context:css/examples/ajaxformsinaloop.css}"/>
</head>
<body>
    <h1>Ajax Forms in a Loop</h1>

    <noscript class="js-required">
        ${message:javascript_required}
    </noscript>     
    
    To demonstrate a server-side error, change any First Name to <em>${BAD_NAME}</em>.

    <div class="eg">
    
        <form t:type="form" t:id="preferencesForm">
            Highlight zone updates? 
             <input t:type="checkbox" t:id="highlightZoneUpdates" value="highlightZoneUpdates" onclick="this.form.submit()"/>
            This shows you which zones are updated by the Ajax response. <br/>
            Note that it upsets our hover highlighting due to <a href="https://issues.apache.org/jira/browse/TAP5-2014">TAP5-2014</a>.
        </form><br/>
    
        <table id="personsTable" class="outer">
            <thead>
                <tr>
                    <th>
                        <table class="inner">
                            <thead>
                                <tr>
                                    <th>Id</th>
                                    <th>First Name</th>
                                    <th>Last Name</th>
                                    <th>Region</th>
                                    <th>Start Date</th>
                                    <th>Action</th>
                                </tr>
                            </thead>
                        </table>
                    </th>
                </tr>
            </thead>
            <tbody>
                <t:Loop t:source="persons" t:value="person">
                    <tr t:type="Zone" t:id="rowZone" id="prop:currentRowZoneId" class="prop:evenodd.next" t:update="prop:zoneUpdateFunction">
                        <td>
                            <form t:id="personForm" t:type="form" t:context="person.id" t:zone="^" t:clientValidation="false">
                                <table class="inner">
                                    <tbody>
                                        <tr>
                                            <td>
                                                <span t:type="any" id="personId">${person.id}</span>

                                                <!-- If optimistic locking is not needed then comment out this next line. -->
                                                <t:hidden value="person.version"/>
                                            </td>
                                            <td>
                                                <t:if test="!editing">
                                                    ${person.firstName}
                                                </t:if>
                                                <t:if test="editing">
                                                    <input t:type="TextField" t:id="firstName" value="person.firstName" t:validate="required, maxlength=10" size="10"/>
                                                </t:if>
                                            </td>
                                            <td>
                                                ${person.lastName}

                                                <!-- We shadow each output-only with a hidden field to enable redisplay of the list exactly as it was submitted. -->
                                                <t:hidden value="person.lastName"/>
                                            </td>
                                            <td>
                                                <t:if test="!editing">
                                                    ${personRegion}
                                                </t:if>
                                                <t:if test="editing">
                                                    <input t:type="Select" t:id="region" value="person.region"/>
                                                </t:if>
                                            </td>
                                            <td>
                                                <t:if test="!editing">
                                                    <t:output t:value="person.startDate" t:format="prop:dateFormat"/>
                                                </t:if>
                                                <t:if test="editing">
                                                    <input t:type="DateField" t:id="startDate" t:value="person.startDate" t:format="prop:dateFormat" size="10"/>
                                                </t:if>
                                            </td>
                                            <td>
                                                <t:if test="!editing">
                                                    <input t:type="submit" t:id="edit" value="Edit" t:mode="cancel"/>
                                                </t:if>
                                                <t:if test="editing">
                                                    <input t:type="submit" t:id="save" value="Save"/>
                                                    <input t:type="submit" t:id="cancel" value="Cancel" t:mode="cancel"/>
                                                </t:if>
                                            </td>
                                        </tr>
                                        <t:if test="personFormHasErrors">
                                            <tr>
                                                <td colspan="6">
                                                    <t:errors/>
                                                </td>
                                            </tr>
                                        </t:if>
                                    </tbody>
                                </table>
                            </form>
                        </td>
                    </tr>
                </t:Loop>
            </tbody>
        </table>

    </div>

    Here's the sequence when you Edit, Save, or Cancel: 
    <ul>
        <li>In comes an AJAX component event request: a submit from a Form.</li>
        <li>- Form bubbles up <em>prepareForSubmit</em>. It has a context.</li>
        <li>- Submit bubbles up <em>selected</em>.</li>
        <li>- Form bubbles up <em>validate</em>.</li>
        <li>- Form bubbles up <em>success</em> or <em>failure</em>. Our event handlers nominate which Zone to render.</li>
        <li>Tapestry renders the Zone...</li>
        <li>- Form bubbles up <em>prepareForRender</em>. It has a context.</li>
        <li>Out goes an AJAX response with Zone content which includes the Form.</li>
    </ul>

    Notes: 
    <ul>
        <li>This example does not cope with choosing Edit, Save, or Cancel on an entity that no longer exists,<br/> 
            so it suits applications that use "soft" delete (ie. that update the entity to a "deleted" state rather than actually deleting it).</li>
        <li>Not tested with IE7 or earlier.</li>
    </ul>

    References: 
    <a href="http://tapestry.apache.org/5.3.7/apidocs/org/apache/tapestry5/corelib/components/Loop.html">Loop</a>, 
    <a href="http://tapestry.apache.org/5.3.7/apidocs/org/apache/tapestry5/corelib/components/Zone.html">Zone</a>, 
    <a href="http://tapestry.apache.org/ajax-and-zones.html">Ajax and Zones</a>, 
    <a href="http://tapestry.apache.org/5.3.7/apidocs/org/apache/tapestry5/corelib/components/EventLink.html">EventLink</a>, 
    <a href="http://tapestry.apache.org/5.3.7/apidocs/org/apache/tapestry5/services/Request.html">Request</a>, 
    <a href="http://tapestry.apache.org/5.3.7/apidocs/org/apache/tapestry5/ioc/annotations/Inject.html">@Inject</a>, 
    <a href="http://tapestry.apache.org/5.3.7/apidocs/org/apache/tapestry5/annotations/InjectComponent.html">@InjectComponent</a>.<br/><br/> 

    <a t:type="pagelink" t:page="Index" href="#">Home</a><br/><br/>
    
    The source for Person, PersonFinderService, @EJB handling, etc. is shown in the @EJB example.<br/><br/>

    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/ajax/AjaxFormsInALoop.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/ajax/AjaxFormsInALoop.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/ajax/AjaxFormsInALoop.properties"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/css/examples/ajaxformsinaloop.css"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/commons/EvenOdd.java"/>
</body>
</html>

AjaxFormsInALoop.java


package jumpstart.web.pages.examples.ajax;

import java.text.DateFormat;
import java.text.Format;
import java.util.List;
import java.util.Locale;

import javax.ejb.EJB;

import jumpstart.business.domain.person.Person;
import jumpstart.business.domain.person.Regions;
import jumpstart.business.domain.person.iface.IPersonFinderServiceLocal;
import jumpstart.business.domain.person.iface.IPersonManagerServiceLocal;
import jumpstart.util.ExceptionUtil;
import jumpstart.web.commons.EvenOdd;

import org.apache.tapestry5.annotations.Component;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.Persist;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.Form;
import org.apache.tapestry5.corelib.components.Zone;
import org.apache.tapestry5.ioc.Messages;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.services.Request;
import org.apache.tapestry5.services.ajax.AjaxResponseRenderer;

public class AjaxFormsInALoop {
    static private final int MAX_RESULTS = 30;

    // Screen fields

    @Property
    private List<Person> persons;

    @Property
    private Person person;

    @Property
    private boolean editing;

    @Property
    private EvenOdd evenOdd;

    @Property
    private final String BAD_NAME = "Acme";

    @Property
    @Persist
    private boolean highlightZoneUpdates;

    // Work fields

    private boolean loadingLoop;

    private Actions action;

    // Generally useful bits and pieces

    @EJB
    private IPersonFinderServiceLocal personFinderService;

    @EJB
    private IPersonManagerServiceLocal personManagerService;

    @InjectComponent
    private Zone rowZone;

    @Inject
    private Request request;

    @Inject
    private AjaxResponseRenderer ajaxResponseRenderer;

    @Component
    private Form personForm;

    @Inject
    private Messages messages;

    @Inject
    private Locale currentLocale;

    private enum Actions {
        TO_EDIT, CANCEL, SAVE;
    }

    // The code

    void onActivate() {
        loadingLoop = false;
    }

    void setupRender() {
        loadingLoop = true;

        // Get all persons - ask business service to find them (from the database)
        persons = personFinderService.findPersons(MAX_RESULTS);

        evenOdd = new EvenOdd();
    }

    void onPrepareForRenderFromPersonForm(Long personId) {

        // If the loop is being reloaded, the form may have had errors so clear them just in case.

        if (loadingLoop) {
            personForm.clearErrors();
            editing = false;
        }

        // If the form is valid then we're not redisplaying due to error, so get the person.

        if (personForm.isValid()) {
            person = personFinderService.findPerson(personId);
            // Handle null person in the template.
        }
    }

    void onPrepareForSubmitFromPersonForm(Long personId) {

        // Get objects for the form fields to overlay.
        person = personFinderService.findPerson(personId);

    }
    
    void onSelectedFromEdit() {
        action = Actions.TO_EDIT;
    }

    void onSelectedFromSave() {
        action = Actions.SAVE;
    }

    void onSelectedFromCancel() {
        action = Actions.CANCEL;
    }
    
    void onValidateFromPersonForm() {

        if (action == Actions.SAVE) {

            if (personForm.getHasErrors()) {
                // We get here only if a server-side validator detected an error.
                return;
            }

            // Simulate a server-side validation error: return error if anyone's first name is BAD_NAME.

            if (person.getFirstName() != null && person.getFirstName().equals(BAD_NAME)) {
                personForm.recordError("First name cannot be " + BAD_NAME + ".");
                return;
            }

            try {
                personManagerService.changePerson(person);
            }
            catch (Exception e) {
                // Display the cause. In a real system we would try harder to get a user-friendly message.
                personForm.recordError(ExceptionUtil.getRootCauseMessage(e));
            }

        }
        else {
            personForm.clearErrors();
        }

    }

    void onSuccessFromPersonForm() {

        editing = action == Actions.TO_EDIT;

        if (request.isXHR()) {
            ajaxResponseRenderer.addRender(rowZone);
        }
    }

    void onFailureFromPersonForm() {

        editing = action == Actions.SAVE;

        if (request.isXHR()) {
            ajaxResponseRenderer.addRender(rowZone);
        }
    }

    public String getCurrentRowZoneId() {
        // The id attribute of a row must be the same every time that row asks for it and unique on the page.
        return "rowZone_" + person.getId();
    }

    public String getPersonRegion() {
        // Follow the same naming convention that the Select component uses
        return messages.get(Regions.class.getSimpleName() + "." + person.getRegion().name());
    }

    public Format getDateFormat() {
        return DateFormat.getDateInstance(DateFormat.SHORT, currentLocale);
    }

    public boolean isPersonFormHasErrors() {
        return personForm.getHasErrors();
    }

    public String getZoneUpdateFunction() {
        return highlightZoneUpdates ? "highlight" : "show";
    }

}

AjaxFormsInALoop.properties


## These enum conversions could be moved to the central message properties file called app.properties
Regions.EAST_COAST=East Coast
Regions.WEST_COAST=West Coast

ajaxformsinaloop.css


body, th, td    { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 13px; font-weight: normal; color: #333;
                    line-height: 17px; }
h1              { font-size: 26px; line-height: 20px; } /* For IE 7 */

.eg             { margin: 20px 0; padding: 20px; color: #888; 
                    border: 1px solid #ddd; border-radius: 4px; -webkit-border-radius: 4px; -mox-border-radius: 4px; }

a               { text-decoration: none; color: #3D69B6; }
a:hover         { text-decoration: underline; }

/* We'll use very specific selectors to avoid interfering with the DatePicker. */ 

.outer                          { border-collapse: collapse; border-spacing: 0; border: 1px solid #dddddd; font-size: inherit; }
.outer > tbody > tr.odd         { background-color: #f8f8f8; }
.outer > tbody > tr:hover       { background-color: #eeeeee; }
.outer > tbody > tr.odd:hover   { background-color: #eeeeee; }
.outer > tbody > tr > td > form { margin: 0; padding: 0; }

.inner                          { border-collapse: collapse; border-spacing: 0; font-size: inherit; width: 100%; }
.inner > thead > tr > th        { padding: 3px 5px; text-align: left; width: 130px; border: 1px solid #dddddd; 
                                    font-weight: normal; background-color: #eeeeee;
                                    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fbfbfb', endColorstr='#e4e4e4'); /* for IE */
                                    background: -webkit-gradient(linear, left top, left bottom, from(#fbfbfb), to(#e4e4e4)); /* for webkit browsers */
                                    background: -moz-linear-gradient(top, #fbfbfb, #e4e4e4); /* for firefox 3.6+ */ }
.inner > tbody > tr > td        { padding: 3px 5px; width: 130px; height: 2.3em; text-align:left; }

.inner div.t-error              { margin: 0; border: none; }
.inner div.t-error div.t-banner { display: none; }
.inner div.t-error ul           { color: inherit; background-color: inherit; list-style: none; margin: 0; }
.inner div.t-error li           { color: red; background-color: inherit; text-align: center; }

.nodata                         { margin-top: 5px; margin-left:20px; }

.js-required                    { color: red; display: block; margin-bottom: 14px; }

EvenOdd.java


// Adapted from Tapestry 4's EvenOdd class.

package jumpstart.web.commons;

/**
 * Used to emit a stream of alterating string values: "even", "odd", etc. This is often used in the Inspector pages to
 * make the class of a &lt;tr&gt; alternate for presentation reasons.
 * 
 * @author Howard Lewis Ship
 */

public class EvenOdd {

    private boolean even = true;

    /**
     * Returns "even" or "odd". Whatever it returns on one invocation, it will return the opposite on the next. By
     * default, the first value returned is "even".
     */
    public String getNext() {
        String result = getCurrent();
        even = !even;
        return result;
    }

    public String getCurrent() {
        return even ? "even" : "odd";
    }
    
    public boolean isEven() {
        return even;
    }

    /**
     * Overrides the even flag.
     */
    public void setEven(boolean value) {
        even = value;
    }
}