Components CRUD

This example is based on the Filter CRUD example, replacing the list and the editor with two custom components: PersonList and PersonEditor.
Create...
Person
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: ComponentResourcesCommon, @ActivationRequestParameter.

Home

The source for PersonFinderService, @EJB handling, etc. is shown in the @EJB 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/componentscrud.css}"/>
</head>
<body>
    <h1>Components CRUD</h1>
    
    This example is based on the <em>Filter CRUD</em> example, replacing the list and the editor with two custom components: 
    PersonList and PersonEditor.<br/>

    <div class="eg">
        <a t:type="eventLink" t:event="toCreate" 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:together.componentscrud.PersonList t:id="list" partialName="partialName" selectedPersonId="listPersonId"/>
                    </td>
                    
                    <!-- This is the right side of the table: where a Person will be created, reviewed, or updated. -->
            
                    <td id="editorCell">
                        <t:together.componentscrud.PersonEditor t:id="editor" mode="editorMode" personId="editorPersonId"/>
                    </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/apidocs/org/apache/tapestry5/ComponentResourcesCommon.html">ComponentResourcesCommon</a>, 
    <a href="http://tapestry.apache.org/5.3/apidocs/org/apache/tapestry5/annotations/ActivationRequestParameter.html">@ActivationRequestParameter</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/><br/>

    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/together/componentscrud/Persons.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/together/componentscrud/Persons.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/componentscrud/PersonList.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/componentscrud/PersonList.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/componentscrud/PersonEditor.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/componentscrud/PersonEditor.properties"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/together/componentscrud/PersonEditor.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/css/together/componentscrud.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.componentscrud;

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

import org.apache.tapestry5.EventContext;
import org.apache.tapestry5.annotations.ActivationRequestParameter;
import org.apache.tapestry5.annotations.Property;

public class Persons {

    // The activation context

    @Property
    private Mode editorMode;

    @Property
    private Long editorPersonId;

    // Screen fields

    @Property
    @ActivationRequestParameter
    private String partialName;

    @Property
    private Long listPersonId;

    // The code

    // onPassivate() is called by Tapestry to get the activation context to put in the URL.

    Object[] onPassivate() {

        if (editorMode == null) {
            return null;
        }
        else if (editorMode == Mode.CREATE) {
            return new Object[] { editorMode };
        }
        else if (editorMode == Mode.REVIEW || editorMode == Mode.UPDATE) {
            return new Object[] { editorMode, editorPersonId };
        }
        else {
            throw new IllegalStateException(editorMode.toString());
        }

    }

    // onActivate() is called by Tapestry to pass in the activation context from the URL.

    void onActivate(EventContext ec) {

        if (ec.getCount() == 0) {
            editorMode = null;
            editorPersonId = null;
        }
        else if (ec.getCount() == 1) {
            editorMode = ec.get(Mode.class, 0);
            editorPersonId = null;
        }
        else {
            editorMode = ec.get(Mode.class, 0);
            editorPersonId = ec.get(Long.class, 1);
        }

    }

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

    void setupRender() {
        listPersonId = editorPersonId;
    }

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

    // Handle event "toCreate" from this page

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

}

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/componentscrud.css"/>

<t:content>

    <div id="personFilter">
        <form t:type="form" t:id="filterForm">
            <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">[Grid here]
            <p:nameCell>
                <a t:type="eventLink" t:event="selected" t:context="person.id" class="prop:linkCSSClass" 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.componentscrud;

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.annotations.Events;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.grid.GridDataSource;

/**
 * This component will trigger the following events on its container (which in this example is the page):
 * {@link jumpstart.web.components.examples.component.crud.PersonList#SELECTED}(Long personId).
 */
// @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;

    // The code

    // 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/componentscrud.css"/>

<t:content>

    <t:if test="modeCreate">
        <h1>Create</h1>
        
        <form t:type="form" t:id="createForm" >
            <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" 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" href="#">Update...</a>
                <a t:type="eventLink" t:event="delete" t:context="[person.id,person.version]" href="#" 
                    t:mixins="Confirm" t:message="Delete ${person.firstName} ${person.lastName}?">Delete...</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="form" t:id="updateForm">
            <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="person.id" 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>

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

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.component.crud.PersonEditor#CANCEL_CREATE},
 * {@link jumpstart.web.components.examples.component.crud.PersonEditor#SUCCESSFUL_CREATE}(Long personId),
 * {@link jumpstart.web.components.examples.component.crud.PersonEditor#FAILED_CREATE},
 * {@link jumpstart.web.components.examples.component.crud.PersonEditor#TO_UPDATE}(Long personId),
 * {@link jumpstart.web.components.examples.component.crud.PersonEditor#SUCCESSFUL_UPDATE}(Long personId),
 * {@link jumpstart.web.components.examples.component.crud.PersonEditor#FAILED_UPDATE}(Long personId),
 * {@link jumpstart.web.components.examples.component.crud.PersonEditor#SUCCESSFUL_DELETE}(Long personId),
 * {@link jumpstart.web.components.examples.component.crud.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.SUCCESFUL_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 SUCCESFUL_DELETE = "successfulDelete";
    public static final String FAILED_DELETE = "failedDelete";

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

    public enum Mode {
        CREATE, REVIEW, UPDATE;
    }

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

    // Work fields

    // This carries version through the redirect that follows a server-side validation failure.
    @Persist(PersistenceConstants.FLASH)
    private Integer versionFlash;

    // 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 {
                if (person == null) {
                    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() {
        person = personFinderService.findPerson(personId);
        // Handle null person in the template.

        // If the form has errors then we're redisplaying after a redirect.
        // Form will restore your input values but it's up to us to restore Hidden values.

        if (updateForm.getHasErrors()) {
            if (person != null) {
                person.setVersion(versionFlash);
            }
        }
    }

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

    void onPrepareForSubmitFromUpdateForm() {
        // Get objects for the form fields to overlay.
        person = personFinderService.findPerson(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() {
        versionFlash = person.getVersion();

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

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

            // Trigger new event "failedDelete" which will bubble up.
            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;
        }

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

            // Trigger new event "failedDelete" which will bubble up.
            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;
        }

        // Trigger new event "successfulDelete" which will bubble up.
        componentResources.triggerEvent(SUCCESFUL_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 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());
    }
}

componentscrud.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; }

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

#personList             { height: 240px; 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: 4px; bottom: 3px; 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; }
#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;
}