Ajax Forms in a Loop

To demonstrate a server-side error, change any First Name to Acme.
Id First Name Last Name Region Start Date Action
5 Dishy Spoon East Coast 2/29/08 Edit
1 Humpty Dumpty2 East Coast 12/28/07 Edit
3 Jack Sprat West Coast 2/28/07 Edit
4 Jill Spill West Coast 2/29/08 Edit
2 Mary Contrary East Coast 2/29/08 Edit
Each row is a Zone around a Form. Here's the sequence when you click an Edit, Save, or Cancel: Notes: References: Loop, Zone, Ajax and Zones, EventLink, Request, AjaxResponseRenderer, @Inject, @InjectComponent, t5/core/zone, t5/core/ajax, t5/core/forms.

Home

The source for IPersonFinderServiceLocal, IPersonManagerServiceLocal, and @EJB is shown in the Session Beans and @EJB examples.


<!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_4.xsd">
<body class="container">
    <h3>Ajax Forms in a Loop</h3>

    <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">
    
        <table id="personsTable" class="table table-striped table-hover table-condensed">
            <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">
                        <td>
                            <t:form t:id="personForm" context="person.id" async="true" validate="person">
                                <table class="inner">
                                    <tbody>
                                        <tr>
                                            <td>
                                                <span t:type="any">${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">
                                                    <t:textfield t:id="firstName" value="person.firstName"/>
                                                </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">
                                                    <t: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">
                                                    <t:datefield t:id="startDate" value="person.startDate" format="prop:dateFormat"/>
                                                </t:if>
                                            </td>
                                            <td>
                                                <t:if test="!editing">
                                                    <t:eventlink event="toEdit" context="person.id" async="true" class="btn btn-default">Edit</t:eventlink>
                                                </t:if>
                                                <t:if test="editing">
                                                    <t:submit t:id="save" value="Save"/>
                                                    <t:eventlink event="cancel" context="person.id" async="true" class="btn btn-default">Cancel</t:eventlink>
                                                </t:if>
                                            </td>
                                        </tr>
                                        <t:if test="personFormHasErrors">
                                            <tr>
                                                <td colspan="6">
                                                    <t:errors globalOnly="true"/>
                                                </td>
                                            </tr>
                                        </t:if>
                                    </tbody>
                                </table>
                            </t:form>
                        </td>
                    </tr>
                </t:Loop>
            </tbody>
        </table>

    </div>

    Each row is a Zone around a Form. Here's the sequence when you click an Edit, Save, or Cancel: 
    <ul>
        <li>Tapestry's JavaScript on the client submits the form as an AJAX component event request. It reaches the corresponding Form on the server and...</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, during which...</li>
        <li>- Form bubbles up <em>prepareForRender</em>. It has a context.</li>
        <li>Tapestry returns the fresh Zone contents to the client in an AJAX response.</li>
        <li>Tapestry's JavaScript on the client puts the Zone contents in the right place on the page.</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.4/apidocs/org/apache/tapestry5/corelib/components/Loop.html">Loop</a>, 
    <a href="http://tapestry.apache.org/5.4/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.4/apidocs/org/apache/tapestry5/corelib/components/EventLink.html">EventLink</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/services/Request.html">Request</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/services/ajax/AjaxResponseRenderer.html">AjaxResponseRenderer</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/ioc/annotations/Inject.html">@Inject</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/annotations/InjectComponent.html">@InjectComponent</a>, 
    <a href="http://tapestry.apache.org/5.4/coffeescript/zone.html">t5/core/zone</a>, 
    <a href="http://tapestry.apache.org/5.4/coffeescript/ajax.html">t5/core/ajax</a>, 
    <a href="http://tapestry.apache.org/5.4/coffeescript/forms.html">t5/core/forms</a>.<br/><br/> 

    <t:pagelink page="Index">Home</t:pagelink><br/><br/>
    
    The source for IPersonFinderServiceLocal, IPersonManagerServiceLocal, and @EJB is shown in the Session Beans and @EJB examples.<br/><br/>

    <t:tabgroup>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/ajax/AjaxFormsInALoop.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/ajax/AjaxFormsInALoop.java"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/ajax/AjaxFormsInALoop.properties"/>
        <t:sourcecodetab src="/web/src/main/resources/META-INF/assets/css/examples/ajaxformsinaloop.css"/>
    </t:tabgroup>
</body>
</html>


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 org.apache.tapestry5.annotations.Import;
import org.apache.tapestry5.annotations.InjectComponent;
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;

@Import(stylesheet = "css/examples/ajaxformsinaloop.css")
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 final String BAD_NAME = "Acme";

    // Work fields

    private boolean loadingLoop;

    // 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;

    @InjectComponent
    private Form personForm;

    @Inject
    private Messages messages;

    @Inject
    private Locale currentLocale;

    // 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);
    }

    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 onValidateFromPersonForm() {

        // 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 must not be " + BAD_NAME + ".");
        }

        if (person.getId() == 2 && !person.getFirstName().equals("Mary")) {
            personForm.recordError("First name for this person must be Mary.");
        }

        if (personForm.getHasErrors()) {
            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));
        }

    }

    void onSuccessFromPersonForm() {

        editing = false;

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

    void onFailureFromPersonForm() {

        editing = true;

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

    void onToEdit(Long personId) {
        person = personFinderService.findPerson(personId);
        editing = true;

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

    void onCancel(Long personId) {
        person = personFinderService.findPerson(personId);
        editing = false;

        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() {
        return messages.get(Regions.class.getSimpleName() + "." + person.getRegion().name());
    }

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

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

}


## These enum conversions could be moved to the central message properties file called app.properties
## The structure we've chosen (enum class name, dot, enum value) is the same as expected by the Select component.
Regions.EAST_COAST=East Coast
Regions.WEST_COAST=West Coast


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

.inner {
                border-collapse: collapse;
                border-spacing: 0;
                font-size: inherit;
                width: 100%;
}

.inner>thead>tr>th {
                padding: 3px 5px;
                width: 130px;
                text-align: left;
}

.inner>tbody>tr>td {
                padding: 3px 5px;
                width: 130px;
                text-align: left;
}

.inner div.alert h4 {
                display: none;
}

.inner div.alert ul {
                list-style: none;
}

.inner div.alert li {
                text-align: center;
}

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