AJAX Components CRUD

This example is based on AJAX Filter CRUD, modified by replacing the list and the editor with two custom components: PersonList and PersonEditor.
Highlight zone updates? This shows you which zones are updated by the Ajax response.

Create...
 
Like all good components, PersonList and PersonEditor do not know about each other. They handle their own events, then bubble them up to their container which in this case is the page. The container is responsible for coordinating its components. For example, when you click on a person in the list, PersonList bubbles an event up to the page and the page decides what to tell the PersonEditor.

A component can include data within an event that it bubbles up, which is what PersonList and PersonEditor do, but take note that a component can also return data in its parameters - the container can read the data because component parameters are bi-directional.

Splitting the page into custom components like PersonList and PersonEditor is very appealing because it provides a clear and very natural separation of concerns, but just be aware that it doesn't come for free: you have to put in a bit of extra effort to get the event handling right.

References: Zone, Request, AjaxResponseRenderer, ComponentResources, @Inject, @InjectComponent.

Home

The source for PersonFinderService, @EJB handling, etc. is shown in the @EJB example.
The source for CustomForm and CustomError is shown in the No Validation Bubbles example.

Persons.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" xmlns:p="tapestry:parameter">
<head>
    <link rel="stylesheet" type="text/css" href="${context:css/together/ajaxcomponentscrud.css}"/>
</head>
<body>
    <h1>AJAX Components CRUD</h1>
    
    <noscript class="js-required">
        ${message:javascript_required}
    </noscript>     

    This example is based on <em>AJAX Filter CRUD</em>, modified by replacing the list and the editor with two custom components: 
    PersonList and PersonEditor.

    <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.
        </form><br/>
    
        <a t:type="eventLink" t:event="toCreate" t:zone="editorZone" href="#">Create...</a><br/>
        
        <table id="listAndEditor">
            <tbody>
                <tr>
    
                    <!-- This is the left side of the table: a list of Persons -->
    
                    <td id="listCell">
                        <t:zone t:id="listZone" id="listZone" t:update="prop:zoneUpdateFunction">
                            <t:together.ajaxcomponentscrud.PersonList t:id="list" partialName="partialName" selectedPersonId="listPersonId"/>
                        </t:zone>
                    </td>
                    
                    <!-- This is the right side of the table: where a Person will be created, reviewed, or updated. -->
            
                    <td id="editorCell">
                        <t:zone t:id="editorZone" id="editorZone" t:update="prop:zoneUpdateFunction">
                            <t:together.ajaxcomponentscrud.PersonEditor t:id="editor" mode="editorMode" personId="editorPersonId"/>
                        </t:zone>
                    </td>
                    
                </tr>
            </tbody>
        </table>
    </div>

    Like all good components, PersonList and PersonEditor do not know about each other. They handle their own events, then bubble them up 
    to their container which in this case is the page. The container is responsible for coordinating its components. For example, when you 
    click on a person in the list, PersonList bubbles an event up to the page and the page decides what to tell the PersonEditor.<br/><br/>
     
    A component can include data within an event that it bubbles up, which is what PersonList and PersonEditor do, but take note that a 
    component can also return data in its parameters - the container can read the data because component parameters are bi-directional.<br/><br/>
    
    Splitting the page into custom components like PersonList and PersonEditor is very appealing because it provides a clear and very 
    natural separation of concerns, but just be aware that it doesn't come for free: you have to put in a bit of extra effort to get 
    the event handling right.<br/><br/>

    References: 
    <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/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/services/ajax/AjaxResponseRenderer.html">AjaxResponseRenderer</a>, 
    <a href="http://tapestry.apache.org/5.3.7/apidocs/org/apache/tapestry5/ComponentResources.html">ComponentResources</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 PersonFinderService, @EJB handling, etc. is shown in the @EJB example.<br/>
    The source for CustomForm and CustomError is shown in the No Validation Bubbles example.<br/><br/>

    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/together/ajaxcomponentscrud/Persons.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/together/ajaxcomponentscrud/Persons.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonList.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonList.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonEditor.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonEditor.properties"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonEditor.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/css/together/ajaxcomponentscrud.css"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/mixins/Confirm.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/mixins/Confirm.js"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/model/together/PersonFilteredDataSource.java"/>
    <t:sourcecodedisplay src="/business/src/main/java/jumpstart/business/domain/person/Person.java"/>
    <t:sourcecodedisplay src="/business/src/main/java/jumpstart/business/domain/person/Regions.java"/>
</body>
</html>

Persons.java


package jumpstart.web.pages.together.ajaxcomponentscrud;

import jumpstart.web.components.together.ajaxcomponentscrud.PersonEditor.Mode;

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

public class Persons {

    // Screen fields

    @Property
    @Persist
    private boolean highlightZoneUpdates;

