AJAX Components CRUD

This example is based on the Smaller Components CRUD example, and adds AJAX functionality with Tapestry's Zone component.

Create...

In this example we avoided depending on the activation context and activation request parameters. Deeply nested components need to be able to handle requests independently, without depending on the activation context and activation request parameters being passed down to them through layers of components.

Instead, we used Form's and EventLink's context parameter to save the containing component's parameters, and our event handler methods receive the context and restore the parameters.

However, Grid doesn't have a context! We need it so we can pass selectedPersonId with every click of a GridPager link. Without it, PersonList would no longer know who was selected. So, we created GridWithContext, we enhanced GridPager to carry the context and bubble it up in an event called PAGING, and our onPaging(...) method restores selectedPersonId.

Vote for Grid to get a context parameter for AJAX paging and column sorting here: TAP5-2297.

References: ComponentResourcesCommon, @ActivationRequestParameter, Environmental Services, FormSupport, ValidationTracker, @Component.

Home

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


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a doctype to allow us to use special characters like &nbsp; 
     We use a "strict" DTD to make IE follow the alignment rules. -->
     
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd" xmlns:p="tapestry:parameter">
<body class="container">
    <h1>AJAX Components CRUD</h1>
    
   <noscript class="js-required">
        ${message:javascript_required}
    </noscript>     

    <p>This example is based on the <em>Smaller Components CRUD</em> example, and adds AJAX functionality with Tapestry's Zone component.</p>
    
    <div class="eg">
        <t:eventLink event="toCreate" async="true">Create...</t:eventLink><br/>
        
        <table id="listAndEditor">
            <tbody>
                <tr>
    
                    <!-- This is the left side of the table: a list of Persons -->
    
                    <td id="listSide">
                        <t:together.ajaxcomponentscrud.PersonList t:id="list" selectedPersonId="listPersonId"/>
                    </td>
                    
                    <!-- This is the right side of the table: where a Person will be created, reviewed, updated, or deleted. -->
            
                    <td id="editorSide">
                        <t:zone t:id="editorZone" id="editorZone">
                            <t:if test="isFunction('create')">
                                <t:together.ajaxcomponentscrud.PersonCreate t:id="personCreate"/>
                            </t:if>
                            <t:if test="isFunction('review')">
                                <t:together.ajaxcomponentscrud.PersonReview t:id="personReview" personId="editorPersonId"/>
                            </t:if>
                            <t:if test="isFunction('update')">
                                <t:together.ajaxcomponentscrud.PersonUpdate t:id="personUpdate" personId="editorPersonId"/>
                            </t:if>
                        </t:zone>
                    </td>
                    
                </tr>
            </tbody>
        </table>
    </div>

    <p>In this example we avoided depending on the activation context and activation request parameters. 
        Deeply nested components need to be able to handle requests independently, without depending on the 
        activation context and activation request parameters being passed down to them through layers of components.</p>
        
    <p>Instead, we used Form's and EventLink's context parameter to save the containing component's parameters, 
        and our event handler methods receive the context and restore the parameters.</p>
        
    <p>However, Grid doesn't have a context! We need it so we can pass <em>selectedPersonId</em> with every click of a GridPager link. 
        Without it, PersonList would no longer know who was selected. So, we created GridWithContext, we enhanced GridPager to carry the context 
        and bubble it up in an event called PAGING, and our <code>onPaging(...)</code> method restores selectedPersonId.</p>
    
    <p class="alert alert-info">
        Vote for Grid to get a context parameter for AJAX paging and column sorting here: 
        <a href="https://issues.apache.org/jira/browse/TAP5-2297">TAP5-2297</a>.
    </p>

    References:
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/ComponentResourcesCommon.html">ComponentResourcesCommon</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/annotations/ActivationRequestParameter.html">@ActivationRequestParameter</a>, 
    <a href="http://tapestry.apache.org/environmental-services.html">Environmental Services</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/services/FormSupport.html">FormSupport</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/ValidationTracker.html">ValidationTracker</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/annotations/Component.html">@Component</a>.<br/><br/>

    <t:pagelink page="Index">Home</t:pagelink><br/><br/>
    
    The source for IPersonFinderServiceLocal and @EJB is shown in the Session Beans and @EJB examples.<br/><br/>
    <t:tabgroup>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/together/ajaxcomponentscrud/Persons.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/together/ajaxcomponentscrud/Persons.java"/>
        <t:sourcecodetab src="/web/src/main/resources/META-INF/assets/css/together/ajaxcomponentscrud.css"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonList.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonList.java"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/person-list.js"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonCreate.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonCreate.java"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonReview.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonReview.java"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonUpdate.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonUpdate.java"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonEditor.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonEditor.properties"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/together/ajaxcomponentscrud/PersonEditor.java"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/models/together/PersonFilteredDataSource.java"/>
        <t:sourcecodetab src="/business/src/main/java/jumpstart/business/domain/person/Person.java"/>
        <t:sourcecodetab src="/business/src/main/java/jumpstart/business/domain/person/Regions.java"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/GridWithContext.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/GridWithContext.java"/>
        <t:sourcecodetab src="/web/src/main/java/org/apache/tapestry5/corelib/components/GridPager.java"/>
    </t:tabgroup>
