Access Keys:
Skip to content (Access Key - 0)

Using Tapestry

Labels

tapestry tapestry Delete
Enter labels to add to this page:
Please wait 
Looking for a label? Just start typing.

About this Tutorial

This tutorial will show you how to create master/detail screens with Tapestry 5. The list (master) screen will have the ability to sort columns, as well as page 25 records at a time. The form (detail) screen will use a nifty CSS form layout (courtesy of Wufoo). You will also configure client and server-side validation to improve your users' experience.

IntelliJ IDEA Rocks
We highly recommend using IntelliJ IDEA when developing web applications in Java. Not only is its Java and JUnit support fantastic, but it has excellent CSS and JavaScript support. Using JRebel with IDEA is likely to provide you with the most pleasant Java development experiences you've ever had.
This tutorial assumes you've created a project with the appfuse-basic-tapestry archetype and have already completed the Persistence and Services tutorials. If you're using the appfuse-modular-tapestry archetype, please morph your mind into using the web module as the root directory. If you created your project with a different web framework than Tapestry, you're likely to be confused and nothing will work in this tutorial.

Table of Contents

  1. Introduction to Tapestry
  2. Create a PersonListTest
  3. Create a PersonList class that will fetch people
  4. Create PersonList.html to show search results
  5. Create a PersonFormTest and PersonForm for edit(), save() and delete() methods
  6. Add an edit listener to PersonList.java
  7. Create PersonForm.html to edit a person
  8. Configure Validation
  9. Create a Canoo WebTest to test browser-like actions
  10. Add link to menu
Source Code
The code for this tutorial is located in the "tutorial-tapestry" module of the appfuse-demos project on Google Code. Use the following command to check it out from Subversion:
svn checkout http://appfuse-demos.googlecode.com/svn/trunk/tutorial-tapestry

Introduction to Tapestry

Tapestry is a component-based framework for developing web applications. Unlike many other Java web frameworks, Tapestry uses a component object model similar to traditional GUI frameworks. According to Howard Lewis Ship, the founder of Tapestry:

A component is an object that fits into an overall framework; the responsibilities of the component are defined by the design and structure of the framework. A component is a component, and not simply an object, when it follows the rules of the framework. These rules can take the form of classes to inherit from, naming conventions (for classes or methods) to follow, or interfaces to implement. Components can be used within the context of the framework. The framework will act as a container for the component, controlling when the component is instantiated and initialized, and dictating when the methods of the component are invoked. – Lewis Ship, Howard. Tapestry in Action. Greenwich, CT: Manning Publications Co., 2004.

The figure below shows how Tapestry fits into a web application's architecture:

Tapestry's component model allows you to have a very high level of reuse within and between projects. You can package components in JAR files and distribute them among teams and developers.

Tapestry tries to hide the Servlet API from developers. Learning Tapestry is often characterized as an "unlearning" process. GUI programmers typically have an easier time adjusting to the way things work in Tapestry. Tapestry operates in terms of objects, methods and properties, rather than URLs and query parameters. All of the URL building, page dispatching and method invocation happens transparently.

Other benefits of Tapestry include line-precise error reporting and easy-to-use HTML templates. While other frameworks use external templating systems, Tapestry has its own templating system. Tapestry templates are often HTML files, but they can also be WML or XML. You can hook into these templates by using Tapestry-specific attributes on existing HTML elements.

Create a PersonListTest

This tutorial shows you how to create a Tapestry application using test-first development. You will use JUnit and a BasePageTestCase that instantiates page classes for you.

Create a PersonListTest.java class in src/test/java/**/webapp/pages:

package org.appfuse.tutorial.webapp.pages;

import org.apache.tapestry5.dom.Element;
import org.apache.tapestry5.dom.Node;
import org.junit.Test;

import java.util.List;
import java.util.ResourceBundle;

import static org.junit.Assert.*;

public class PersonListTest extends BasePageTestCase {
    @Test
    public void testList() {
        doc = tester.renderPage("personList");
        assertNotNull(doc.getElementById("personList"));
        assertTrue(doc.getElementById("personList").find("tbody").getChildren().size() >= 2);
    }
}

This class will not compile until you create the PersonList class.

Create a PersonList that will fetch people