    @Property
    // If we use @ActivationRequestParameter instead of @Persist, then our handler for filter form success would have
    // to render more than just the listZone, it would have to render all other links and forms: it would need a zone
    // around the "Create..." link so it could render it; and it would render the editorZone, which would be destructive
    // if the user has been typing into Create or Update. Alternatively, it could use a custom JavaScript callback to
    // update the partialName in all other links and forms - see AjaxResponseRenderer#addCallback(JavaScriptCallback).
    @Persist
    private String partialName;

    @Property
    private Long listPersonId;

    @Property
    @ActivationRequestParameter
    private Mode editorMode;

    @Property
    @ActivationRequestParameter
    private Long editorPersonId;

    // Generally useful bits and pieces

    @InjectComponent
    private Zone listZone;

    @InjectComponent
    private Zone editorZone;

    @Inject
    private Request request;

    @Inject
    private AjaxResponseRenderer ajaxResponseRenderer;

    // The code

    void onActivate() {
        listPersonId = editorPersonId;
    }

    // /////////////////////////////////////////////////////////////////////
    // FILTER
    // /////////////////////////////////////////////////////////////////////

    // Handle event "filter" from component "list"

    void onFilterFromList() {
        if (request.isXHR()) {
            ajaxResponseRenderer.addRender(listZone);
        }
    }

    // /////////////////////////////////////////////////////////////////////
    // CREATE
    // /////////////////////////////////////////////////////////////////////

    // Handle event "toCreate" from this page

    void onToCreate() {
        editorMode = Mode.CREATE;
        editorPersonId = null;
        listPersonId = null;

        if (request.isXHR()) {
            ajaxResponseRenderer.addRender(listZone).addRender(editorZone);
        }
    }

    // Handle event "cancelCreate" from component "editor"