</body>
</html>


package jumpstart.web.pages.together.ajaxcomponentscrud;

import jumpstart.business.domain.person.Person;
import jumpstart.web.components.together.ajaxcomponentscrud.PersonList;

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

@Import(stylesheet = "css/together/ajaxcomponentscrud.css")
public class Persons {

    public enum Function {
        CREATE, REVIEW, UPDATE;
    }

    // Screen fields

    @Property
    private Function function;

    @Property
    private Long listPersonId;

    @Property
    private Long editorPersonId;

    // Generally useful bits and pieces

    @InjectComponent
    private PersonList list;

    @InjectComponent
    private Zone editorZone;

    @Inject
    private Request request;

    @Inject
    private AjaxResponseRenderer ajaxResponseRenderer;

    // The code

    void onToCreate() {
        function = Function.CREATE;
        editorPersonId = null;

        list.doChangeOfSelectedPerson();

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

    void onPersonSelectedFromList(Long oldSelectedPersonId, Long newSelectedPersonId) {
        function = Function.REVIEW;
        editorPersonId = newSelectedPersonId;

        list.doChangeOfSelectedPerson();

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

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

    void onCanceledFromPersonCreate() {
        function = null;
        editorPersonId = null;

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

    void onCreatedFromPersonCreate(Person person) {
        function = Function.REVIEW;
        editorPersonId = person.getId();
        listPersonId = person.getId();

        list.doChangeOfSelectedPerson();

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

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

    void onToUpdateFromPersonReview(Long personId) {
        function = Function.UPDATE;
        editorPersonId = personId;

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

    void onDeletedFromPersonReview(Long personId) {
        function = null;
        editorPersonId = null;
        listPersonId = null;

        list.doChangeOfSelectedPerson();

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

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

    void onCanceledFromPersonUpdate(Long personId) {
        function = Function.REVIEW;
        editorPersonId = personId;

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

    void onUpdatedFromPersonUpdate(Person person) {
        function = Function.REVIEW;
        editorPersonId = person.getId();
        listPersonId = person.getId();

        list.doChangeOfSelectedPerson();

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

    // /////////////////////////////////////////////////////////////////////
    // GETTERS ETC.
    // /////////////////////////////////////////////////////////////////////

    public boolean isFunction(Function function) {
        return this.function == function;
    }
}


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

.eg {
                margin: 20px 0;
                padding: 20px;
                color: #888;
                border: 1px solid #ddd;
                border-radius: 4px;
                -webkit-border-radius: 4px;
                -mox-border-radius: 4px;
                font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
                font-size: 13px;
                font-weight: normal;
                color: #333;
                line-height: 17px;
}

.eg a {
                text-decoration: none;
                color: #3D69B6;
}

.eg a:hover {
                text-decoration: underline;
}

#listAndEditor {
                width: 100%;
                max-width: 800px;
                border: none;
                margin: 10px 0 0;
}

#listSide {
                width: 25%;
                border-right: 3px solid white;
                vertical-align: top;
                padding: 0;
}

#listContainer {
                height: 328px;
                background-color: #eee;
}

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

#personFilter input[type="search"] {
                width: 90%;
                margin: 0 5%;
}

#personList {
                background-color: #eee;
}

#personList table {
                margin: 0;
}

#personList table th {
                display: none;
}

#personList table td {
                padding: 0;
                border: 0;
                border-bottom: 2px solid white;
                background-color: #eee;
}

#personList table a {
                width: 100%;
                line-height: 50px;
                display: block;
                text-align: center;
                text-decoration: none;
                color: black;
}

#personList table a:visited {
                color: inherit;
}

#personList table a:hover {
                background: #ccc;
                color: #fff;
                text-decoration: none;
}

#personList table a.active {
                background: #999;
                color: #fff;
}

#personList .pagination {
            margin: 9px 9px 7px;                
}

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

#editorSide {
                width: 75%;
                height: 100%;
                vertical-align: top;
                background-color: #eee;
                padding: 20px;
}

#editorSide h1 {
                font-size: large;
                text-align: center;
                margin: 0;
}

#editorSide > dl.well {
                background-color: transparent;
                border: none;
                box-shadow: none;
                margin-bottom: 0;
                padding: 19px;              
}