Create a PersonList.java file in src/main/java/**/webapp/pages:

package org.appfuse.tutorial.webapp.pages;

import org.apache.tapestry5.annotations.Component;
import org.apache.tapestry5.annotations.InjectPage;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.annotations.Service;
import org.apache.tapestry5.corelib.components.EventLink;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.appfuse.service.GenericManager;
import org.appfuse.tutorial.model.Person;
import org.slf4j.Logger;

import java.util.List;

public class PersonList extends BasePage {

    @Inject
    @Service("personManager")
    private GenericManager<Person, Long> personManager;

    @Property
    private Person person;

    @Component(parameters = {"event=add"})
    private EventLink addTop, addBottom;

    @Component(parameters = {"event=done"})
    private EventLink doneTop, doneBottom;

    public List<Person> getPersons() {
        return personManager.getAll();
    }

    Object onDone() {
        return MainMenu.class;
    }
}

Since Tapestry's PageTester class requires your template exists before tests will pass, please continue to the next step before running your test.

Create PersonList.tml to show search results

Create a _src/main/webapp/PersonList.tml page to display the list of people.

<t:layout title="message:personList.title"
          heading="message:personList.heading" menu="literal:PersonMenu"
          xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd">

    <t:messagebanner t:id="message" type="type"/>
    <p>
        <t:eventlink t:id="addTop">
            <input type="button" class="button" value="${message:button.add}"/>
        </t:eventlink>
        <t:eventlink t:id="doneTop">
            <input type="button" class="button" value="${message:button.done}"/>
        </t:eventlink>
    </p>

    <t:grid source="persons" row="person" id="personList" class="table"/>

    <p>
        <t:eventlink t:id="addBottom">
            <input type="button" class="button" value="${message:button.add}"/>
        </t:eventlink>
        <t:eventlink t:id="doneBottom">
            <input type="button" class="button" value="${message:button.done}"/>
        </t:eventlink>
    </p>

    <script type="text/javascript">
        highlightTableRows("personList");
    </script>
</t:layout>
Now if you run mvn test -Dtest=PersonListTest, your test should pass.

Nice!
BUILD SUCCESSFUL
Total time: 17 seconds

Open src/main/resources/ApplicationResources.properties and add i18n keys/values for the various "person" properties:

# -- person form --
person.id=Id
person.firstName=First Name
person.lastName=Last Name

person.added=Person has been added successfully.
person.updated=Person has been updated successfully.
person.deleted=Person has been deleted successfully.

# -- person list page --
personList.title=Person List
personList.heading=Persons

# -- person detail page --
personDetail.title=Person Detail
personDetail.heading=Person Information

Run mvn jetty:run and open http://localhost:8080/personlist in your browser. Login with admin/admin and you should see a screen similar to the figure below.

Security settings for AppFuse specify that most url-patterns should be protected (except for /signup and /passwordhint). This guarantees that clients must go through Tapestry's TapestrySpringFilter to get to view pages.

CSS Customization
If you want to customize the CSS for a particular page, you can add <body id="pageName"/> to the top of the file. This will be slurped up by SiteMesh and put into the final page. You can then customize your CSS on a page-by-page basis using something like the following:
body#pageName element.class { background-color: blue } 

Create a PersonFormTest and PersonForm for edit(), save() and delete() methods

To start creating the detail screen, create a PersonFormTest.java class in src/test/java/**/webapp/pages:

package org.appfuse.tutorial.webapp.pages;

import org.apache.tapestry5.dom.Element;
import org.apache.tapestry5.dom.Node;
import static org.junit.Assert.*;
import org.junit.Test;

import java.util.List;
import java.util.ResourceBundle;

public class PersonFormTest extends BasePageTestCase {

    @Test
    public void testCancel() throws Exception {
        doc = tester.renderPage("personList");
        Element table = doc.getElementById("personList");
        List<Node> rows = table.find("tbody").getChildren();
        String id = ((Element) rows.get(0)).find("td/a").getChildMarkup().trim();

        Element editLink = table.getElementById("person-" + id);
        doc = tester.clickLink(editLink);

        Element cancelButton = doc.getElementById("cancel");
        doc = tester.clickSubmit(cancelButton, fieldValues);

        ResourceBundle rb = ResourceBundle.getBundle(MESSAGES);

        assertTrue(doc.toString().contains("<title>" +
                rb.getString("personList.title")));
    }

