Graceful AJAX Filter CRUD

This example is based on AJAX Filter CRUD, modified to handle when the browser has JavaScript support turned off.
Highlight zone updates? This shows you which zones are updated by the Ajax response.

Create...

Create

: (required)
: (required)
: (required)
: [Show] (required, dd/MM/yyyy)
References: @ActivationRequestParameter.

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/gracefulajaxfiltercrud.css}"/>
</head>
<body>
    <h1>Graceful AJAX Filter CRUD</h1>
    
    This example is based on <em>AJAX Filter CRUD</em>, modified to handle when the browser has JavaScript support turned off.<br/>
    
    <div class="eg">
        <form t:type="form" t:id="preferencesForm">
            <noscript class="js-recommended">
                ${message:javascript_recommended}
            </noscript>     
            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">
        
                            <!-- 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="listPersons" t:row="listPerson"
                                    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="ajax">[Grid here]
                                    <p:nameCell>
                                        <a t:type="eventLink" t:event="selected" t:context="listPerson.id" class="prop:linkCSSClass" t:zone="^" href="#">
                                            ${listPerson.firstName} ${listPerson.lastName}
                                        </a>
                                    </p:nameCell>
                                </table>
                            </div>
                            
                            <t:block t:id="emptyPersons">
                                <div id="noPersons">
                                    (No persons found)
                                </div>
                            </t:block>
                            
                        </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: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="editorPerson.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="editorPerson.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="editorPerson.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="editorPerson.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="editorPerson">
                                    <div t:type="if" t:test="deleteMessage" class="error">
                                        ${deleteMessage}
                                    </div>
        
                                    <table>
                                        <tr>
                                            <th>Id:</th>
                                            <td>${editorPerson.id}</td>
                                        </tr>
                                        <tr>
                                            <th>Version:</th>
                                            <td>${editorPerson.version}</td>
                                        </tr>
                                        <tr>
                                            <th>Name:</th>
                                            <td>${editorPerson.firstName} ${editorPerson.lastName}</td>
                                        </tr>
                                        <tr>
                                            <th>Region:</th>
                                            <td>${editorPersonRegion}</td>
                                        </tr>
                                        <tr>
                                            <th>Start Date:</th>
                                            <td><t:output value="editorPerson.startDate" format="prop:dateFormat"/></td>
                                        </tr>
                                    </table>
        
                                    <div class="buttons">
                                        <a t:type="eventLink" t:event="toUpdate" t:context="editorPerson.id" t:zone="^" href="#">Update...</a>
                                        <a t:type="eventLink" t:event="delete" t:context="[editorPerson.id,editorPerson.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 ${editorPerson.firstName} ${editorPerson.lastName}?">
                                                Delete...
                                            </span>
                                        </a>
                                    </div>
        
                                </t:if>
                                <t:if negate="true" test="editorPerson">
                                    Person ${editorPersonId} 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="editorPersonId" t:zone="^">
                                    <t:errors/>
                            
                                    <t:if test="editorPerson">
                                        <!-- 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="editorPerson.version"/>
                                
                                        <table>
                                            <tr>
                                                <th><t:label for="updFirstName"/>:</th>
                                                <td><input t:type="TextField" t:id="updFirstName" value="editorPerson.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="editorPerson.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="editorPerson.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="editorPerson.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="editorPersonId" t:zone="^" href="#">Cancel</a>
                                            <input t:type="submit" value="Save"/>
                                        </div>
                                        
                                    </t:if>
                                    <t:if negate="true" test="editorPerson">
                                        Person ${editorPersonId} does not exist.<br/><br/>
                                    </t:if>
                                </form>
                                
                            </t:if>
                            
                            <!--  This next mode is only used when JavaScript is disabled. -->
    
                            <t:if test="modeConfirmDelete">
                                <h1>Delete</h1>
                                
                                <form t:type="form" t:id="confirmDeleteForm" t:zone="^">
                                    <t:errors/>
                                
                                    <t:if test="editorPerson">
                                        <!-- 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="editorPerson.version"/>
                                
                        
                                        <div class="buttons">
                                            Delete ${editorPerson.firstName} ${editorPerson.lastName}?<br/><br/>
                                            
                                            <a t:type="eventLink" t:event="cancelConfirmDelete" t:context="editorPerson.id" t:zone="^" href="#">Cancel</a>
                                            <input type="submit" value="Delete"/>
                                        </div>
                                                
                                    </t:if>
                                </form>
                        
                                <t:if negate="true" test="editorPerson">
                                    Person ${editorPersonId} does not exist.<br/><br/>
                                </t:if>
                                
                            </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:zone> 
                    </td>
                    
                </tr>
            </tbody>
        </table>
    </div>
    
    References: 
    <a href="http://tapestry.apache.org/5.3.7/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/>
    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/gracefulajaxfiltercrud/Persons.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/together/gracefulajaxfiltercrud/Persons.properties"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/together/gracefulajaxfiltercrud/Persons.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/css/together/gracefulajaxfiltercrud.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.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