#editorSide form {
                padding-top: 19px;              
}

#editorSide .buttons {
                text-align: center;
}

.error {
                color: red;
                text-align: center;
                padding-top: 19px;
}

#editorSide form .error {
                padding-top: 0;
                padding-bottom: 19px;
}


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a doctype to allow us to use special characters like &nbsp; 
     We use a "strict" DTD to make IE follow the alignment rules. -->
     
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd" xmlns:p="tapestry:parameter">
<t:content>

    <div id="listContainer">

        <div id="personFilter">
            <t:form t:id="filterForm" context="selectedPersonId" async="true">
                <div>
                    Person
                </div>
                <div>
                    <t:textfield t:id="partialName" type="search" placeholder="Filter"/>
                    <t:submit t:id="submitFilter" value="Filter" title="Filter" style="display: none;"/>
                </div>
            </t:form>
        </div>
        
        <t:zone t:id="personsZone" id="personsZone">

            <div id="personList">
                <t:gridwithcontext t:id="list" source="persons" row="person" class="table"
                    exclude="id,version,firstName,lastName,region,startDate" add="name"
                    rowsPerPage="4" pagerPosition="bottom"
                    empty="block:emptyPersons" inplace="true" context="[partialName,selectedPersonId]">
                    <p:nameCell>
                        <t:eventLink event="personSelected" context="[selectedPersonId,person.id]" class="prop:linkCSSClass" async="true">
                            ${person.firstName} ${person.lastName}
                        </t:eventLink>
                    </p:nameCell>
                </t:gridwithcontext>
            </div>
            
            <t:block t:id="emptyPersons">
                <div id="noPersons">
                    (No persons found)
                </div>
            </t:block>

        </t:zone>
    
    </div>

</t:content>
</html>


package jumpstart.web.components.together.ajaxcomponentscrud;

import javax.ejb.EJB;

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

import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.EventConstants;
import org.apache.tapestry5.annotations.Events;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.Form;
import org.apache.tapestry5.corelib.components.Submit;
import org.apache.tapestry5.corelib.components.Zone;
import org.apache.tapestry5.grid.GridDataSource;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.services.Request;
import org.apache.tapestry5.services.ajax.AjaxResponseRenderer;
import org.apache.tapestry5.services.ajax.JavaScriptCallback;
import org.apache.tapestry5.services.javascript.JavaScriptSupport;