    @Test
    public void testSave() throws Exception {
        doc = tester.renderPage("personForm");

        Element form = doc.getElementById("personForm");
        assertNotNull(form);

        // enter all required fields
        fieldValues.put("firstName", "Jack");
        fieldValues.put("lastName", "Raible");

        doc = tester.submitForm(form, fieldValues);

        Element errors = doc.getElementById("errorMessages");

        if (errors != null) {
            System.out.println(errors);
        }

        assertNull(doc.getElementById("errorMessages"));

        Element successMessages = doc.getElementById("successMessages");
        assertNotNull(successMessages);
        assertTrue(successMessages.toString().contains("added successfully"));
        Element table = doc.getElementById("personList");
        assertNotNull(table);
    }

    @Test
    public void testRemove() throws Exception {
        doc = tester.renderPage("personList");
        Element table = doc.getElementById("personList");
        List<Node> rows = table.find("tbody").getChildren();
        String id = ((Element) rows.get(1)).find("td/a").getChildMarkup().trim();

        Element editLink = table.getElementById("person-" + id);
        doc = tester.clickLink(editLink);

        Element deleteButton = doc.getElementById("delete");
        doc = tester.clickSubmit(deleteButton, fieldValues);
        assertTrue(doc.toString().contains("deleted successfully"));
    }
}

Nothing will compile at this point; you need to create the PersonForm that you're referring to in this test.

In src/main/java/**/webapp/pages, create a PersonForm.java class that extends AppFuse's BasePage. Populate it with the following code:

package org.appfuse.tutorial.webapp.pages;

import org.apache.tapestry5.annotations.Component;
import org.apache.tapestry5.annotations.InjectPage;
import org.apache.tapestry5.annotations.Persist;
import org.apache.tapestry5.annotations.Service;
import org.apache.tapestry5.corelib.components.Form;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.appfuse.service.GenericManager;
import org.appfuse.tutorial.model.Person;
import org.slf4j.Logger;

public class PersonForm extends BasePage {
    @Inject
    private Logger log;

    @Inject
    @Service("personManager")
    private GenericManager<Person, Long> personManager;

    @Persist
    private Person person;

    public Person getPerson() {
        return person;
    }

    /**
     * Allows setting person object from another class (i.e. PersonList)
     *
     * @param person an initialized instance
     */
    public void setPerson(Person person) {
        this.person = person;
    }

    @InjectPage
    private PersonList personList;

    @Component(id = "personForm")
    private Form form;

    private boolean cancel;
    private boolean delete;

    void onValidateForm() {
        if (!delete && !cancel) {
            // manually validate required fields or annotate the Person object
            /*if (foo.getProperty() == null || user.getProperty().trim().equals("")) {
                form.recordError("Property is a required field.");
            }*/
        }
    }

    void onActivate(Long id) {
        if (id != null) {
            person = personManager.get(id);
        }
    }

    Object onSuccess() {
        if (delete) return onDelete();
        if (cancel) return onCancel();

        log.debug("Saving person...");

        boolean isNew = (getPerson().getId() == null);

        personManager.save(person);

        String key = (isNew) ? "person.added" : "person.updated";

        if (isNew) {
            personList.addInfo(key, true);
            return personList;
        } else {
            addInfo(key, true);
            return this;
        }
    }

    void onSelectedFromDelete() {
        log.debug("Deleting person...");
        delete = true;
    }

    void onSelectedFromCancel() {
        log.debug("Cancelling form...");
        cancel = true;
    }

    Object onDelete() {
        personManager.remove(person.getId());
        personList.addInfo("person.deleted", true);
        return personList;
    }

    Object onCancel() {
        return personList;
    }
}

You might notice a number of keys in this file - "person.deleted", "person.added" and "person.updated". These are all keys that need to be in your i18n bundle (ApplicationResources.properties). You should've added these at the beginning of this tutorial.

If you look at your PersonFormTest, all the tests depend on having a record with id=1 in the database (and testRemove depends on id=2), so let's add those records to our sample data file (src/test/resources/sample-data.xml). Adding it at the bottom should work - order is not important since it (currently) does not relate to any other tables. If you already have this table, make sure the 2nd record exists so testRemove() doesn't fail.