Persons.java


package jumpstart.web.pages.together.gracefulajaxfiltercrud;

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 jumpstart.web.model.together.PersonFilteredDataSource;

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

public class Persons {

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

    private enum Mode {
        CREATE, REVIEW, UPDATE, CONFIRM_DELETE;
    }

    // The activation context

    @Property
    private Mode editorMode;

    @Property
    private Long editorPersonId;

    @ActivationRequestParameter
    private Mode arpEditorMode;

    @ActivationRequestParameter
    private Long arpEditorPersonId;

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

    @Property
    private Person editorPerson;

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

    @Component
    private Form confirmDeleteForm;

    @InjectComponent
    private Zone listZone;

    @InjectComponent
    private Zone editorZone;

    @Inject
    private Messages messages;

    @Inject
    private Request request;

    @Inject
    private AjaxResponseRenderer ajaxResponseRenderer;

    // The code

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

    Object[] onPassivate() {

        if (request.isXHR()) {
            arpEditorMode = editorMode;
            arpEditorPersonId = editorPersonId;

            return null;
        }
        else {

            if (editorMode == null) {
                return null;
            }
            else if (editorMode == Mode.CREATE) {
                return new Object[] { editorMode };
            }
            else if (editorMode == Mode.REVIEW || editorMode == Mode.UPDATE || editorMode == Mode.CONFIRM_DELETE) {
                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 (request.isXHR()) {
            editorMode = arpEditorMode;
            editorPersonId = arpEditorPersonId;
        }
        else {
            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() {
        if (editorMode == Mode.REVIEW) {
            if (editorPersonId == null) {
                editorPerson = null;
                // Handle null editorPerson in the template.
            }
            else {
                editorPerson = personFinderService.findPerson(editorPersonId);
                // Handle null editorPerson in the template.
            }
        }
    }

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

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

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

    // Handle event "toCreate"

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

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

    // Handle event "cancelCreate"

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

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

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

    void onPrepareFromCreateForm() throws Exception {
        editorMode = Mode.CREATE;
        // Instantiate a Person for the form data to overlay.
        editorPerson = 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 {
            editorPerson = personManagerService.createPerson(editorPerson);
        }
        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

    void onSuccessFromCreateForm() {
        editorMode = Mode.REVIEW;
        editorPersonId = editorPerson.getId();

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

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

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

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

    // Handle event "selected"

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

        if (request.isXHR()) {
            editorPerson = personFinderService.findPerson(personId);
            ajaxResponseRenderer.addRender(listZone).addRender(editorZone);
        }
    }

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

    // Handle event "toUpdate"

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

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

    // Handle event "cancelUpdate"

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

        if (request.isXHR()) {
            editorPerson = personFinderService.findPerson(personId);
            ajaxResponseRenderer.addRender(editorZone);
        }
    }

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

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

        if (request.isXHR()) {

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

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

        }
        else {
            editorPerson = personFinderService.findPerson(personId);
            // Handle null editorPerson 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 (editorPerson != null) {
                    editorPerson.setVersion(versionFlash);
                }
            }

        }
    }

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

    void onPrepareForSubmitFromUpdateForm(Long personId) {
        editorPersonId = personId;

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

        if (editorPerson == null) {
            editorPerson = 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(editorPerson);
        }
        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

    void onSuccessFromUpdateForm() {
        editorMode = Mode.REVIEW;

        if (request.isXHR()) {
            editorPerson = personFinderService.findPerson(editorPersonId);
            ajaxResponseRenderer.addRender(listZone).addRender(editorZone);
        }
    }

    void onFailureFromUpdateForm() {
        editorMode = Mode.UPDATE;

        if (request.isXHR()) {
            ajaxResponseRenderer.addRender(editorZone);
        }
        else {
            versionFlash = editorPerson.getVersion();
        }
    }

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

    // Handle event "delete"

    void onDelete(Long personId, Integer personVersion) {

        // If request is AJAX then the user has pressed Delete..., was presented with a Confirm dialog, and OK'd it.

        if (request.isXHR()) {
            editorPersonId = 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) {
                editorMode = null;
                editorPersonId = null;

                if (request.isXHR()) {
                    ajaxResponseRenderer.addRender(listZone).addRender(editorZone);
                }
            }
            else {
                editorMode = Mode.REVIEW;
                editorPersonId = personId;

                if (request.isXHR()) {
                    editorPerson = personFinderService.findPerson(personId);
                    ajaxResponseRenderer.addRender(editorZone);
                }
            }

        }

        // Else, (JavaScript disabled) user has pressed Delete..., but not yet confirmed so go to a confirmation screen.

        else {
            editorMode = Mode.CONFIRM_DELETE;
            editorPersonId = personId;
        }

    }

    // /////////////////////////////////////////////////////////////////////
    // CONFIRM DELETE - used only when JavaScript is disabled.
    // /////////////////////////////////////////////////////////////////////

    // Handle event "cancelConfirmDelete"

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

    // Component "confirmDeleteForm" bubbles up the PREPARE_FOR_RENDER event during form render.

    void onPrepareForRenderFromConfirmDeleteForm() {
        editorMode = Mode.CONFIRM_DELETE;

        editorPerson = personFinderService.findPerson(editorPersonId);
        // Handle null editorPerson 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 (confirmDeleteForm.getHasErrors()) {
            if (editorPerson != null) {
                editorPerson.setVersion(versionFlash);
            }
        }
    }