/**
 * 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#PERSON_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.PERSON_SELECTED })
public class PersonList {
    public static final String PERSON_SELECTED = "personSelected";

    // Parameters

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

    // Screen fields

    @Property
    private String partialName;

    @Property
    private Person person;

    // Generally useful bits and pieces

    @EJB
    private IPersonFinderServiceLocal personFinderService;

    @InjectComponent
    private Submit submitFilter;

    @Inject
    private JavaScriptSupport javaScriptSupport;

    @InjectComponent
    private Form filterForm;

    @InjectComponent
    private Zone personsZone;

    @Inject
    private Request request;

    @Inject
    private ComponentResources componentResources;

    @Inject
    private AjaxResponseRenderer ajaxResponseRenderer;

    // The code

    void afterRender() {
        javaScriptSupport
                .require("components/together/ajaxcomponentscrud/person-list")
                .invoke("init").with(submitFilter.getClientId());
    }

    void onPrepareForSubmit(Long selectedPersonId) {
        this.selectedPersonId = selectedPersonId;
    }

    boolean onSuccess() {

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

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

    boolean onPersonSelected(Long oldSelectedPersonId, Long newSelectedPersonId) {
        this.selectedPersonId = newSelectedPersonId;
        // Let it bubble up.
        return false;
    }

    boolean onPaging(String partialName, Long selectedPersonId) {
        this.partialName = partialName;
        this.selectedPersonId = selectedPersonId;
        return true;
    }

    public void doChangeOfSelectedPerson() {

        if (request.isXHR()) {

            // At this point only the client-side filterForm knows the latest
            // partialName. So we'll refresh the list by
            // asking JavaScript to submit the filterForm.
            // But also at this point the client-side filterForm does not know
            // the latest selectedPersonId. So we'll
            // ask JavaScript to update the filterForm's context before
            // submitting it.

            // Updating the context is a bit messy: client-side, a Form's
            // context is within an ActionLink URI. We'll
            // generate a replacement ActionLink URI and give it to the
            // JavaScript.

            String actionURI = componentResources.createFormEventLink(
                    EventConstants.ACTION, (Object[]) null).toURI();

            String actionWithSelectedPersonURI = componentResources
                    .createFormEventLink(EventConstants.ACTION,
                            new Object[] { selectedPersonId }).toURI();

            String filterFormActionWithSelectedPersonURI = actionWithSelectedPersonURI
                    .replace(actionURI, actionURI + ".filterForm");

            ajaxResponseRenderer
                    .addCallback(makeJavaScriptCallbackToSubmitPersonList(filterFormActionWithSelectedPersonURI));
        }

    }

    private JavaScriptCallback makeJavaScriptCallbackToSubmitPersonList(
            final String updatedFilterFormAction) {

        return new JavaScriptCallback() {
            public void run(JavaScriptSupport javaScriptSupport) {
                javaScriptSupport
                        .require(
                                "components/together/ajaxcomponentscrud/person-list")
                        .invoke("submit").with(updatedFilterFormAction);
            }
        };

    }

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

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


define(["jquery"], function($) {

    var $submit;

    var init = function(submitId) {
        $submit = $("#" + submitId);
    };
    
    var submit = function(updatedFilterFormAction) {
        $form = $submit.closest("form");
        $form.attr("action", updatedFilterFormAction);
        $submit.click();
    }
    
    return {
        init : init,
        submit : submit
    }

});


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a doctype to allow us to use special characters like &nbsp; 
     We use a "strict" DTD to make IE follow the alignment rules. -->
     
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd" xmlns:p="tapestry:parameter">
<t:content>

    <h1>Create</h1>
    
    <t:zone t:id="formZone" id="formZone">
        <t:form t:id="form" class="form-horizontal" validate="person" async="true">

            <t:errors globalOnly="true"/>
            
            <t:together.smallercomponentscrud.PersonEditor person="person"/>
    
            <div class="form-group">
                <div class="col-sm-4 col-sm-offset-4">
                    <t:submit value="Save" />
                    <t:eventlink event="cancel" class="btn btn-default" async="true">Cancel</t:eventlink>
                </div>
            </div>
        </t:form>
    </t:zone>

</t:content>
</html>


package jumpstart.web.components.together.ajaxcomponentscrud;

import javax.ejb.EJB;

import jumpstart.business.domain.person.Person;
import jumpstart.business.domain.person.iface.IPersonFinderServiceLocal;
import jumpstart.business.domain.person.iface.IPersonManagerServiceLocal;
import jumpstart.util.ExceptionUtil;

import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.EventConstants;
import org.apache.tapestry5.annotations.Events;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.Form;
import org.apache.tapestry5.corelib.components.Zone;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.services.Request;
import org.apache.tapestry5.services.ajax.AjaxResponseRenderer;

/**
 * This component will trigger the following events on its container (which in this example is the page):
 * {@link PersonCreate#CANCELED}, {@link PersonCreate#CREATED}(Long personId).
 */
// @Events is applied to a component solely to document what events it may
// trigger. It is not checked at runtime.
@Events({ EventConstants.CANCELED, PersonCreate.CREATED })
public class PersonCreate {
    public static final String CANCELED = "canceled";
    public static final String CREATED = "created";

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

    // Parameters

    // Screen fields

    @Property
    private Person person;

    // Generally useful bits and pieces

    @EJB
    private IPersonFinderServiceLocal personFinderService;

    @EJB
    private IPersonManagerServiceLocal personManagerService;

    @InjectComponent
    private Form form;

    @InjectComponent
    private Zone formZone;

    @Inject
    private Request request;

    @Inject
    private ComponentResources componentResources;

    @Inject
    private AjaxResponseRenderer ajaxResponseRenderer;

    // The code

    boolean onCancel() {
        componentResources.triggerEvent(CANCELED, new Object[] {}, null);
        // We don't want the original event to bubble up, so we return true to
        // say we've handled it.
        return true;
    }

    void onPrepareForRender() throws Exception {

        // If fresh start, make sure there's a Person object available.

        if (form.isValid()) {
            person = new Person();
        }
    }

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

    boolean onValidateFromForm() {

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

        if (form.getHasErrors()) {
            return true;
        }

        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.
            form.recordError(ExceptionUtil.getRootCauseMessage(e));
        }