<table name='person'>
  <column>id</column>
  <column>first_name</column>
  <column>last_name</column>
  <row>
    <value>1</value>
    <value>Matt</value>
    <value>Raible</value>
  </row>
  <row>
    <value>2</value>
    <value>Bob</value>
    <value>Johnson</value>
  </row>
</table>

DbUnit loads this file before you run any tests, so these records will be available to your PersonFormTest class. Since Tapestry's PageTester class requires your template exists before tests will pass, please continue to the next step before running your test.

Save all your files and run the tests in PersonFormTest using the command mvn test -Dtest=PersonFormTest.

BUILD SUCCESSFUL
Total time: 16 seconds

Add an edit listener to PersonList.java

To allow users to click on the list screen to get to the edit screen, you need to add onAdd() and onActionFromEdit() methods to PersonList.java. Open PersonListTest.java and add the following testEdit() method:

@Test
public void testEdit() {
    doc = tester.renderPage("personList");

    Element table = doc.getElementById("personList");
    List<Node> rows = table.find("tbody").getChildren();
    String id = ((Element) rows.get(0)).find("td/a").getChildMarkup().trim();
    Element editLink = table.getElementById("person-" + id);
    doc = tester.clickLink(editLink);

    ResourceBundle rb = ResourceBundle.getBundle(MESSAGES);

    assertTrue(doc.toString().contains("<title>" +
            rb.getString("personDetail.title")));
}

Add the aforementioned methods to PersonList.java:

@Inject
private Logger log;

@InjectPage
private PersonForm form;

Object onAdd() {
    form.setPerson(new Person());
    return form;
}

Object onActionFromEdit(Long id) {
    log.debug("fetching person with id: {}", id);
    return form;
}

Then add <t:parameter> and <t:pagelink> elements to the grid component in PersonList.tml

<t:grid source="persons" row="person" id="personList" class="table">
    <t:parameter name="idCell">
        <t:pagelink page="personform" context="person.id" id="person-${person.id}">
            ${person.id}
        </t:pagelink>
    </t:parameter>
</t:grid>

Now you need to create the view template so you can edit a person's information.

Create PersonForm.html to edit a person

Create a src/main/webapp//PersonForm.tml page to display the form:

<t:layout title="message:personDetail.title"
          heading="message:personDetail.heading" menu="literal:PersonMenu"
          xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd">

    <t:messagebanner t:id="message" type="type"/>

    <form t:id="personForm">
        <t:errors/>

        <div class="t-beaneditor">
            <t:beaneditor t:id="person" object="person" exclude="id"/>
            
            <div class="t-beaneditor-row" style="text-align: center">
                <input t:type="submit" t:id="save" id="save" value="message:button.save"/>
                <input t:type="submit" t:id="delete" id="delete" value="message:button.delete"
                       onclick="return confirmDelete('Person')"/>
                <input t:type="submit" t:id="cancel" id="cancel" value="message:button.cancel"/>
            </div>
        </div>
    </form>

    <script type="text/javascript">
        Form.focusFirstElement($("personForm"));
    </script>

</t:layout>

Run mvn jetty:run, open your browser to http://localhost:8080/personlist, and click on the Add button.

Fill in the first name and last name fields and click the Save button. This should route you to the list screen, where a success message flashes and the new person displays in the list.

Displaying success messages
AppFuse renders success and error messages using the following code that refers to the MessageBanner component in src/main/java/**/webapp/components/MessageBanner.tml.
<t:messagebanner t:id="message" type="type"/>

Configure Validation

To enable server-side validation, you need to add the PersonForm.java object so you can specify validation information with annotations. Also, you'll want to add a beforeRender() method to make sure there's a Person object that can be populated.

@Component(id = "firstName", parameters = {"value=person.firstName", "validate=required"})
private TextField firstNameField;

@Component(id = "lastName", parameters = {"value=person.lastName", "validate=required"})
private TextField lastNameField;

void beginRender() {
    if (person == null) {
        person = new Person();
    }
}

Then replace the form in PersonForm.tml with the following:

<form t:id="personForm">
    <t:errors/>

    <ul>
        <li>
            <t:label class="desc" for="firstName">${message:person.firstName}</t:label>
            <input class="text medium" type="text" t:id="firstName"/>
        </li>
        <li>
            <t:label class="desc" for="lastName">${message:person.lastName}</t:label>
            <input class="text medium" type="text" t:id="lastName"/>
        </li>
        <li class="buttonBar bottom">
            <input t:type="submit" t:id="save" id="save" value="message:button.save"/>
            <input t:type="submit" t:id="delete" id="delete" value="message:button.delete"
                   onclick="return confirmDelete('Person')"/>
            <input t:type="submit" t:id="cancel" id="cancel" value="message:button.cancel"/>
        </li>
    </ul>
</form>

After saving all your files and running mvn jetty:run, validation should kick in when you try to save this form. To test, go to http://localhost:8080/personform and try to add a new user with no first or last name. You should see the following validation errors:

With Tapestry, client-side validation is enabled by default. You can turn it off by adding clientValidation="false to the <form> tag in PersonForm.tml.

<form t:id="personForm" clientValidation="true">

You'll also need to add form.onsubmit = null to the onclick() handlers of the Delete and Cancel buttons so client-side validation is disabled when they're clicked:

<li class="buttonBar bottom">
    <input t:type="submit" t:id="save" id="save" value="message:button.save"/>
    <input t:type="submit" t:id="delete" id="delete" value="message:button.delete"
           onclick="form.onsubmit = null; return confirmDelete('Person')"/>
    <input t:type="submit" t:id="cancel" id="cancel" value="message:button.cancel"
           onclick="form.onsubmit = null"/>
</li>

After saving all your files and running mvn jetty:run, client-side validation should kick in when you try to save this form. To test, go to http://localhost:8080/personform and try to add a new user with no first or last name. You should get the following JavaScript alert:

Create a Canoo WebTest to test browser-like actions

The next (optional) step in this tutorial is to create a Canoo WebTest to test your UI. This step is optional, because you can run the same tests manually through your browser. Regardless, it's a good idea to automate as much of your testing as possible.

You can use the following URLs to test the different actions for adding, editing and saving a user.

WebTest Recorder
There is a WebTest Recorder Firefox plugin that allows you to record your tests, rather than manually writing them.

Canoo tests are pretty slick in that they're simply configured in an XML file. To add tests for add, edit, save and delete, open src/test/resources/web-tests.xml and add the following XML. You'll notice that this fragment has a target named PersonTests that runs all the related tests.

<!-- runs person-related tests -->
<target name="PersonTests" depends="SearchPersons,EditPerson,SavePerson,AddPerson,DeletePerson"
    description="Call and executes all person test cases (targets)">
    <echo>Successfully ran all Person UI tests!</echo>
</target>

<!-- Verify the persons list screen displays without errors -->
<target name="SearchPersons" description="Tests search for and displaying all persons">
    <webtest name="searchPersons">
        &config;
        <steps>
            &login;
            <invoke description="click View Person link" url="/personlist"/>
            <verifytitle description="we should see the personList title"
                text=".*${personList.title}.*" regex="true"/>
        </steps>
    </webtest>
</target>

<!-- Verify the edit person screen displays without errors -->
<target name="EditPerson" description="Tests editing an existing Person's information">
    <webtest name="editPerson">
        &config;
        <steps>
            &login;
            <invoke description="View Person List" url="/personlist"/>
            <clicklink description="click on first record in list" label="1"/>
            <verifytitle description="we should see the personDetail title"
                text=".*${personDetail.title}.*" regex="true"/>
        </steps>
    </webtest>
</target>

<!-- Edit a person and then save -->
<target name="SavePerson" description="Tests editing and saving a person">
    <webtest name="savePerson">
        &config;
        <steps>
            &login;
            <invoke description="View Person List" url="/personlist"/>
            <clicklink description="click on first record in list" label="1"/>
            <verifytitle description="we should see the personDetail title"
                text=".*${personDetail.title}.*" regex="true"/>

            <clickbutton label="${button.save}" description="Click Save"/>
            <verifytitle description="Page re-appears if save successful"
                text=".*${personDetail.title}.*" regex="true"/>
            <verifytext description="verify success message" text="${person.updated}"/>
        </steps>
    </webtest>