    // Component "confirmDeleteForm" bubbles up the PREPARE_FOR_SUBMIT event during form submission.

    void onPrepareForSubmitFromConfirmDeleteForm() {
        // Get objects for the form fields to overlay.
        editorPerson = personFinderService.findPerson(editorPersonId);

        if (editorPerson == null) {
            editorPerson = new Person();
            confirmDeleteForm.recordError("Person has already been deleted by another process.");
        }
    }

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

    void onValidateFromConfirmDeleteForm() {

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

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

            try {
                personManagerService.deletePerson(editorPersonId, editorPerson.getVersion());
            }
            catch (Exception e) {
                // Display the cause. In a real system we would try harder to get a user-friendly message.
                confirmDeleteForm.recordError(ExceptionUtil.getRootCauseMessage(e));
            }

        }

    }

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

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

    void onFailureFromConfirmDeleteForm() {
        editorMode = Mode.CONFIRM_DELETE;
        versionFlash = editorPerson.getVersion();
    }

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

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

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

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

    public boolean isAjax() {
        return request.isXHR();
    }

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

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

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

    public boolean isModeConfirmDelete() {
        return editorMode == Mode.CONFIRM_DELETE;
    }

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

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

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

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

gracefulajaxfiltercrud.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-recommended     { 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;
}