        return true;
    }

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

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

    boolean onFailure() {

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

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


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a doctype to allow us to use special characters like &nbsp; 
     We use a "strict" DTD to make IE follow the alignment rules. -->
     
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd" xmlns:p="tapestry:parameter">
<t:content>

    <h1>Review</h1>
    
    <t:form t:id="form" class="form-horizontal">

        <t:if test="person">
            <t:zone t:id="messageZone" id="messageZone">
                <div t:type="if" t:test="deleteMessage" class="error">
                    ${deleteMessage}
                </div>
            </t:zone> 
            
            <t:together.smallercomponentscrud.PersonEditor person="person" disabled="true"/>
    
            <div class="buttons">
                <t:eventlink event="toUpdate" context="person.id" async="true">Update...</t:eventlink>
                <t:eventlink event="delete" context="[person.id,person.version]" async="true" 
                    t:mixins="Confirm" Confirm.message="Delete ${person.firstName} ${person.lastName}?">Delete...</t:eventlink>
            </div>
        </t:if>
    
        <t:if test="!person">
            Person ${personId} does not exist.<br/><br/>
        </t:if>
        
    </t:form>
        
</t:content>
</html>


package jumpstart.web.components.together.ajaxcomponentscrud;

import javax.ejb.EJB;

import jumpstart.business.domain.person.Person;
import jumpstart.business.domain.person.iface.IPersonFinderServiceLocal;
import jumpstart.business.domain.person.iface.IPersonManagerServiceLocal;
import jumpstart.util.ExceptionUtil;

import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.annotations.Events;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.Zone;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.services.Request;
import org.apache.tapestry5.services.ajax.AjaxResponseRenderer;

/**
 * This component will trigger the following events on its container (which in this example is the page):
 * {@link PersonReview#TO_UPDATE}(Long personId), {@link PersonReview#DELETED}(Long personId).
 */
// @Events is applied to a component solely to document what events it may
// trigger. It is not checked at runtime.
@Events({ PersonReview.TO_UPDATE, PersonReview.DELETED })
public class PersonReview {
    public static final String TO_UPDATE = "toUpdate";
    public static final String DELETED = "deleted";

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

    // Parameters

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

    // Screen fields

    @Property
    private Person person;

    @Property
    private String deleteMessage;

    // Generally useful bits and pieces

    @EJB
    private IPersonFinderServiceLocal personFinderService;

    @EJB
    private IPersonManagerServiceLocal personManagerService;

    @InjectComponent
    private Zone messageZone;

    @Inject
    private Request request;

    @Inject
    private ComponentResources componentResources;

    @Inject
    private AjaxResponseRenderer ajaxResponseRenderer;

    // The code

    void setupRender() {
        deleteMessage = null;

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

    }

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

    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.";

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

            // We don't want the event 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);

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

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

        // Trigger new event which will bubble up.
        componentResources.triggerEvent(DELETED, new Object[] { personId }, null);
        // We don't want the original event to bubble up, so we return true to
        // say we've handled it.
        return true;
    }

}


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a doctype to allow us to use special characters like &nbsp; 
     We use a "strict" DTD to make IE follow the alignment rules. -->
     
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd" xmlns:p="tapestry:parameter">
<t:content>

    <h1>Update</h1>
    
    <t:zone t:id="formZone" id="formZone">
        <t:form t:id="form" class="form-horizontal" validate="person" context="personId" async="true">
        
            <t:errors globalOnly="true"/>
        
            <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"/>
                
                <t:together.smallercomponentscrud.PersonEditor person="person"/>
    
                <div class="form-group">
                    <div class="col-sm-4 col-sm-offset-4">
                        <t:submit value="Save" />
                        <t:eventlink event="cancel" context="personId" class="btn btn-default" async="true">Cancel</t:eventlink>
                    </div>
                </div>
            </t:if>
    
            <t:if test="!person">
                Person ${personId} does not exist.<br/><br/>
            </t:if>
        </t:form>
    </t:zone>
                
</t:content>
</html>


package jumpstart.web.components.together.ajaxcomponentscrud;

import javax.ejb.EJB;

import jumpstart.business.domain.person.Person;
import jumpstart.business.domain.person.iface.IPersonFinderServiceLocal;
import jumpstart.business.domain.person.iface.IPersonManagerServiceLocal;
import jumpstart.util.ExceptionUtil;

import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.annotations.Events;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.Form;
import org.apache.tapestry5.corelib.components.Zone;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.services.Request;
import org.apache.tapestry5.services.ajax.AjaxResponseRenderer;

/**
 * This component will trigger the following events on its container (which in this example is the page):
 * {@link PersonUpdate#CANCELED}(Long personId), {@link PersonUpdate#UPDATED}(Long personId).
 */
// @Events is applied to a component solely to document what events it may
// trigger. It is not checked at runtime.
@Events({ PersonUpdate.CANCELED, PersonUpdate.UPDATED })
public class PersonUpdate {
    public static final String CANCELED = "canceled";
    public static final String UPDATED = "updated";

    // Parameters

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

    // Screen fields

    @Property
    private Person person;

    // Generally useful bits and pieces

    @EJB
    private IPersonFinderServiceLocal personFinderService;

    @EJB
    private IPersonManagerServiceLocal personManagerService;

    @InjectComponent
    private Form form;

    @InjectComponent
    private Zone formZone;

    @Inject
    private Request request;