</target>

<!-- Add a new Person -->
<target name="AddPerson" description="Adds a new Person">
    <webtest name="addPerson">
        &config;
        <steps>
            &login;
            <invoke description="Click Add button" url="/personform"/>
            <verifytitle description="we should see the personDetail title"
                text=".*${personDetail.title}.*" regex="true"/>

            <!-- enter required fields -->
            <setinputfield description="set firstName" name="firstName" value="Abbie"/>
            <setinputfield description="set lastName" name="lastName" value="Raible"/>

            <clickbutton label="${button.save}" description="Click button 'Save'"/>
            <verifytitle description="Person List appears if save successful"
                text=".*${personList.title}.*" regex="true"/>
            <verifytext description="verify success message" text="${person.added}"/>
        </steps>
    </webtest>
</target>

<!-- Delete existing person -->
<target name="DeletePerson" description="Deletes existing Person">
    <webtest name="deletePerson">
        &config;
        <steps>
            &login;
            <invoke description="View Person List" url="/personlist"/>
            <clicklink description="click on first record in list" label="2"/>
            <prepareDialogResponse description="Confirm delete" dialogType="confirm" response="true"/>
            <clickbutton label="${button.delete}" description="Click button 'Delete'"/>
            <verifyNoDialogResponses/>
            <verifytitle description="display Person List" text=".*${personList.title}.*" regex="true"/>
            <verifytext description="verify success message" text="${person.deleted}"/>
        </steps>
    </webtest>
</target>

To include the PersonTests when all Canoo tests are run, add it as a dependency to the "run-all-tests" target in src/test/resources/web-test.xml.

<target name="run-all-tests" 
    depends="Login,Logout,PasswordHint,Signup,UserTests,StaticPages,WebServices,DWR,FileUpload,PersonTests"
    description="Call and executes all test cases (targets)"/>

After adding this, you should be able to run mvn verify and have these tests execute. If this command results in "BUILD SUCCESSFUL" - nice work!

Add link to menu

The last step is to make the list, add, edit and delete functions visible to the user. The simplest way is to add a new link to the list of links in src/main/webapp/MainMenu.tml.

<li>
    <a t:type="pagelink" page="PersonList">${message:menu.viewPeople}</a>
</li>

Where menu.viewPeople is an entry in src/main/resources/ApplicationResources.properties (which is auto-copied to src/main/webapp/WEB-INF/app.properties).

menu.viewPeople=View People

The other (more likely) alternative is that you'll want to add it to the menu. To do this, add the following to src/main/webapp/WEB-INF/menu-config.xml:

<Menu name="PersonMenu" title="menu.viewPeople" page="/personlist"/>

Make sure the above XML is inside the <Menus> tag, but not within another <Menu>. Then create src/main/webapp/common/menu.jsp and add the following code to it:

<%@ include file="/common/taglibs.jsp" %>

<menu:useMenuDisplayer name="Velocity" config="cssHorizontalMenu.vm" permissions="rolesAdapter">
    <ul id="primary-nav" class="menuList">
        <li class="pad">&nbsp;</li>
        <c:if test="${empty pageContext.request.remoteUser}">
            <li><a href="<c:url value="/login"/>" class="current"><fmt:message key="login.title"/></a></li>
        </c:if>
        <menu:displayMenu name="MainMenu"/>
        <menu:displayMenu name="UserMenu"/>
        <menu:displayMenu name="PersonMenu"/>
        <menu:displayMenu name="AdminMenu"/>
        <menu:displayMenu name="Logout"/>
    </ul>
</menu:useMenuDisplayer>

Now if you run mvn jetty:run and go to http://localhost:8080/mainmenu, you should see something like the screenshot below.

Notice that there is a new link in your main screen (from mainMenu.html) and on the top in your menu bar (from menu.jsp).

That's it!
You've completed the full lifecycle of developing a set of master-detail pages with AppFuse and Tapestry 5 - Congratulations!

Because it's so much fun to watch tests fly by and success happen, run all your tests again using mvn install.

Happy Day!
BUILD SUCCESSFUL
Total time: 1 minute 33 seconds

Adaptavist Theme Builder (4.0.0-M8) Powered by Atlassian Confluence 3.1, the Enterprise Wiki.