    void onCancelCreateFromEditor() {
        editorMode = null;
        editorPersonId = null;

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

    // Handle event "successfulCreate" from component "editor"

    void onSuccessfulCreateFromEditor(Long personId) {
        editorMode = Mode.REVIEW;
        editorPersonId = personId;
        listPersonId = personId;

        if (request.isXHR()) {
            ajaxResponseRenderer.addRender(listZone).addRender(editorZone);
        }
    }

    // Handle event "failedCreate" from component "editor"

    void onFailedCreateFromEditor() {
        editorMode = Mode.CREATE;
        editorPersonId = null;

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

    // /////////////////////////////////////////////////////////////////////
    // REVIEW
    // /////////////////////////////////////////////////////////////////////

    // Handle event "selected" from component "list"

    void onSelectedFromList(Long personId) {
        editorMode = Mode.REVIEW;
        editorPersonId = personId;
        listPersonId = personId;

        if (request.isXHR()) {
            ajaxResponseRenderer.addRender(listZone).addRender(editorZone);
        }
    }

    // /////////////////////////////////////////////////////////////////////
    // UPDATE
    // /////////////////////////////////////////////////////////////////////

    // Handle event "toUpdate" from component "editor"

    void onToUpdateFromEditor(Long personId) {
        editorMode = Mode.UPDATE;
        editorPersonId = personId;

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

    // Handle event "cancelUpdate" from component "editor"

    void onCancelUpdateFromEditor(Long personId) {
        editorMode = Mode.REVIEW;
        editorPersonId = personId;

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

    // Handle event "successfulUpdate" from component "editor"

    void onSuccessfulUpdateFromEditor(Long personId) {
        editorMode = Mode.REVIEW;
        editorPersonId = personId;
        listPersonId = personId;

        if (request.isXHR()) {
            ajaxResponseRenderer.addRender(listZone).addRender(editorZone);
        }
    }

    // Handle event "failedUpdate" from component "editor"

    void onFailedUpdateFromEditor(Long personId) {
        editorMode = Mode.UPDATE;
        editorPersonId = personId;

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

    // /////////////////////////////////////////////////////////////////////
    // DELETE
    // /////////////////////////////////////////////////////////////////////

    // Handle event "successfulDelete" from component "editor"

    void onSuccessfulDeleteFromEditor(Long personId) {
        editorMode = null;
        editorPersonId = null;
        listPersonId = null;

        if (request.isXHR()) {
            ajaxResponseRenderer.addRender(listZone).addRender(editorZone);
        }
    }

    // Handle event "failedDelete" from component "editor"

    void onFailedDeleteFromEditor(Long personId) {
        editorMode = Mode.REVIEW;
        editorPersonId = personId;

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

    // /////////////////////////////////////////////////////////////////////
    // GETTERS
    // /////////////////////////////////////////////////////////////////////

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

}

PersonList.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" xmlns:p="tapestry:parameter">
<!--  At runtime the page will supply the stylesheet. The link here is to enable preview. -->
<link rel="stylesheet" type="text/css" href="../../../css/examples/ajaxcomponentscrud.css"/>

<t:content>

    <!-- We can't use the form's id in the css because the Zone messes with it, so we add a div with its own id. -->
    <div id="personFilter">
        <form t:type="form" t:id="filterForm" t:zone="^">
            <div>
                Person
            </div>
            <div>
                <input t:id="partialName" t:type="TextField" size="15" t:validate="maxLength=15"/>
                <input type="submit" value="Filter" title="Filter"/>
            </div>
        </form>
    </div>
    
    <div id="personList">
        <table t:type="grid" t:id="list" t:source="persons" t:row="person"
            t:exclude="id,version,firstName,lastName,region,startDate" t:add="name" 
            t:rowsPerPage="4" t:pagerPosition="bottom"
            t:class="personGrid" t:empty="block:emptyPersons" t:inplace="true">[Grid here]
            <p:nameCell>
                <a t:type="eventLink" t:event="selected" t:context="person.id" class="prop:linkCSSClass" t:zone="^" href="#">
                    ${person.firstName} ${person.lastName}
                </a>
            </p:nameCell>
        </table>
    </div>
    
    <t:block t:id="emptyPersons">
        <div id="noPersons">
            (No persons found)
        </div>
    </t:block>

</t:content>
</html>

PersonList.java


package jumpstart.web.components.together.ajaxcomponentscrud;

import javax.ejb.EJB;

import jumpstart.business.domain.person.Person;
import jumpstart.business.domain.person.iface.IPersonFinderServiceLocal;
import jumpstart.web.model.together.PersonFilteredDataSource;

import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.annotations.Events;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.grid.GridDataSource;
import org.apache.tapestry5.ioc.annotations.Inject;

/**
 * This component will trigger the following events on its container (which in this example is the page):
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonList#SELECTED}(Long selectedPersonId).
 */
// @Events is applied to a component solely to document what events it may trigger. It is not checked at runtime.
@Events({ PersonList.SELECTED })
public class PersonList {
    public static final String SELECTED = "selected";

    // Parameters

    @Parameter(required = true)
    @Property
    private String partialName;

    @Parameter(required = true)
    @Property
    private Long selectedPersonId;

    // Screen fields

    @Property
    private Person person;

    // Generally useful bits and pieces

    @EJB
    private IPersonFinderServiceLocal personFinderService;

    @Inject
    private ComponentResources componentResources;

    // The code

    boolean onSuccessFromFilterForm() {
        // Trigger new event "filter" which will bubble up.
        componentResources.triggerEvent("filter", null, null);
        // We don't want "success" to bubble up, so we return true to say we've handled it.
        return true;
    }

    // Handle event "selected"

    boolean onSelected(Long personId) {
        // Return false, which means we haven't handled the event so bubble it up.
        // This method is here solely as documentation, because without this method the event would bubble up anyway.
        return false;
    }

    // Getters

    public GridDataSource getPersons() {
        return new PersonFilteredDataSource(personFinderService, partialName);
    }

    public String getLinkCSSClass() {
        if (person != null && person.getId().equals(selectedPersonId)) {
            return "active";
        }
        else {
            return "";
        }
    }
}

PersonEditor.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" xmlns:p="tapestry:parameter">
<!--  At runtime the page will supply the stylesheet. The link here is to enable preview. -->
<link rel="stylesheet" type="text/css" href="../../../css/examples/ajaxcomponentscrud.css"/>

<t:content>

    <t:if test="modeCreate">
        <h1>Create</h1>
        
        <form t:type="CustomForm" t:id="createForm" t:zone="^">
            <t:errors/>
    
            <table>
                <tr>
                    <th><t:label for="firstName"/>:</th>
                    <td><input t:type="TextField" t:id="firstName" value="person.firstName" t:validate="required, maxlength=10" size="10"/></td>
                    <td>(required)</td>
                </tr>
                <tr class="err">
                    <th></th>
                    <td colspan="2"><t:CustomError for="firstName"/></td>
                </tr>
                <tr>
                    <th><t:label for="lastName"/>:</th>
                    <td><input t:type="TextField" t:id="lastName" t:clientid="clastname" value="person.lastName" t:validate="required, maxlength=10" size="10"/></td>
                    <td>(required)</td>
                </tr>
                <tr class="err">
                    <th></th>
                    <td colspan="2"><t:CustomError for="lastName"/></td>
                </tr>
                <tr>
                    <th><t:label for="region"/>:</th>
                    <td><input t:type="Select" t:id="region" value="person.region" t:validate="required" t:blankOption="ALWAYS"/></td>
                    <td>(required)</td>
                </tr>
                <tr class="err">
                    <th></th>
                    <td colspan="2"><t:CustomError for="region"/></td>
                </tr>
                <tr>
                    <th><t:label for="startDate"/>:</th>
                    <td><input t:type="DateField" t:id="startDate" t:clientid="cstartdate" value="person.startDate" t:format="prop:dateFormat" t:validate="required" size="10"/></td>
                    <td>(required, ${datePattern})</td>
                </tr>
                <tr class="err">
                    <th></th>
                    <td colspan="2"><t:CustomError for="startDate"/></td>
                </tr>
            </table>

            <div class="buttons">
                <a t:type="eventLink" t:event="cancelCreate" t:zone="^" href="#">Cancel</a>
                <input type="submit" value="Save"/>
            </div>
        </form>

    </t:if>

    <t:if test="modeReview">
        <h1>Review</h1>
        
        <t:if test="person">
            <div t:type="if" t:test="deleteMessage" class="error">
                ${deleteMessage}
            </div>

            <table>
                <tr>
                    <th>Id:</th>
                    <td>${person.id}</td>
                </tr>
                <tr>
                    <th>Version:</th>
                    <td>${person.version}</td>
                </tr>
                <tr>
                    <th>Name:</th>
                    <td>${person.firstName} ${person.lastName}</td>
                </tr>
                <tr>
                    <th>Region:</th>
                    <td>${personRegion}</td>
                </tr>
                <tr>
                    <th>Start Date:</th>
                    <td><t:output value="person.startDate" format="prop:dateFormat"/></td>
                </tr>
            </table>

            <div class="buttons">
                <a t:type="eventLink" t:event="toUpdate" t:context="person.id" t:zone="^" href="#">Update...</a>
                <a t:type="eventLink" t:event="delete" t:context="[person.id,person.version]" t:zone="^" href="#">
                    <!-- The Confirm mixin can't cancel an EventLink that specifies a Zone, so we put the Confirm inside the EventLink. -->
                    <!-- See http://tapestry-users.832.n2.nabble.com/Confirm-mixin-won-t-cancel-when-in-zone-td5048950.html#a5048950 -->
                    <span t:type="any" t:mixins="Confirm" t:message="Delete ${person.firstName} ${person.lastName}?">
                        Delete...
                    </span>
                </a>
            </div>

        </t:if>
        <t:if negate="true" test="person">
            Person ${personId} does not exist.<br/><br/>
        </t:if>
        
    </t:if>

    <t:if test="modeUpdate">
        <h1>Update</h1>
        
        <form t:type="CustomForm" t:id="updateForm" t:context="personId" t:zone="^">
            <t:errors/>
        
            <t:if test="person">
                <!-- If optimistic locking is not needed then comment out this next line. It works because Hidden fields are part of the submit. -->
                <t:hidden value="person.version"/>
        
                <table>
                    <tr>
                        <th><t:label for="updFirstName"/>:</th>
                        <td><input t:type="TextField" t:id="updFirstName" value="person.firstName" t:validate="required, maxlength=10" size="10"/></td>
                        <td>(required)</td>
                    </tr>
                    <tr class="err">
                        <th></th>
                        <td colspan="2"><t:CustomError for="updFirstName"/></td>
                    </tr>
                    <tr>
                        <th><t:label for="updLastName"/>:</th>
                        <td><input t:type="TextField" t:id="updLastName" value="person.lastName" t:validate="required, maxlength=10" size="10"/></td>
                        <td>(required)</td>
                    </tr>
                    <tr class="err">
                        <th></th>
                        <td colspan="2"><t:CustomError for="updLastName"/></td>
                    </tr>
                    <tr>
                        <th><t:label for="updRegion"/>:</th>
                        <td><input t:type="Select" t:id="updRegion" value="person.region" t:validate="required"/></td>
                        <td>(required)</td>
                    </tr>
                    <tr class="err">
                        <th></th>
                        <td colspan="2"><t:CustomError for="updRegion"/></td>
                    </tr>
                    <tr>
                        <th><t:label for="updStartDate"/>:</th>
                        <td><input t:type="DateField" t:id="updStartDate" value="person.startDate" t:format="prop:dateFormat" t:validate="required" size="10"/></td>
                        <td>(required, ${datePattern})</td>
                    </tr>
                    <tr class="err">
                        <th></th>
                        <td colspan="2"><t:CustomError for="updStartDate"/></td>
                    </tr>
                </table>

                <div class="buttons">
                    <a t:type="eventLink" t:event="cancelUpdate" t:context="personId" t:zone="^" href="#">Cancel</a>
                    <input t:type="submit" value="Save"/>
                </div>
                        
            </t:if>
    
            <t:if negate="true" test="person">
                Person ${personId} does not exist.<br/><br/>
            </t:if>
        </form>
        
    </t:if>

    <!-- This is needed to clear the zone. -->
    <t:if test="modeNull">
        <!-- The space character is needed only to make the zone update highlight visible. -->
        &nbsp;
    </t:if>
        
</t:content>
</html>

PersonEditor.properties


region-blankLabel=Choose...

## 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

updFirstName-label=First Name
updLastName-label=Last Name
updRegion-label=Region
updStartDate-label=Start Date

PersonEditor.java


package jumpstart.web.components.together.ajaxcomponentscrud;

import java.text.Format;
import java.text.SimpleDateFormat;

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.components.CustomForm;

import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.PersistenceConstants;
import org.apache.tapestry5.annotations.Component;
import org.apache.tapestry5.annotations.Events;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.Persist;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.ioc.Messages;
import org.apache.tapestry5.ioc.annotations.Inject;

/**
 * This component will trigger the following events on its container (which in this example is the page):
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonEditor#CANCEL_CREATE},
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonEditor#SUCCESSFUL_CREATE}(Long personId),
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonEditor#FAILED_CREATE},
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonEditor#TO_UPDATE}(Long personId),
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonEditor#CANCEL_UPDATE}(Long personId),
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonEditor#SUCCESSFUL_UPDATE}(Long personId),
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonEditor#FAILED_UPDATE}(Long personId),
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonEditor#SUCCESSFUL_DELETE}(Long personId),
 * {@link jumpstart.web.components.examples.ajax.componentscrud.PersonEditor#FAILED_DELETE}(Long personId).
 */
// @Events is applied to a component solely to document what events it may trigger. It is not checked at runtime.
@Events({ PersonEditor.CANCEL_CREATE, PersonEditor.SUCCESSFUL_CREATE, PersonEditor.FAILED_CREATE,
        PersonEditor.TO_UPDATE, PersonEditor.CANCEL_UPDATE, PersonEditor.SUCCESSFUL_UPDATE, PersonEditor.FAILED_UPDATE,
        PersonEditor.SUCCESSFUL_DELETE, PersonEditor.FAILED_DELETE })
public class PersonEditor {
    public static final String CANCEL_CREATE = "cancelCreate";
    public static final String SUCCESSFUL_CREATE = "successfulCreate";
    public static final String FAILED_CREATE = "failedCreate";
    public static final String TO_UPDATE = "toUpdate";
    public static final String CANCEL_UPDATE = "cancelUpdate";
    public static final String SUCCESSFUL_UPDATE = "successfulUpdate";
    public static final String FAILED_UPDATE = "failedUpdate";
    public static final String SUCCESSFUL_DELETE = "successfulDelete";
    public static final String FAILED_DELETE = "failedDelete";

    private final String demoModeStr = System.getProperty("jumpstart.demo-mode");

    public enum Mode {
        CREATE, REVIEW, UPDATE, DELETE;
    }

    // Parameters

    @Parameter(required = true)
    @Property
    private Mode mode;

    @Parameter(required = true)
    @Property
    private Long personId;

    // Screen fields

    @Property
    private Person person;

    @Property
    @Persist(PersistenceConstants.FLASH)
    private String deleteMessage;

    // Generally useful bits and pieces

    @EJB
    private IPersonFinderServiceLocal personFinderService;

    @EJB
    private IPersonManagerServiceLocal personManagerService;

    @Component
    private CustomForm createForm;

    @Component
    private CustomForm updateForm;

    @Inject
    private ComponentResources componentResources;

    @Inject
    private Messages messages;

    // The code

    // setupRender() is called by Tapestry right before it starts rendering the component.

    void setupRender() {

        if (mode == Mode.REVIEW) {
            if (personId == null) {
                person = null;
                // Handle null person in the template.
            }
            else {
                person = personFinderService.findPerson(personId);
                // Handle null person in the template.
            }
        }

    }

    // /////////////////////////////////////////////////////////////////////
    // CREATE
    // /////////////////////////////////////////////////////////////////////

    // Handle event "cancelCreate"

    boolean onCancelCreate() {
        // Return false, which means we haven't handled the event so bubble it up.
        // This method is here solely as documentation, because without this method the event would bubble up anyway.
        return false;
    }

    // Component "createForm" bubbles up the PREPARE event when it is rendered or submitted

    void onPrepareFromCreateForm() throws Exception {
        // Instantiate a Person for the form data to overlay.
        person = new Person();
    }

    // Component "createForm" bubbles up the VALIDATE event when it is submitted

    void onValidateFromCreateForm() {

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

        if (demoModeStr != null && demoModeStr.equals("true")) {
            createForm.recordError("Sorry, but Create is not allowed in Demo mode.");
            return;
        }

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

    // Component "createForm" bubbles up SUCCESS or FAILURE when it is submitted, depending on whether VALIDATE
    // records an error

    boolean onSuccessFromCreateForm() {
        // We want to tell our containing page explicitly what person we've created, so we trigger new event
        // "successfulCreate" with a parameter. It will bubble up because we don't have a handler method for it.
        componentResources.triggerEvent(SUCCESSFUL_CREATE, new Object[] { person.getId() }, null);
        // We don't want "success" to bubble up, so we return true to say we've handled it.
        return true;
    }

    boolean onFailureFromCreateForm() {
        // Rather than letting "failure" bubble up which doesn't say what you were trying to do, we trigger new event
        // "failedCreate". It will bubble up because we don't have a handler method for it.
        componentResources.triggerEvent(FAILED_CREATE, null, null);
        // We don't want "failure" to bubble up, so we return true to say we've handled it.
        return true;
    }

    // /////////////////////////////////////////////////////////////////////
    // REVIEW
    // /////////////////////////////////////////////////////////////////////

    // /////////////////////////////////////////////////////////////////////
    // UPDATE
    // /////////////////////////////////////////////////////////////////////

    // Handle event "toUpdate"

    boolean onToUpdate(Long personId) {
        // Return false, which means we haven't handled the event so bubble it up.
        // This method is here solely as documentation, because without this method the event would bubble up anyway.
        return false;
    }

    // Handle event "cancelUpdate"

    boolean onCancelUpdate(Long personId) {
        // Return false, which means we haven't handled the event so bubble it up.
        // This method is here solely as documentation, because without this method the event would bubble up anyway.
        return false;
    }

    // Component "updateForm" bubbles up the PREPARE_FOR_RENDER event during form render

    void onPrepareForRenderFromUpdateForm(Long personId) {
        this.personId = personId;
        
        // If the form is valid then we're not redisplaying due to error, so get the person.

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

    // Component "updateForm" bubbles up the PREPARE_FOR_SUBMIT event during form submission

    void onPrepareForSubmitFromUpdateForm(Long personId) {
        this.personId = personId;

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

        if (person == null) {
            person = new Person();
            updateForm.recordError("Person has been deleted by another process.");
        }
    }

    // Component "updateForm" bubbles up the VALIDATE event when it is submitted

    void onValidateFromUpdateForm() {

        if (updateForm.getHasErrors()) {
            // We get here only if a server-side validator detected an error.
            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.
            updateForm.recordError(ExceptionUtil.getRootCauseMessage(e));
        }
    }

    // Component "updateForm" bubbles up SUCCESS or FAILURE when it is submitted, depending on whether VALIDATE
    // records an error

    boolean onSuccessFromUpdateForm() {
        // We want to tell our containing page explicitly what person we've updated, so we trigger new event
        // "successfulUpdate" with a parameter. It will bubble up because we don't have a handler method for it.
        componentResources.triggerEvent(SUCCESSFUL_UPDATE, new Object[] { personId }, null);
        // We don't want "success" to bubble up, so we return true to say we've handled it.
        return true;
    }

    boolean onFailureFromUpdateForm() {
        // Rather than letting "failure" bubble up which doesn't say what you were trying to do, we trigger new event
        // "failedUpdate". It will bubble up because we don't have a handler method for it.
        componentResources.triggerEvent(FAILED_UPDATE, new Object[] { personId }, null);
        // We don't want "failure" to bubble up, so we return true to say we've handled it.
        return true;
    }

    // /////////////////////////////////////////////////////////////////////
    // DELETE
    // /////////////////////////////////////////////////////////////////////

    // Handle event "delete"

    boolean onDelete(Long personId, Integer personVersion) {
        this.personId = personId;

        boolean successfulDelete = false;

        if (demoModeStr != null && demoModeStr.equals("true")) {
            deleteMessage = "Sorry, but Delete is not allowed in Demo mode.";
        }
        else {

            try {
                personManagerService.deletePerson(personId, personVersion);
                successfulDelete = true;
            }
            catch (Exception e) {
                // Display the cause. In a real system we would try harder to get a user-friendly message.
                deleteMessage = ExceptionUtil.getRootCauseMessage(e);
            }
        }

        if (successfulDelete) {
            // Trigger new event "successfulDelete" (which in this example will bubble up to the page).
            componentResources.triggerEvent(SUCCESSFUL_DELETE, new Object[] { personId }, null);
        }
        else {
            // Trigger new event "failedDelete" (which in this example will bubble up to the page).
            componentResources.triggerEvent(FAILED_DELETE, new Object[] { personId }, null);
        }

        // We don't want "delete" to bubble up, so we return true to say we've handled it.
        return true;
    }

    // /////////////////////////////////////////////////////////////////////
    // OTHER
    // /////////////////////////////////////////////////////////////////////

    // Getters

    public boolean isModeCreate() {
        return mode == Mode.CREATE;
    }

    public boolean isModeReview() {
        return mode == Mode.REVIEW;
    }

    public boolean isModeUpdate() {
        return mode == Mode.UPDATE;
    }

    public boolean isModeNull() {
        return mode == null;
    }

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

    public String getDatePattern() {
        return "dd/MM/yyyy";
    }

    public Format getDateFormat() {
        return new SimpleDateFormat(getDatePattern());
    }
}

ajaxcomponentscrud.css


body, td        { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 13px; font-weight: normal; color: #333;
                    line-height: 17px; }

.eg             { margin: 20px 0; padding: 20px; 
                    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; }

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

table       { border-collapse: collapse; border-spacing: 0; }
:root table { border-collapse: separate; } /* Firefox 3 */
th, td      { padding: 0; }
form        { margin-bottom: 0; } /* IE 7 */

#listAndEditor          { width: 800px; border: none; margin: 10px 0; }

#listCell               { width: 25%; border: 1px solid white; background-color: #eee; vertical-align: top; }

#personFilter           { width: 100%; padding: 5px 0 10px 0; text-align: center; vertical-align: middle; 
                            background-color: #3d69b6; color: white; font-weight: bold; border: 1px solid white; }

/* Add some padding around the list so that we can see the yellow flash when "Highlight zone updates" is on. */ 
#listZone               { padding: 4px; background-color: inherit; }
#listZone               { background-color: #eee; zoom: 1; } /* IE 7. zoom is to fix hasLayout bug. */

#personList             { height: 238px; position: relative; }
.personGrid             { width: 100%; font-family: Arial, Helvetica, sans-serif; }
.personGrid th          { display: none; }
.personGrid td          { border: thin solid white; background-color: #eee; }
.personGrid a           { width: 100%; line-height: 50px; display: block; text-align: center;
                            text-decoration: none; color: black; }
.personGrid a:visited   { color: inherit; }
.personGrid a:hover     { background: #ccc; color: #fff; }
.personGrid a.active    { background: #999; color: #fff; }
.personGrid span.current    { background-color: #3d69b6; }

#personList .t-data-grid-pager  /* Need line-height to work around IE7 hasLayout and missing margins bug */
                        { line-height: 24px; position: absolute; left: 2px; bottom: 0px; margin: 0;
                            font-family: Arial, Helvetica, sans-serif; }

#noPersons              { text-align: center; padding-top: 10px; }

#editorCell             { width: 75%; height: 100%; vertical-align: top; 
                             border: 1px solid white; background-color: #eee; padding: 20px; }
#editorZone             { background-color: inherit; /* For IE7: */ background-color: #eee; }
#editorCell table       { margin: auto; } 
#editorCell h1          { font-size: large; text-align: center; } 
#editorCell th          { padding: 2px 5px; text-align: right; }
#editorCell td          { padding: 2px 5px; text-align: left; }
#editorCell tr.err th   { padding: 0; }
#editorCell tr.err td   { padding: 0; }
#editorCell tr.err td.error-msg-c   { padding: 0 0 4px 7px; font-size: 11px; color: red; } 
#editorCell .buttons    { text-align: center; padding-top: 15px; } 

.error                  { color: red; text-align: center; padding-bottom: 13px; }

Confirm.java


// Based on http://wiki.apache.org/tapestry/Tapestry5AndJavaScriptExplained

package jumpstart.web.mixins;

import org.apache.tapestry5.BindingConstants;
import org.apache.tapestry5.ClientElement;
import org.apache.tapestry5.annotations.AfterRender;
import org.apache.tapestry5.annotations.Import;
import org.apache.tapestry5.annotations.InjectContainer;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.json.JSONObject;
import org.apache.tapestry5.services.javascript.JavaScriptSupport;

/**
 * A simple mixin for attaching a javascript confirmation box to the onclick event of any component that implements
 * ClientElement.
 * 
 * @author <a href="mailto:chris@thegodcode.net">Chris Lewis</a> Apr 18, 2008
 */
// The @Import tells Tapestry to put a link to the file in the head of the page so that the browser will pull it in. 
@Import(library = "Confirm.js")
public class Confirm {

    @Parameter(name = "message", value = "Are you sure?", defaultPrefix = BindingConstants.LITERAL)
    private String message;

    @Inject
    private JavaScriptSupport javaScriptSupport;

    @InjectContainer
    private ClientElement clientElement;

    @AfterRender
    public void afterRender() {

        // Tell the Tapestry.Initializer to do the initializing of a Confirm, which it will do when the DOM has been
        // fully loaded.

        JSONObject spec = new JSONObject();
        spec.put("elementId", clientElement.getClientId());
        spec.put("message", message);
        javaScriptSupport.addInitializerCall("confirm", spec);
    }

}

Confirm.js


// Based on http://wiki.apache.org/tapestry/Tapestry5AndJavaScriptExplained

// A class that attaches a confirmation box (with logic)  to
// the 'onclick' event of any HTML element.
// @author Chris Lewis Apr 18, 2008 <chris@thegodcode.net>

Confirm = Class.create({
        
    initialize: function(elementId, message) {
        this.message = message;
        Event.observe($(elementId), 'click', this.doConfirm.bindAsEventListener(this));
    },
    
    doConfirm: function(e) {
        
        // Pop up a javascript Confirm Box (see http://www.w3schools.com/js/js_popup.asp)
        
        if (!confirm(this.message)) {
                e.stop();
        }
    }
        
})

// Extend the Tapestry.Initializer with a static method that instantiates a Confirm.

Tapestry.Initializer.confirm = function(spec) {
    new Confirm(spec.elementId, spec.message);
}

PersonFilteredDataSource.java


package jumpstart.web.model.together;

import java.util.List;

import jumpstart.business.domain.person.Person;
import jumpstart.business.domain.person.iface.IPersonFinderServiceRemote;

import org.apache.tapestry5.grid.GridDataSource;
import org.apache.tapestry5.grid.SortConstraint;

public class PersonFilteredDataSource implements GridDataSource {
    private IPersonFinderServiceRemote personFinderService;
    private String partialName;

    private int startIndex;
    private List<Person> preparedResults;

    public PersonFilteredDataSource(IPersonFinderServiceRemote personFinderService, String partialName) {
        this.personFinderService = personFinderService;
        this.partialName = partialName;
    }

    @Override
    public int getAvailableRows() {
        return (int) personFinderService.countPersons(partialName);
    }

    @Override
    public void prepare(final int startIndex, final int endIndex, final List<SortConstraint> sortConstraints) {
        preparedResults = personFinderService.findPersons(partialName, startIndex, endIndex - startIndex + 1);
        this.startIndex = startIndex;
    }

    @Override
    public Object getRowValue(final int index) {
        return preparedResults.get(index - startIndex);
    }

    @Override
    public Class<Person> getRowType() {
        return Person.class;
    }

}

Person.java


package jumpstart.business.domain.person;

import java.io.Serializable;
import java.util.Date;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import javax.persistence.Version;


/**
 * The Person entity.
 */
@Entity
@SuppressWarnings("serial")
public class Person implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(nullable = false)
    private Long id;

    @Version
    @Column(nullable = false)
    private Integer version;

    @Column(length = 10, nullable = false)
    private String firstName;

    @Column(length = 10, nullable = false)
    private String lastName;
    
    @Enumerated(EnumType.STRING)
    private Regions region;

    @Temporal(TemporalType.DATE)
    private Date startDate;

    public String toString() {
        final String DIVIDER = ", ";
        
        StringBuilder buf = new StringBuilder();
        buf.append(this.getClass().getSimpleName() + ": ");
        buf.append("[");
        buf.append("id=" + id + DIVIDER);
        buf.append("version=" + version + DIVIDER);
        buf.append("firstName=" + firstName + DIVIDER);
        buf.append("lastName=" + lastName + DIVIDER);
        buf.append("region=" + region + DIVIDER);
        buf.append("startDate=" + startDate);
        buf.append("]");
        return buf.toString();
    }

    // Default constructor is required by EJB3.
    public Person() {
    }

    public Person(String firstName, String lastName, Regions region, Date startDate) {
        super();
        this.firstName = firstName;
        this.lastName = lastName;
        this.region = region;
        this.startDate = startDate;
    }

    // The need for an equals() method is discussed at http://www.hibernate.org/109.html
    
    @Override
    public boolean equals(Object obj) {
        return (obj == this) || (obj instanceof Person) && id != null && id.equals(((Person) obj).getId());
    }

    // The need for a hashCode() method is discussed at http://www.hibernate.org/109.html

    @Override
    public int hashCode() {
        return id == null ? super.hashCode() : id.hashCode();
    }

    @PrePersist
    @PreUpdate
    public void validate() throws ValidationException {

        // Validate syntax...

        if ((firstName == null) || (firstName.trim().length() == 0)) {
            throw new ValidationException("First name is required.");
        }

        if ((lastName == null) || (lastName.trim().length() == 0)) {
            throw new ValidationException("Last name is required.");
        }

        if (region == null) {
            throw new ValidationException("Region is required.");
        }

        if (startDate == null) {
            throw new ValidationException("Start date is required.");
        }

    }

    public Long getId() {
        return id;
    }

    public Integer getVersion() {
        return version;
    }

    public void setVersion(Integer version) {
        this.version = version;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public Regions getRegion() {
        return region;
    }

    public void setRegion(Regions region) {
        this.region = region;
    }

    public Date getStartDate() {
        return startDate;
    }

    public void setStartDate(Date startDate) {
        this.startDate = startDate;
    }

}

Regions.java


package jumpstart.business.domain.person;

public enum Regions {
    EAST_COAST, WEST_COAST;
}