    @Inject
    private ComponentResources componentResources;

    @Inject
    private AjaxResponseRenderer ajaxResponseRenderer;

    // The code

    boolean onCancel(Long personId) {
        this.personId = personId;

        componentResources.triggerEvent(CANCELED, new Object[] { personId }, null);
        // We don't want the event to bubble up, so we return true to say we've
        // handled it.
        return true;
    }

    void onPrepareForRender() {

        // If fresh start, make sure there's a Person object available.

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

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

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

        if (person == null) {
            form.recordError("Person has been deleted by another process.");
            // Instantiate an empty person to avoid NPE in the Form.
            person = new Person();
        }
    }

    boolean onValidateFromForm() {

        if (form.getHasErrors()) {
            return true;
        }

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

        return true;
    }

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

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

    boolean onFailure() {

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

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


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a doctype to allow us to use special characters like &nbsp; 
     We use a "strict" DTD to make IE follow the alignment rules. -->
     
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd" xmlns:p="tapestry:parameter">
<t:content>

    <div class="form-group">
        <t:label for="firstName" class="col-sm-4"/>
        <div class="col-sm-4">
            <t:textfield t:id="firstName" value="person.firstName" disabled="disabled"/>
        </div>
        <div class="col-sm-4">
            <p class="form-control-static">(required)</p>
        </div>
    </div>
    <div class="form-group">
        <t:label for="lastName" class="col-sm-4"/>
        <div class="col-sm-4">
            <t:textfield t:id="lastName" value="person.lastName" disabled="disabled"/>
        </div>
        <div class="col-sm-4">
            <p class="form-control-static">(required)</p>
        </div>
    </div>
    <div class="form-group">
        <t:label for="region" class="col-sm-4"/>
        <div class="col-sm-4">
            <t:select t:id="region" value="person.region" blankOption="always" disabled="disabled"/>
        </div>
        <div class="col-sm-4">
            <p class="form-control-static">(required)</p>
        </div>
    </div>
    <div class="form-group">
        <t:label for="startDate" class="col-sm-4"/>
        <div class="col-sm-4">
            <t:datefield t:id="startDate" value="person.startDate" format="prop:dateFormat" disabled="disabled"/>
        </div>
        <div class="col-sm-4">
            <p class="form-control-static">(required, ${datePattern})</p>
        </div>
    </div>

</t:content>
</html>


region-blankLabel=Choose...

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


package jumpstart.web.components.together.ajaxcomponentscrud;

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

import jumpstart.business.domain.person.Person;
import jumpstart.business.domain.person.Regions;

import org.apache.tapestry5.ComponentAction;
import org.apache.tapestry5.ValidationTracker;
import org.apache.tapestry5.annotations.Environmental;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.TextField;
import org.apache.tapestry5.ioc.Messages;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.services.FormSupport;

public class PersonEditor {

    // Parameters

    @Parameter(required = true, allowNull = false)
    @Property
    private Person person;

    @Parameter(value = "false")
    @Property
    private boolean disabled;

    // Generally useful bits and pieces

    @InjectComponent("firstName")
    private TextField firstNameField;
    
    @Inject
    private Messages messages;

    @Environmental
    private FormSupport formSupport;

    @Environmental
    private ValidationTracker tracker;

    private static final ProcessSubmission PROCESS_SUBMISSION = new ProcessSubmission();

    // The code

    void afterRender() {

        // If we are inside a form, ask FormSupport to store PROCESS_SUBMISSION in its list of actions to do on submit.
        // If I contain other components, their actions will already be in the list, before PROCESS_SUBMISSION. That is
        // because this method, afterRender(), is late in the sequence. This guarantees PROCESS_SUBMISSION will be
        // executed on submit AFTER the components I contain are processed (which includes their validation).

        if (formSupport != null) {
            formSupport.store(this, PROCESS_SUBMISSION);
        }

    }

    private static class ProcessSubmission implements ComponentAction<PersonEditor> {
        private static final long serialVersionUID = 1L;

        public void execute(PersonEditor component) {
            component.processSubmission();
        }

        @Override
        public String toString() {
            return this.getClass().getSimpleName() + ".ProcessSubmission";
        }
    };

    private void processSubmission() {

        // Validate. We ensured in afterRender() that the components I contain have already been validated.

        if (person.getFirstName() != null && person.getFirstName().equals("Acme")) {
            tracker.recordError(firstNameField, firstNameField.getLabel() + " must not be Acme.");
        }

        if (person.getId() != null && person.getId() == 2 && !person.getFirstName().equals("Mary")) {
            tracker.recordError(firstNameField, firstNameField.getLabel() + " for this person must be Mary.");
        }

    }

    // ////////////////////////////////////////////////////////////////////////
    // GETTERS ETC.
    // ////////////////////////////////////////////////////////////////////////

    public String getPersonRegion() {
        return messages.get(Regions.class.getSimpleName() + "." + person.getRegion().name());
    }

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

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


package jumpstart.web.models.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;
    }

}


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;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;


/**
 * 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)
    @NotNull
    @Size(max = 10)
    private String firstName;

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

    @Temporal(TemporalType.DATE)
    @NotNull
    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 JPA.
    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 {

    }

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

}


package jumpstart.business.domain.person;

public enum Regions {
    EAST_COAST, WEST_COAST;
}


<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
<t:content>
    <t:grid t:id="grid">
        <t:body/>
    </t:grid>
</t:content>
</html>


package jumpstart.web.components;

import javax.inject.Inject;

import org.apache.tapestry5.ClientElement;
import org.apache.tapestry5.EventContext;
import org.apache.tapestry5.MarkupWriter;
import org.apache.tapestry5.annotations.Component;
import org.apache.tapestry5.annotations.Events;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.SupportsInformalParameters;
import org.apache.tapestry5.beaneditor.BeanModel;
import org.apache.tapestry5.corelib.components.Grid;
import org.apache.tapestry5.grid.GridDataSource;
import org.apache.tapestry5.grid.GridModel;
import org.apache.tapestry5.grid.GridSortModel;
import org.apache.tapestry5.internal.EmptyEventContext;
import org.apache.tapestry5.internal.services.ArrayEventContext;
import org.apache.tapestry5.ioc.services.TypeCoercer;
import org.apache.tapestry5.services.Environment;

/**
 * Behaves mostly the same as Grid but this component also accepts a context parameter and our modified GridPager 
 * copies that context into the links that it renders.
 */
@SupportsInformalParameters
@Events({ GridWithContext.PAGING })
public class GridWithContext implements ClientElement, GridModel {

    public static final String PAGING = "Paging";

    /**
     * This list of values will be converted into strings and included in our modified GridPager's pager links. The
     * strings will be coerced back to whatever their values are and made available in the PAGING event bubbled up by
     * our modified GridPager.
     */
    @Parameter
    private Object[] context;

    // Generally useful bits and pieces

    @Component(publishParameters = "add, class, columnIndex, empty, encoder, exclude, inPlace, include, lean, model, overrides, pagerPosition, reorder, row, rowClass, rowIndex, rowsPerPage, sortModel, source, volatile", inheritInformalParameters = true)
    private Grid grid;

    @Inject
    private Environment environment;

    @Inject
    private TypeCoercer typeCoercer;

    // The code

    /**
     * This beginRender() will execute before our inner Grid's beginRender(). We'll push our context onto the
     * Environment stack for use by our modified GridPager.
     */
    void beginRender(MarkupWriter writer) {
        EventContext gridContext = context == null ? new EmptyEventContext() : new ArrayEventContext(typeCoercer,
                context);
        environment.push(EventContext.class, gridContext);
    }

    /**
     * This afterRender() will execute after our inner Grid's beginRender(). Let's undo what we did in beforeRender().
     */
    void afterRender(MarkupWriter writer) {
        environment.pop(EventContext.class);
    }

    @Override
    public String getClientId() {
        return grid.getClientId();
    }

    @Override
    public BeanModel<?> getDataModel() {
        return grid.getDataModel();
    }

    @Override
    public GridDataSource getDataSource() {
        return grid.getDataSource();
    }

    @Override
    public GridSortModel getSortModel() {
        return grid.getSortModel();
    }

}


// Copyright 2007, 2008, 2009, 2010, 2011, 2012 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package org.apache.tapestry5.corelib.components;

import java.util.ArrayList;
import java.util.List;

import jumpstart.web.components.GridWithContext;

import org.apache.tapestry5.BindingConstants;
import org.apache.tapestry5.ComponentParameterConstants;
import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.EventConstants;
import org.apache.tapestry5.EventContext;
import org.apache.tapestry5.Link;
import org.apache.tapestry5.MarkupWriter;
import org.apache.tapestry5.annotations.Environmental;
import org.apache.tapestry5.annotations.Events;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.dom.Element;
import org.apache.tapestry5.grid.GridDataSource;
import org.apache.tapestry5.internal.InternalConstants;
import org.apache.tapestry5.ioc.Messages;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.ioc.util.UnknownValueException;
import org.apache.tapestry5.services.Request;

/**
 * Generates a series of links used to jump to a particular page index within the overall data set.
 * 
 * @tapestrydoc
 */
// START OF MODIFIED SOURCE.
@Events({ GridWithContext.PAGING, InternalConstants.GRID_INPLACE_UPDATE + " (internal event)" })
// END OF MODIFIED SOURCE.
public class GridPager {
    /**
     * The source of the data displayed by the grid (this is used to determine {@link GridDataSource#getAvailableRows()
     * how many rows are available}, which in turn determines the page count).
     */
    @Parameter(required = true)
    private GridDataSource source;

    /**
     * The number of rows displayed per page.
     */
    @Parameter(required = true)
    private int rowsPerPage;

    /**
     * The current page number (indexed from 1).
     */
    @Parameter(required = true)
    private int currentPage;

    /**
     * Number of pages before and after the current page in the range. The pager always displays links for 2 * range + 1
     * pages, unless that's more than the total number of available pages.
     */
    @Parameter(BindingConstants.SYMBOL + ":" + ComponentParameterConstants.GRIDPAGER_PAGE_RANGE)
    private int range;

    /**
     * If not null, then each link is output as a link to update the specified zone.
     */
    @Parameter
    private String zone;

    private int lastIndex;

    private int maxPages;

    @Inject
    private ComponentResources resources;

    @Inject
    private Messages messages;

    @Inject
    private Request request;

    @Environmental
    private EventContext gridContext;

    private List<Object> actionContextWithoutPageNum;

    void beginRender(MarkupWriter writer) {
        int availableRows = source.getAvailableRows();

        maxPages = ((availableRows - 1) / rowsPerPage) + 1;

        if (maxPages < 2)
            return;

        writer.element("ul", "class", "pagination");

        if (zone != null) {
            writer.attributes("data-inplace-grid-links", true);
        }

        lastIndex = 0;

        for (int i = 1; i <= 2; i++)
            writePageLink(writer, i);

        int low = currentPage - range;
        int high = currentPage + range;

        if (low < 1) {
            low = 1;
            high = 2 * range + 1;
        }
        else {
            if (high > maxPages) {
                high = maxPages;
                low = high - 2 * range;
            }
        }

        for (int i = low; i <= high; i++)
            writePageLink(writer, i);

        for (int i = maxPages - 1; i <= maxPages; i++)
            writePageLink(writer, i);

        writer.end(); // ul
    }

    private void writePageLink(MarkupWriter writer, int pageIndex) {
        if (pageIndex < 1 || pageIndex > maxPages)
            return;

        if (pageIndex <= lastIndex)
            return;

        if (pageIndex != lastIndex + 1) {
            writer.element("li", "class", "disabled");
            writer.element("a", "href", "#");
            writer.write(" ... ");
            writer.end();
            writer.end();
        }

        lastIndex = pageIndex;

        if (pageIndex == currentPage) {
            writer.element("li", "class", "active");
            writer.element("a", "href", "#");
            writer.write(Integer.toString(pageIndex));
            writer.end();
            writer.end();
            return;
        }

        writer.element("li");

        // START OF MODIFIED SOURCE.

        // Create link with page index and the request's context.

        List<Object> newContext = new ArrayList<>();
        newContext.add(new Integer(pageIndex));

        // If actionContextWithoutPageNum is not null, then we're rendering an AJAX response to an action. Use it.

        if (actionContextWithoutPageNum != null) {
            newContext.addAll(actionContextWithoutPageNum);
        }

        // Else, use the context put into the Environment by Grid.

        else {
            try {
                for (int i = 0; i < gridContext.getCount(); i++) {
                    newContext.add(gridContext.get(Object.class, i));
                }
            }
            catch (UnknownValueException e) {
                // This occurs when enclosed by Grid, not GridWithContext. Ignore it.
            }
        }

        Link link = resources.createEventLink(EventConstants.ACTION, newContext.toArray());

        // END OF MODIFIED SOURCE.

        if (zone != null) {
            link.addParameter("t:inplace", "true");
        }

        Element element = writer.element("a", "href", link, "data-update-zone", zone, "title",
                messages.format("core-goto-page", pageIndex));

        writer.write(Integer.toString(pageIndex));

        writer.end();

        writer.end(); // li
    }

    /**
     * Repaging event handler.
     */
    boolean onAction(EventContext actionContext) {
        // TODO: Validate newPage in range

        // START OF MODIFIED SOURCE.

        currentPage = actionContext.get(Integer.class, 0);

        actionContextWithoutPageNum = new ArrayList<>();

        for (int i = 1; i < actionContext.getCount(); i++) {
            actionContextWithoutPageNum.add(actionContext.get(Object.class, i));
        }

        resources.triggerEvent(GridWithContext.PAGING, actionContextWithoutPageNum.toArray(), null);

        // END OF MODIFIED SOURCE.

        if (request.isXHR()) {
            resources.triggerEvent(InternalConstants.GRID_INPLACE_UPDATE, null, null);
        }

        return true; // abort event
    }
}