Professional Documents
Culture Documents
with Tapestry
Copyright © 2005-2006
Ka Iok 'Kent' Tong
Foreword
Acknowledgments
I'd like to thank:
• Howard Lewis Ship for creating Tapestry.
• Mike Bowler, the creator of HtmlUnit, for reviewing the chapter on HtmlUnit.
• Helena Lei for proofreading this book.
• Eugenia Chan Peng U for doing book cover and layout design.
Enjoying Web Development with Tapestry 5
Table of Contents
Foreword......................................................................................................................................3
How to create AJAX web-based application easily?................................................................3
How this book can help you learn Tapestry?...........................................................................3
Unique contents in this book....................................................................................................4
Target audience and prerequisites..........................................................................................4
Acknowledgments....................................................................................................................4
Chapter 1 Getting Started with Tapestry....................................................................................11
What's in this chapter?..........................................................................................................12
Developing a Hello World application with Tapestry..............................................................12
Installing Eclipse....................................................................................................................12
Installing Tomcat....................................................................................................................12
Installing Tapestry..................................................................................................................14
Creating a Hello Word application.........................................................................................15
Generating dynamic content..................................................................................................21
Disabling caching in Tapestry................................................................................................24
Making changes to Java code take effect..............................................................................25
Other ways to set the value...................................................................................................25
Debugging a Tapestry application.........................................................................................26
Summary...............................................................................................................................29
Chapter 2 Using Forms..............................................................................................................31
What's in this chapter?..........................................................................................................32
Developing a stock quote application....................................................................................32
Creating the result page........................................................................................................37
Displaying the Result page in the listener..............................................................................37
Easier way to get access to another page.............................................................................40
Instance variables may breach security.................................................................................40
Using Java annotations to inject pages and properties.........................................................44
Using implicit components.....................................................................................................46
Using a combo box................................................................................................................47
Using the DatePicker.............................................................................................................48
Using the API doc..................................................................................................................51
Using the component reference.............................................................................................52
Summary...............................................................................................................................53
Chapter 3 Validating Input.........................................................................................................55
What's in this chapter?..........................................................................................................56
Postage calculator.................................................................................................................56
Accepting integer input..........................................................................................................57
What if the input is invalid?....................................................................................................60
Using validators.....................................................................................................................66
What if the translator can't translate the string?....................................................................69
Handling null input.................................................................................................................69
Setting the display message..................................................................................................70
Using a FieldLabel.................................................................................................................71
Creating your own validator...................................................................................................72
Showing all the errors............................................................................................................74
Using informal parameters.....................................................................................................77
Performing validation using Javascript..................................................................................77
6 Enjoying Web Development with Tapestry
Summary.............................................................................................................................304
Chapter 11 Building Interactive Forms with AJAX...................................................................307
What's in this chapter?........................................................................................................308
A sample AJAX application..................................................................................................308
Creating the Home page......................................................................................................310
Loading a single customer...................................................................................................311
Using a DataSet to store the Customer objects...................................................................316
Listing all the customers......................................................................................................321
Implementing the Edit function............................................................................................324
Skipping validation for form cancellation.............................................................................331
Refreshing the current row only...........................................................................................334
Refreshing the city list when the country is changed...........................................................337
Preventing multiple forms....................................................................................................340
Implementing the Delete function........................................................................................341
Implementing the Add and the Commit function..................................................................342
Using FireBug......................................................................................................................344
Summary.............................................................................................................................348
Chapter 12 Test Driven Development with HtmlUnit................................................................349
What's in this chapter?........................................................................................................350
Developing a calculator using test driven development.......................................................350
Setting up HtmlUnit..............................................................................................................351
Setting up the web application context................................................................................352
Implementing the add operation..........................................................................................354
Providing a list of operations................................................................................................361
Using the setUp() method....................................................................................................362
Implementing minus.............................................................................................................364
Implementing the History link...............................................................................................365
Fixing the problems revealed by manual inspection............................................................371
Running all the tests............................................................................................................373
Implementing validation.......................................................................................................373
Implementing the Help link..................................................................................................375
Refactoring..........................................................................................................................379
Summary.............................................................................................................................381
Chapter 13 Database and Concurrency Issues.......................................................................383
What's in this chapter?........................................................................................................384
Developing a banking application........................................................................................384
Setting up PostgreSQL........................................................................................................384
Hard coding some bank accounts.......................................................................................392
Transferring some money....................................................................................................393
Using a transaction..............................................................................................................396
Connection pooling..............................................................................................................398
Concurrency issues.............................................................................................................402
Long transaction..................................................................................................................416
Dividing the application into layers.......................................................................................426
Summary.............................................................................................................................434
Chapter 14 Using Hibernate.....................................................................................................435
What's in this chapter?........................................................................................................436
Setting up Hibernate............................................................................................................436
Adding an id not exposed to the user..................................................................................439
Specifying the mapping.......................................................................................................440
Enjoying Web Development with Tapestry 9
Chapter 1
Chapter 1 Getting Started with Tapestry
12 Chapter 1 Getting Started with Tapestry
Installing Eclipse
First, you need to make sure you have Eclipse installed. If not, go to http://www.eclipse.org to download the Eclipse
platform (e.g., eclipse-platform-3.1-win32.zip) and the Eclipse Java Development Tool (eclipse-JDT-3.1.zip). Unzip both
into c:\eclipse. Then, create a shortcut to run "c:\eclipse\eclipse -data c:\workspace". This way, it will store your projects
under the c:\workspace folder. To see if it's working, run it and then you should be able to switch to the Java
perspective:
Installing Tomcat
Next, you need to install Tomcat. Go to http://jakarta.apache.org to download a binary package of Tomcat. Download
the zip version instead of the Windows exe version. Suppose that it is jakarta-tomcat-5.5.7.zip. Unzip it into a folder say
c:\tomcat. If you're going to use Tomcat 5.5 with JDK 1.4 or 1.3, you also need to download the compat package and
unzip it into c:\tomcat.
Before you can run it, make sure the environment variable JAVA_HOME is defined to point to your JDK folder (e.g.,
C:\Program Files\Java\jdk1.5.0_02):
Getting Started with Tapestry 13
If you don't have it, define it now. Now, run open a command line, change to c:\tomcat\bin and then run startup.bat. If it
is working, you should see:
Installing Tapestry
Next, go to http://tapestry.apache.org to download a binary package of Tapestry (e.g., tapestry-project-4.1.1-bin.zip)
and unzip it into a folder say c:\tapestry. It contains a quite some jar files in different sub-folders (each folder is a
"module"):
It's quite difficult to use these jar files as they're in different sub-folders. To solve the problem, you'll copy them available
in one folder. Create a folder c:\tapestry\jars, then choose "Search" on the Start Menu to search for files named *.jar
inside c:\tapestry. The result should be like:
Getting Started with Tapestry 15
Copy all of the files and paste them into c:\tapestry\jars. That's it. You can't run it yet because Tapestry is a library, not
an application.
The bin folder is useless so you can delete it. Then right click the project and choose "Properties", choose "Java Build
Path" on the left hand side, choose the "Libraries" tab:
If you see a "Tapestry Framework" library as shown above, Do NOT choose it! It is Tapestry 3.0 coming with Spindle.
Click "Next":
Click "New" to define a new one and enter "Tapestry 4" as the name of the library:
Click "Add JARs", browse to c:\tapestry\jars and add all the jar files there:
18 Chapter 1 Getting Started with Tapestry
Then close all the dialog boxes. Next, create a new file Home.html in context\WEB-INF in the project. It will act as the
home page of your application. Next, use DreamWeaver, FrontPage or your favorite web page editor to modify it. But
where is it located? It is in context/WEB-INF in your project and the whole project is in c:\workspace:
So, its full path is c:\workspace\HelloWorld\context\WEB-INF\Home.html. Knowing its full path, you can modify it to look
like:
If you'd like, you can edit the HTML code directly in Eclipse:
Next, create a new file Home.page in the same folder as Home.html with the following content:
<?xml version="1.0"?>
<!DOCTYPE page-specification PUBLIC
"-//Apache Software Foundation//Tapestry Specification 4.0//EN"
"http://jakarta.apache.org/tapestry/dtd/Tapestry_4_0.dtd">
<page-specification>
</page-specification>
Next, you need to make the Tapestry jar files available to this application. To do that, copy all the tapestry jar files in
c:\tapestry\jars into c:\tomcat\shared\lib:
Getting Started with Tapestry 19
This way, they will be available to all applications running in Tomcat, including your own. Next, create a file web.xml in
context\WEB-INF with the following content:
<?xml version="1.0"?>
<web-app xmlns="http://java.sun.com/xml/ns/j2ee"
xmlns:xsi="http://www.w3.org/TR/xmlschema-1/"
xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd" version="2.4">
<display-name>HelloWorld</display-name>
<servlet>
<servlet-name>HelloWorld</servlet-name>
<servlet-class>org.apache.tapestry.ApplicationServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>HelloWorld</servlet-name>
<url-pattern>/app</url-pattern>
</servlet-mapping>
</web-app>
You can ignore its meaning for now.
To make this application run in Tomcat, you must register it with Tomcat. To do that, create a file HelloWorld.xml in
c:\tomcat\conf\Catalina\localhost:
20 Chapter 1 Getting Started with Tapestry
HelloWorld.xml
<Context
docBase="c:/workspace/HelloWorld/context"
path="/HelloWorld"/>
This is called the "context path". It Tell Tomcat that the application's
is telling Tomcat that users should files can be found in
access this application using c:\workspace\HelloWorld\context.
http://localhost:8080/HelloWorld.
Bar.xml /Bar
Now, start Tomcat (by running startup.bat). To run your application, run a browser and try to go to
http://localhost:8080/HelloWorld/app?service=page&page=Home. You should see:
<Context
docBase="c:/workspace/HelloWorld/context"
path="/HelloWorld"/>
http://localhost:8080/HelloWorld/app?service=page&page=Home
Component
6: Output HTML code for
"subject" itself such as "John"
However, in order for Tapestry to create the component, it needs to know what type of component it is. Therefore, you
need specify its type. This is done in the Home.page file:
22 Chapter 1 Getting Started with Tapestry
<page-specification>
<component id="subject" type="Insert">
<binding name="value" value="ognl:greetingSubject"/>
The whole thing will be
</component>
evaluated as the value
</page-specification>
of the "value"
parameter and output
Each Insert component has a few This is the "prefix" This is an OGNL
"parameters". In particular, it will of the expression. It expression. But what
evaluate its parameter named is saying that what does it mean?
"value" and output the result as the is following is an
plain text to output. "OGNL" expression.
What is OGNL? It stands for Object Graph Navigation Language. What does this particular expression
"greetingSubject" mean? Before the page is rendered, actually, Tapestry will first create a Java object to represent the
page. What is the Java class of this object? By default, it is the class org.apache.tapestry.html.BasePage provided by
Tapestry. Let's call this object the page object. Then it will create all the components listed here in Home.page and put
them into the page object (imagine a page object contains an array to store the components). In this case there is only
one Insert component named "subject":
Page object
(a BasePage
object)
Component
"subject"
...
It is this page object that is reading Home.html and generating the output. As mentioned before, when it sees the
"subject" component in Home.html, it will ask the "subject" component to output HTML code for itself (see the diagram
below). Then this component will evaluate the expression "greetingSubject", which means that it will call a
getGreetingSubject() method on the page object and expect that the result to be some plain text for it to output:
Page object
(a BasePage 3: Result is
"John"
object)
1: Generate
HTML for
yourself
2: call the
getGreetingSubject()
method
Component
"subject" 4: Output
"John"
...
As you can see, when displaying the Home page, Home.html is acting as a template. So it is called a "template" in
Tapestry. Home.page is specifying the Java class for the page object and lists the components to be created in the
Getting Started with Tapestry 23
package com.ttdev.helloworld;
import org.apache.tapestry.html.BasePage;
Why? You have made changes to your Home.html, Home.page and Home.java files, but Tapestry will cache HTML
files and .page files in memory once they're read. In addition, Tomcat will cache Java class files once they're read. So,
these changes won't take effect. To make them take effect, you need to reload the application. To do that, go to
http://localhost:8080 and choose "Tomcat Manager", but it requires you to enter a user name and password:
24 Chapter 1 Getting Started with Tapestry
Therefore, you need to create a user account first. To do that, edit c:\tomcat\conf\tomcat-users.xml:
<?xml version='1.0' encoding='utf-8'?>
<tomcat-users>
<role rolename="tomcat"/>
<role rolename="role1"/>
<role rolename="manager"/>
<user username="tomcat" password="tomcat" roles="tomcat"/>
<user username="role1" password="tomcat" roles="role1"/>
<user username="both" password="tomcat" roles="tomcat,role1"/>
<user username="tomcatAdmin" password="123456" roles="manager"/>
</tomcat-users>
Then, restart Tomcat so that it can see the user account. Then, using this account to access the Tomcat Manager:
To restart the application, just click "Reload" for /HelloWorld. However, as you have already restarted Tomcat, all the
applications have been reloaded in the process. Anyway, run the application and you should see the changes taking
effect.
Now, you can change say Home.html and the change will take effect immediately. For example, modify it as:
<html>
Hello <span jwcid="subject">world</span>! Good!
</html>
Then run the application:
Now, reload the application so that Tomcat reads the context descriptor (and learns that the application is now
reloadable). Then try to change the Java code again and reload the page. It will work (it may take a few seconds
though, so be patient).
<page-specification>
<component id="subject" type="Insert">
<binding name="value" value="ognl:'Paul'"/>
</component>
</page-specification>
It is still an OGNL expression, but
the string is quoted, so it is a
string constant.
change the Home.page file again such as adding a space and deleting it again. Finally, save it again. Then the change
should take effect.
There is yet another alternative:
<page-specification>
<component id="subject" type="Insert">
<binding name="value" value="literal:Judy"/>
</component>
</page-specification>
What is following is no It is just a string
longer an OGNL literal. No need to
expression, but a string quote it.
literal.
In fact, you don't have to provide a prefix:
<page-specification>
<component id="subject" type="Insert">
<binding name="value" value="greetingSubject"/>
</component>
</page-specification>
No prefix is specified. In that case the Tapestry
will assume that it is an OGNL expression as if
ognl prefix was used.
Debugging a Tapestry application
To debug your application in Eclipse, you need to set two more environment variables for Tomcat and launch it in a
special way:
Note that you're now launching it using catalina.bat instead of startup.bat. This way Tomcat will run the JVM in debug
mode so that the JVM will listen for connections on port 8000. Later you'll tell Eclipse to connect to this port.
Now, set a breakpoint here:
Getting Started with Tapestry 27
Right click "Remote Java Application" and choose "New". Browse to select your HelloWorld project and make sure the
port is 8000:
28 Chapter 1 Getting Started with Tapestry
Click "Debug" to connect to the JVM in Tomcat. Now go to the browser to load the page again. Eclipse will stop at the
breakpoint:
Then you can step through the program, check the variables and whatever. To stop the debug session, choose the
process and click the Stop icon:
Getting Started with Tapestry 29
Having to set all those environment variables every time is not fun. So, you may create a batch file
c:\tomcat\bin\tap.bat:
set JAVA_OPTS="-Dorg.apache.tapestry.disable-caching=true"
set JPDA_ADDRESS=8000
set JPDA_TRANSPORT=dt_socket
catalina jpda start
Then in the future you can just run it to start Tomcat.
Summary
To develop a Tapestry, you can install Tomcat and Eclipse.
To install Tapestry, just unzip it into a folder. It is just a bunch of jar files. Copy the jar files into Tomcat's shared lib
folder so that they are available to all web applications.
To register a web application with Tomcat, you need to create a web.xml file and a context descriptor to tell Tomcat
where the application's files can be found.
To use a Tapestry application, you can enter a URL to ask Tapestry to display a certain page. If you don't specify
anything particular in the URL, it will display the Home page.
When displaying a certain page, Tapestry will check the .page file (page specification) to find out the Java class of the
page object and create it. The page object will read its HTML file (the template) and basically output what's in the HTML
file. But if there is a component in the HTML file, it will check the .page file to find out the type of the component, then
create the component (a Java object) and ask it to output HTML for itself.
An Insert component will output some plain text as HTML code. It evaluates its "value" parameter which is bound to a
certain expression. The expression may have an OGNL expression (with an ognl prefix) or a string literal (with a literal
prefix). If no prefix is specified, Tapestry will always assume that it is an OGNL expression. For an OGNL expression, if
the expression is "foo", it will call getFoo() on the page object and expect the return value to be a string to output.
If you make changes to your HTML file or .page files, your application won't see the changes by default because
Tapestry is caching them. To solve the problem, set a JVM parameter to disable the cache. Similarly, if you make
changes to your Java class, your application won't see the changes because Tomcat is caching them. To solve the
problem, mark the application as reloadable in the context descriptor so that the application is reloaded automatically if
any of its Java code is changed.
To debug a Tapestry application, tell Tomcat to run the JVM in debug mode, set a breakpoint in the Java code and
make a Debug configuration in Eclipse to connect to that JVM.
31
Chapter 2
Chapter 2 Using Forms
32 Chapter 2 Using Forms
That is, the user can enter the stock id and click "OK", then the stock value will be displayed. To do that, create a new
Java application named "StockQuote":
Set the output folder to StockQuote/context/WEB-INF/classes and then add the user library "Tapestry 4" to it:
Using Forms 33
stockId
Note that in the process the "listener" parameter of the Form component is not used yet. It is used when the HTML
Using Forms 35
form is submitted. Suppose that the user enters "SUN" as the stock id and then clicks "OK". Then an HTTP request will
be sent to your Tapestry application (see the diagram below). The "SUN" value is also included in the request. To
handle the request, Tapestry will create the page object again (and the components in it) and then asks the Form
component to handle the form submission (see the diagram below). The Form component will ask all the components
in its body (there is only one here, the "stockId" TextField) to handle the form submission. The "stockId" component will
get the value of the text input entered by the user ("SUN") and set its "value" parameter, i.e., call setStockId("SUN") on
the page object. Finally, the Form component evaluates its "listener" parameter ("listener:onOk"). In this case, this will
create an action listener object. All action listener objects must define an actionTriggered() method. In this case, the
actionTriggered() method of this action listener object will just call the onOk() method of the page object. Finally, the
Form component will call the actionTriggered() method of that action listener object. The effect is, onOk() of the page
object is called:
<component id="stockQuoteForm" type="Form">
<binding name="listener" value="listener:onOk"/>
</component>
6: Look, what expression to
evaluate? Oh, it is a listener
Tapestry expression.
Page object
void onOk() { 1: Handle the form
... submission
} 7: Create it Action listener
void actionTriggered() {
page.onOk();
}
9: Call it
8: Call
actionTriggered()
stockQuoteForm
5: Call setStockId
("SUN") on the HTTP request
page object.
3: What is the
2: Handle value of the
the form HTML text
submission input?
4: It's "SUN"
stockId
As the BasePage doesn't have a getStockId(), a setStockId() or an onOk() methods, you need to create a subclass.
Let's create a Home class in package com.ttdev.stockquote:
public abstract class Home extends BasePage {
private String stockId;
Check the HTML code to verify that it is not just your Home.html:
Enter "SUN" as the stock id and click "OK". In the Tomcat console window you should see the output message:
In the browser you should see the original page is displayed again:
This is because after handling the form submission, by default Tapestry will render the original page (Home) again.
Because ognl is the default prefix, Home.page can be simplified as:
<page-specification class="com.ttdev.stockquote.Home">
<component id="stockQuoteForm" type="Form">
<binding name="listener" value="listener:onOk"/>
</component>
<component id="stockId" type="TextField">
<binding name="value" value="ognl:stockId"/>
</component>
</page-specification>
Using Forms 37
You're just returning a hard coded stock value of 100. Note that it is an int, not a string. Can the Insert component
handle an int? Yes, it can. If the value is not a string, it will call toString() on it to get a string.
To see if the page is working, use the browser to display
http://localhost:8080/StockQuote/app?service=page&page=Result. You should see:
Action listener
An argument is added. It is a "request void actionTriggered() {
cycle". It represents the request from page.onOk(cycle);
the browser and the response of your }
application.
public abstract class Home extends BasePage { public abstract class Home extends BasePage {
... ...
public void onOk(IRequestCycle cycle) { public void onOk(IRequestCycle cycle) {
//don't call activate() at all cycle.activate("Home");
} }
} }
Now, run the application and click "OK" to see if it can bring you to the Result page:
Instead of calling activate() explicitly, you could achieve exactly the same effect by returning the page name from the
listener method:
Using Forms 39
What you have done is called "injecting a page" into the Home page. Now run the application and it should continue to
work.
It means one user is seeing the result of another user. Why is it happening? It is because after using a page object
(e.g., the Result page object), Tapestry will not throw it away. Instead, it will put it into a pool for reuse (see the diagram
below). Later when it needs to use the Result page object again, it will check if the pool has one. If so, just take it out
from the pool and use it. Only when the pool has no such page object, will it create a new page object. That is, the
whole process is like:
4: Render Result
page (again)
Tapestry
5: Give me a Result
1: Render yourself page object
7: Render yourself
2: Done
Page pool
The problem here is that the Result page object in the pool is still keeping the stock value of 24. To solve this problem,
you can modify Result.java:
public abstract class Result extends BasePage implements PageDetachListener {
int stockValue;
Now try the experiment again and the second user will only see:
The take home message is that generally it is dangerous to have instance variables in a page object. Whenever you
see them, you probably should implement the PageDetachListener interface to clear them to null or 0. As an
alternative, you may let Tapestry do all that for you. Modify Result.page:
<page-specification class="com.ttdev.stockquote.Result">
<property name="stockValue"/>
<component id="stockValue" type="Insert">
<binding name="value" value="stockValue"/>
</component>
</page-specification>
Then at runtime Tapestry will create a subclass of your Result class that looks like:
public class ResultEnhanced extends Result implements PageDetachListener {
private XXX stockValue;
What you have done is called "injecting a property" into the page. The most important effect is that the property is
cleaned up automatically when it is returned to the pool, so security is ensured. Now run the application and it should
continue to work.
Let's check the Home class above. It also has an instance variable. Is it dangerous? It should be OK as the stock id will
be set by the TextField component before it is used. But if you prefer being safe than sorry, you may use a property
specification in its place:
<page-specification class="com.ttdev.stockquote.Home">
<inject property="resultPage" type="page" object="Result"/>
<property name="stockId"/>
<component id="stockQuoteForm" type="Form">
<binding name="listener" value="listener:onOk"/>
</component>
<component id="stockId" type="TextField">
<binding name="value" value="stockId"/>
</component>
</page-specification>
Then, modify Home.java:
public abstract class Home extends BasePage {
private String stockId;
this.stockId = stockId;
}
public IPage onOk(IRequestCycle cycle) {
int stockValue = stockIdgetStockId().hashCode() % 100;
Result resultPage = getResultPage();
resultPage.setStockValue(stockValue);
return resultPage;
}
}
Note that as you can't access the stock id as a variable, the onOk() method needs to call the getter. That's why you
need to keep the getter and declare it as abstract.
Now you're about to run the application. But you have removed the disable-caching JVM property and thus the .page
files are cached. So, reload the application in the Tomcat manager. Then run the application and it should continue to
work. However, you'll notice that the initial value is no longer MSFT:
This is by default the stock id property is set to null. To set the initial value back to MSFT, modify Home.page:
<page-specification class="com.ttdev.stockquote.Home">
<inject property="resultPage" type="page" object="Result"/>
<property name="stockId" initial-value="literal:MSFT"/>
<component id="stockQuoteForm" type="Form">
<binding name="listener" value="listener:onOk"/>
</component>
<component id="stockId" type="TextField">
<binding name="value" value="stockId"/>
</component>
</page-specification>
Of course you could use an OGNL expression if you needed to. Now reload the application and run it. You should see
MSFT again:
You have finished this experiment. Restart Tomcat using tap.bat to get back the disable-caching JVM property.
abstract public String getStockId(); Simply delete the <property> element. When
Tapestry sees an unimplemented getter
public IPage onOk(IRequestCycle cycle) { (either the method is declared abstract or the
int stockValue = getStockId().hashCode() % 100; class is implementing an interface but doesn't
Result resultPage = getResultPage(); provide an implementation for a method) like
resultPage.setStockValue(stockValue); getStockId() here, it will create a property for
return resultPage; it. However, then there is no way to set the
} initial value.
}
How to set the initial stock id to MSFT? Do it this way:
46 Chapter 2 Using Forms
public abstract class Home extends BasePage { After creating a new page object, Tapestry will
@InjectPage("Result") evaluate this expression to get the value and
abstract public Result getResultPage(); store it into the property. In this case you're
using the literal prefix. If required, you can use
@InitialValue("literal:MSFT") the ognl prefix and specify an OGNL
abstract public String getStockId(); expression.
public IPage onOk(IRequestCycle cycle) {
int stockValue = getStockId().hashCode() % 100; Before Tapestry returns the page object back
Result resultPage = getResultPage(); to the pool, it will evaluate the expression and
resultPage.setStockValue(stockValue); store the value into the property again so that
return resultPage; the next time when it's taken out of the pool,
} the value will have already been set.
}
delete your components by mistake. If you're using declared components, all you need to do is to add the component
ids back. If you're using implicit components, you'll have to specify all the bindings again.
</component>
</page-specification>
It's a DatePicker component. It will allow the user choose a date. It will store the selected date into its "value" parameter
when the form is submitted. In this case, it'll call setQuoteDate() on the page object. Of course, it will also call
getQuoteDate() and display the result as the initial value. For this to work, define a property by making an abstract
getter in Home.java:
public abstract class Home extends BasePage {
@InjectPage("Result")
abstract public Result getResultPage();
This is because the DatePicker needs to generate some Javascript in order for it to function. In addition, the script
generated makes use of a Javascript library called "dojo". Therefore, for it to work, first you need to bring in dojo.js
(included in Tapestry) by something like:
<html>
<head>
<script type="text/javascript" src="...">
</script>
</head>
...
</html> The path to the dojo.js file. This file is
included in a tapestry jar file. What is
the path exactly? You don't need to
worry about that. Read on.
<html>
<head>
<title>Stock Quote</title>
<script type="text/javascript" src="...">
</script>
</head>
<form jwcid="stockQuoteForm@Form" ...>
...
</form>
</html>
Second, the script generated by the DatePicker needs to be collected and put into the <body> element:
<html>
<head>
...
</head>
<body>
<form jwcid="stockQuoteForm@Form" ...>
...
</form>
<script ....>
</body>
</html>
To do that, you can use the Body component:
<html jwcid="@Shell" title="Stock Quote">
<body jwcid="@Body">
<form jwcid="stockQuoteForm@Form" listener="listener:onOk">
<select jwcid="stockId">
<option value="0">IBM</option>
<option value="1">RHAT</option>
</select>
on <span jwcid="quoteDate">May 3, 2005</span>
<input type="submit" value="OK"/>
</form>
</body>
</html>
Now, run the application again and it should work:
Using Forms 51
You will find a description about what it does and a list of its parameters. For each parameter, it describes its name, the
type of value expected, whether it is required or optional and its default value if it is not bound.
Summary
To get input from the user, use a Form component and put some TextField components in it. When the form is
rendered, each TextField will get the data from the page object and display it in an HTML input field. When the form is
submitted, each TextField will store the input into the page object and finally the Form's listener will be called so that
you can perform further calculation on the data already stored in the page object.
To tell Tapestry which page is the result page, call activate() or return the name of the page or the page object itself.
Before that, you can pass information to it by calling its setters (bucket brigade pattern). To let a page load another
page easily, you can inject the page in the .page file using <inject> or directly in the Java class using annotations.
In addition to TextField components, you can also use PropertySelection components and DatePicker components in a
Form. Some components like DatePicker need to generate Javascript to function. Most likely they also need the dojo
library. For them to work, you need a Shell component to bring in dojo and a Body component to collect the scripts.
To create a listener object that calls your listener method on your page object, you can use the listener prefix.
It is dangerous to use instance variables in your page objects because they're reused for different users. You should
use a property by having a <property> in the .page file or by declaring an abstract getter instead. To access such
properties, you can declare abstract getters and setters in your page class and make the class abstract.
You can specify your components in the .page file (declared component) or in the HTML file (implicit component). The
former provides a cleaner separation but the latter is easier to read and write. When specifying a binding in an HTML
file, the default prefix is literal instead of ognl.
To lookup the parameters of a certain component type, use the documentation on the component.
To see what a class does, check the API doc.
55
Chapter 3
Chapter 3 Validating Input
56 Chapter 3 Validating Input
Postage calculator
Suppose that you'd like to develop an application to calculate the postage for sending a package from some place to
another. The user will enter the weight of the package in kg (check the screenshots below). Optionally, he can enter a
"patron code" identifying himself as a patron to get a certain discount. After clicking "OK", it will display the postage:
To do that, create an application named Postage. Setup the class path, output folder and web.xml as usual. Then
modify Home.html:
<html>
<form jwcid="form">
<table>
<tr>
<td>Weight:</td>
<td><input type="text" jwcid="weight"/></td>
</tr>
<tr>
<td>Patron code:</td>
<td><input type="text" jwcid="patronCode"/></td>
</tr>
<tr>
<td></td>
<td><input type="submit"/></td>
</tr>
</table>
</form>
</html>
Define the components in Home.page:
<page-specification class="com.ttdev.postage.Home">
<component id="form" type="Form">
<binding name="listener" value="listener:onSubmit"/>
</component>
<component id="weight" type="TextField">
<binding name="value" value="weight"/>
</component>
<component id="patronCode" type="TextField">
<binding name="value" value="patronCode"/>
</component>
</page-specification>
Home.java is like:
Validating Input 57
string because the TextField component expects a string as the value of its "value" parameter. Fortunately this can be
changed. The idea is (see the diagram below), when it renders itself, let it accepts any object as its "value" parameter,
then give it a "translator" object which can translate the object into a string. Then it can output that string into the HTML
code:
Page object
2: call getWeight(). Assume
that it returns an Integer(5)
object.
1:Render
yourself <input type="text" value="5">
Page object
6: call setWeight() and
pass the Integer(5) object HTTP request
1: Handle
the form 2: What is the
submission value of the
HTML text input?
public Home() {
patronCodeToDiscount = new HashMap();
patronCodeToDiscount.put("p1", new Integer(90));
patronCodeToDiscount.put("p2", new Integer(95));
}
public IPage onSubmit() {
int weight = Integer.parseInt(getWeight());
int weight = getWeight();
Integer discount = (Integer) patronCodeToDiscount.get(getPatronCode());
int postagePerKg = 10;
int postage = weight * postagePerKg;
if (discount != null) {
postage = postage * discount.intValue() / 100;
}
IPage resultPage = getResult();
PropertyUtils.write(resultPage, "postage", new Integer(postage));
return resultPage;
}
}
Now run the application and it should continue to work. What if you'd like to allow a floating number as the weight? By
default the number translator assumes that it is an int. To tell it to accept a floating number, set its pattern to "#.#". This
means a decimal point is allowed and any number of digits are allowed on both side:
<page-specification class="com.ttdev.postage.Home">
<component id="form" type="Form">
<binding name="listener" value="listener:onSubmit"/>
</component>
<component id="weight" type="TextField">
<binding name="value" value="weight"/>
<binding name="translator" value="translator:number,pattern=#.#"/>
</component>
<component id="patronCode" type="TextField">
<binding name="value" value="patronCode"/> This sets a property named "pattern" of the
</component> number translator. If you'd like to set other
</page-specification> properties, just write something like:
<binding name="translator"
value="translator:number,pattern=#.#,foo=xxx,bar=yyy"/>
In addition to the number translator, another common translator is a translator named "date". It supports patterns like
MM/dd/yyyy.
This is no good. Instead, you'd like the application to tell the user that the weight is invalid:
Similarly, it should also check if the patron code is valid or not. For example, if the user enters "p3", it should tell him
that this code is not found:
Note that as the patron code is optionally, if he doesn't enter anything, it shouldn't be treated as an error.
Validating Input 61
public Home() {
patronCodeToDiscount = new HashMap(); Record the
patronCodeToDiscount.put("p1", new Integer(90)); (invalid)
patronCodeToDiscount.put("p2", new Integer(95)); value input
} Set the "weight" by the user
public IPage onSubmit() { TextField as the Record the
delegate = new ValidationDelegate(); current component error
double weight = getWeight(); (input field) message
if (weight < 0) {
delegate.setFormComponent((IFormComponent) getComponent("weight"));
delegate.recordFieldInputValue(Double.toString(weight));
delegate.record("Weight must be >=0",
ValidationConstraint.TOO_SMALL);
} Add another error
Integer discount = (Integer) patronCodeToDiscount.get(getPatronCode()); to the validation
delegate
Patron code if (discount == null && getPatronCode() !=null) {
not found but delegate.setFormComponent((IFormComponent) getComponent("patronCode"));
was indeed delegate.recordFieldInputValue(getPatronCode());
input by the delegate.record("Patron not found", null);
user }
if (delegate.getHasErrors()) { The type of the error. There are
return null; Returning null means other types such as
It contains at using this page (Home) REQUIRED (a required input is
least one }
int postagePerKg = 10; as the response page not provided), TOO_LARGE
error? (value is too large) and etc.
int postage = (int) (weight * postagePerKg);
if (discount != null) { This is not used at all and you
postage = postage * discount.intValue() / 100; may just pass a null to
} represent an unknown type of
IPage resultPage = getResult(); error.
PropertyUtils.write(resultPage, "postage", new Integer(postage));
return resultPage;
}
}
To display the errors stored in the validation delegate, modify Home.html:
<html>
<span jwcid="errors"/>
<form jwcid="form">
<table>
<tr>
<td>Weight:</td>
<td><input type="text" jwcid="weight"/></td>
</tr>
<tr>
<td>Patron code:</td>
<td><input type="text" jwcid="patronCode"/></td>
</tr>
<tr>
<td></td>
62 Chapter 3 Validating Input
<td><input type="submit"/></td>
</tr>
</table>
</form>
</html>
Define this component in Home.page:
<page-specification class="com.ttdev.postage.Home">
<component id="form" type="Form">
<binding name="listener" value="listener:onSubmit"/>
</component>
<component id="weight" type="TextField">
<binding name="value" value="weight"/>
<binding name="translator" value="translator:number,pattern=#.#"/>
</component>
<component id="patronCode" type="TextField">
<binding name="value" value="patronCode"/>
</component>
<component id="errors" type="Delegator">
<binding name="delegate" value="delegate.firstError"/>
</component>
</page-specification>
This component is a Delegator component. What it does is simple: When it is asked to render itself, it will ask another
object to render. You provide that other object to its "delegate" parameter. Here, you get the validation delegate from
the page object and then call getFirstError() on it. This will return the first error message in the validation delegate. Or
more precisely, actually the validation delegate doesn't simply store the error message, it stores a Java object that can
render the error message (see below). Such a Java object is called an "error renderer":
Field name Old value Error renderer
weight "-20" An error render that will render itself as a string "The weight must be >=0".
... ... ...
... ... ...
The idea is that you could provide an error renderer that renders itself as some fancy formatted HTML, a graphics or
anything you want instead of just a plain string.
If there is no error in the validation delegate, getFirstError() will return a null. Then the Delegator component will not
output anything.
As the Delegator component needs to get the validation delegate from the page object, you need to provide a getter:
public abstract class Home extends BasePage {
private Map patronCodeToDiscount;
private ValidationDelegate delegate;
An exception trace with three exceptions is shown. They are all is saying that the source is null when it's trying to get
the "firstError" property of the delegate. If you click on the first Exception, you'll see that it is line 17 in Home.page is
causing this error:
It means the validation delegate it has got is null. This is because you're creating it only in onSubmit(). So when the
Home page (in particular, the "errors" component) is rendered, it is still null. To solve this problem you could create it in
64 Chapter 3 Validating Input
the constructor:
public abstract class Home extends BasePage {
...
private ValidationDelegate delegate;
public Home() {
patronCodeToDiscount = new HashMap();
patronCodeToDiscount.put("p1", new Integer(90));
patronCodeToDiscount.put("p2", new Integer(95));
delegate = new ValidationDelegate();
}
public ValidationDelegate getDelegate() {
return delegate;
}
public void onSubmit() {
delegate = new ValidationDelegate();
...
}
}
But it doesn't smell right as you're also creating it again in onSubmit(). Do you have to create it again there? If you
don't, the errors will be accumulating in the validation delegate. To solve this problem, note that all you want to create it
on demand and destroy it after handling the request. This can be done using a "bean":
<page-specification class="com.ttdev.postage.Home">
<bean name="delegate" class="org.apache.tapestry.valid.ValidationDelegate"/>
<component id="form" type="Form">
<binding name="listener" value="listener:onSubmit"/>
</component> Create it from
<component id="weight" type="TextField"> this class
<binding name="value" value="weight"/>
<binding name="translator" value="translator:number,pattern=#.#"/>
</component>
<component id="patronCode" type="TextField"> Lookup this bean by name. If
<binding name="value" value="patronCode"/> it's not there yet, it will be
</component> created automatically.
<component id="errors" type="Delegator">
<binding name="delegate" value="beans.delegate.firstError"/>
</component>
</page-specification>
Conceptually, each BasePage object has a Map named "beans" to store all the beans created for it so far. To lookup a
bean named XXX in OGNL, just write "beans.XXX". Usually this will call getBeans() first, which will return a Map of the
beans. Then it should call getXXX() on the Map. However, OGNL notes that it is a Map, so it will lookup the Map by
calling get("XXX") on it instead. If such a bean doesn't exist yet in the Map, the Map will create it automatically. When
the request cycle is ended and the page is about to be returned to the pool, the bean will be destroyed.
As you're now using a bean as the validation delegate, update Home.java:
public abstract class Home extends BasePage {
private Map patronCodeToDiscount;
private ValidationDelegate delegate;
public Home() {
patronCodeToDiscount = new HashMap();
patronCodeToDiscount.put("p1", new Integer(90));
patronCodeToDiscount.put("p2", new Integer(95));
}
Again, you don't have to declare the bean in the .page file. You can do it using Java annotations in Home.java:
public abstract class Home extends BasePage {
private Map patronCodeToDiscount;
@InjectPage("Result")
public abstract IPage getResult();
public abstract double getWeight();
public abstract String getPatronCode();
@Bean
public abstract ValidationDelegate getDelegate();
public Home() {
patronCodeToDiscount = new HashMap();
patronCodeToDiscount.put("p1", new Integer(90));
patronCodeToDiscount.put("p2", new Integer(95));
}
public IPage onSubmit() {
ValidationDelegate delegate = (ValidationDelegate) getBeans().getBean(
"delegate");
ValidationDelegate delegate = getDelegate();
double weight = getWeight();
if (weight < 0) {
delegate.setFormComponent((IFormComponent) getComponent("weight"));
delegate.recordFieldInputValue(Integer.toString(weight));
delegate.record("Weight must be >=0",
ValidationConstraint.TOO_SMALL);
}
Integer discount = (Integer) patronCodeToDiscount.get(getPatronCode());
if (discount == null && getPatronCode() != null) {
delegate.setFormComponent((IFormComponent) getComponent("patronCode"));
delegate.recordFieldInputValue(getPatronCode());
delegate.record("Patron not found", null);
}
if (delegate.getHasErrors()) {
return null;
}
int postagePerKg = 10;
int postage = (int) (weight * postagePerKg);
66 Chapter 3 Validating Input
if (discount != null) {
postage = postage * discount.intValue() / 100;
}
IPage resultPage = getResult();
PropertyUtils.write(resultPage, "postage", new Integer(postage));
return resultPage;
}
}
Delete it from Home.page:
<page-specification class="com.ttdev.postage.Home">
<bean name="delegate" class="org.apache.tapestry.valid.ValidationDelegate"/>
<component id="form" type="Form">
<binding name="listener" value="listener:onSubmit"/>
</component>
<component id="weight" type="TextField">
<binding name="value" value="weight"/>
<binding name="translator" value="translator:number,pattern=#.#"/>
</component>
<component id="patronCode" type="TextField">
<binding name="value" value="patronCode"/>
</component>
<component id="errors" type="Delegator">
<binding name="delegate" value="beans.delegate.firstError"/>
</component>
</page-specification>
The application will continue to work.
Using validators
Even though it is working, there is still something to desire for: Checking if a number is positive is something that you
do frequently. Having to do it yourself is too much trouble. To solve these problems, you can use a validator. The idea
is that when the "weight" component is handling the form submission (see the diagram below), it will get the string value
from the HTTP request and ask the translator to translate the string into an object. Then it can ask a list of validators to
validate that object in turn. If any one of them considers the object invalid, that validator will record an error in the
validation delegate and the processing will finish:
Field name Old value Error msg
weight "-20" "Weight must be >=0"
Page object
HTTP request 8: Record an error
1: Handle
the form 2: What is the validator validator ...
submission value of the
HTML text input?
6: Check if Integer(5) 7: Check if Integer(5)
3: It's a string "5"
is valid. Assume it's is valid. Assume it's
considered valid. considered invalid.
<page-specification class="com.ttdev.postage.Home">
<component id="form" type="Form">
<binding name="listener" value="listener:onSubmit"/>
<binding name="delegate" value="beans.delegate"/> This prefix means what is following is a list
</component> of validators
<component id="weight" type="TextField"> The first and only one
<binding name="value" value="weight"/> validator. It checks the
<binding name="translator" value="translator:number,pattern=#.#"/> object (a number) is
<binding name="validators" value="validators:min=0"/> >= 0.
<binding name="displayName" value="literal:Weight"/>
</component>
<component id="patronCode" type="TextField"> What if the object is found
<binding name="value" value="patronCode"/> to be < 0? It will record an
</component> error into the validation
<component id="errors" type="Delegator"> delegate. But how can it
<binding name="delegate" value="beans.delegate.firstError"/> find the validation
</component> delegate? It tries to get it
</page-specification> from the enclosing Form
Set the display name to "Weight". This way, if the
component.
value is found to be invalid, the validator can
compose an error message like "Weight must be >=
0".
Now you no longer need to validate the weight in Home.java:
public abstract class Home extends BasePage {
private Map patronCodeToDiscount;
@InjectPage("Result")
public abstract IPage getResult();
public abstract double getWeight();
public abstract String getPatronCode();
@Bean
public abstract ValidationDelegate getDelegate();
public Home() {
patronCodeToDiscount = new HashMap();
patronCodeToDiscount.put("p1", new Integer(90));
patronCodeToDiscount.put("p2", new Integer(95));
}
public IPage onSubmit() {
ValidationDelegate delegate = getDelegate();
double weight = getWeight();
if (weight < 0) {
delegate.setFormComponent((IFormComponent) getComponent("weight"));
delegate.recordFieldInputValue(Double.toString(weight));
delegate.record("Weight must be >=0",
ValidationConstraint.TOO_SMALL);
}
Integer discount = (Integer) patronCodeToDiscount.get(getPatronCode());
if (discount == null && getPatronCode() !=null) {
delegate
.setFormComponent((IFormComponent) getComponent("patronCode"));
delegate.recordFieldInputValue(getPatronCode());
delegate.record("Patron not found", null);
}
if (delegate.getHasErrors()) {
return null;
}
int postagePerKg = 10;
int postage = (int) (weight * postagePerKg);
if (discount != null) {
postage = postage * discount.intValue() / 100;
}
IPage resultPage = getResult();
PropertyUtils.write(resultPage, "postage", new Integer(postage));
return resultPage;
}
}
Now run the application and it should continue to work:
68 Chapter 3 Validating Input
Note that the error message is now generated by the validator and it is using the display name. In addition, two red
stars are displayed after the input field to indicate that it is in error. This is done by the TextField. When it is asked to
render itself (see the diagram below), it will check with the validation delegate to see if it is in error. If yes, it will display
its "old value" from the validation delegate and ask the validation delegate to any extra error indication (which is two red
stars):
1: Render
yourself
s
Ye
"
bc
3:
If there is no error for the "weight" component in the validation delegate, then it will get the object by calling getWeight()
(see the diagram below) and ask the validator to convert the object back into a string. Then it will output a text field with
the string as the value:
Validating Input 69
5: It's int 5
1: Render
yourself
No
3:
r?
ro
4: Call er
in
getWeight() m I
A
2:
6: Convert Integer(5) to a
Component string
"weight" translator
7: The string is "5"
That's the reason why the old value must be stored in the error in the validation delegate. You simply can't retrieve the
invalid value by calling getWeight() on the page object.
The weight is turned into 0 when it is converted from an Integer object (null) into an int, so the postage calculated is
also 0.
However, in this particular application, the weight is not optional. So, how to enforce this? You can add a validator that
explicitly rejects null. To do that, modify Home.page:
<page-specification class="com.ttdev.postage.Home">
<component id="form" type="Form">
<binding name="listener" value="listener:onSubmit"/>
<binding name="delegate" value="beans.delegate"/>
</component>
<component id="weight" type="TextField">
<binding name="value" value="weight"/>
<binding name="translator" value="translator:number,pattern=#.#"/>
<binding name="validators" value="validators:required,min=0"/>
<binding name="displayName" value="literal:Weight"/>
</component>
<component id="patronCode" type="TextField"> Add a new validator here.
<binding name="value" value="patronCode"/> It will check to ensure the
</component> object is not null.
<component id="errors" type="Delegator">
<binding name="delegate" value="beans.delegate.firstError"/>
</component>
</page-specification>
Now, run the application and it should work (BUG ALERT: In Tapestry 4.1.1 there is a bug in NumberTranslator. It will
convert an empty string into 0, not null. This will get pass both the required and the min validators):
<page-specification class="com.ttdev.postage.Home">
<component id="form" type="Form">
<binding name="listener" value="listener:onSubmit"/>
<binding name="delegate" value="beans.delegate"/>
</component>
<component id="weight" type="TextField">
<binding name="value" value="weight"/>
<binding name="translator" value="translator:number,pattern=#.#"/>
<binding name="validators" value="validators:required[The {0} is missing!],min=0"/>
<binding name="displayName" value="literal:Weight"/>
</component>
<component id="patronCode" type="TextField"> This is the error
<binding name="value" value="patronCode"/> message The {0} will be replaced by
</component> the display name
<component id="errors" type="Delegator"> ("Weight" in this case).
<binding name="delegate" value="beans.delegate.firstError"/>
</component>
</page-specification>
Using a FieldLabel
In addition to the two red stars, you could turn the label into red too. To do that, use a FieldLabel component:
<html>
<span jwcid="errors"/>
<form jwcid="form">
<table>
<tr>
<td><span jwcid="weightLabel">Weight:</span></td>
<td><input type="text" jwcid="weight"/></td>
</tr>
<tr>
<td>Patron code:</td>
<td><input type="text" jwcid="patronCode"/></td>
</tr>
<tr>
<td></td>
<td><input type="submit"/></td>
</tr>
</table>
</form>
</html>
Define it in Home.page:
<page-specification class="com.ttdev.postage.Home">
<component id="form" type="Form">
<binding name="listener" value="listener:onSubmit"/>
<binding name="delegate" value="beans.delegate"/>
</component>
<component id="weight" type="TextField">
<binding name="value" value="weight"/>
<binding name="translator" value="translator:number,pattern=#.#"/>
<binding name="validators" value="validators:required[The {0} is missing!],min=0"/>
<binding name="displayName" value="literal:Weight"/>
</component>
<component id="patronCode" type="TextField">
<binding name="value" value="patronCode"/>
</component>
<component id="errors" type="Delegator">
<binding name="delegate" value="beans.delegate.firstError"/>
</component>
<component id="weightLabel" type="FieldLabel"> When it renders itself, it will check with
<binding name="field" value="component:weight"/> the validation delegation to see if the
</component> "weight" component is in error. If so, it
</page-specification> will render itself in red.
This prefix says that what is following is the name
of a component in this page. The value of the
whole expression is that component object. In this
case it's the "weight" component.
Now, run the application and it will be like:
72 Chapter 3 Validating Input
Note that it uses the display name of the ValidField to render itself.
public KnownPatrons() {
patronCodeToDiscount = new HashMap();
patronCodeToDiscount.put("p1", new Integer(90));
patronCodeToDiscount.put("p2", new Integer(95));
}
public Integer getDiscount(String patronCode) {
return (Integer) patronCodeToDiscount.get(patronCode);
}
public boolean isKnown(String patronCode) {
return patronCodeToDiscount.containsKey(patronCode);
Validating Input 73
}
}
Modify Home.java to create a KnownPatrons object:
public abstract class Home extends BasePage {
private Map patronCodeToDiscount;
private KnownPatrons knownPatrons;
@InjectPage("Result")
public abstract IPage getResult();
public abstract double getWeight();
public abstract String getPatronCode();
@Bean
public abstract ValidationDelegate getDelegate();
public Home() {
knownPatrons = new KnownPatrons();
patronCodeToDiscount = new HashMap();
patronCodeToDiscount.put("p1", new Integer(90));
patronCodeToDiscount.put("p2", new Integer(95));
}
public KnownPatrons getKnownPatrons() {
return knownPatrons;
}
public IPage onSubmit() {
ValidationDelegate delegate = getDelegate();
double weight = getWeight();
Integer discount = knownPatrons.getDiscount(getPatronCode());
Integer discount = (Integer) patronCodeToDiscount.get(getPatronCode());
if (discount == null && !getPatronCode().equals("")) {
delegate.setFormComponent((IFormComponent) getComponent("patronCode"));
delegate.recordFieldInputValue(getPatronCode());
delegate.record("Patron not found", null);
}
if (delegate.getHasErrors()) {
return null;
}
int postagePerKg = 10;
int postage = (int) (weight * postagePerKg);
if (discount != null) {
postage = postage * discount.intValue() / 100;
}
IPage resultPage = getResult();
PropertyUtils.write(resultPage, "postage", new Integer(postage));
return resultPage;
}
}
In Home.page, you can't use the validators prefix to create this validator because you need to call setKnownPatrons()
on it. So, declare it as a bean:
<page-specification class="com.ttdev.postage.Home">
<bean name="patronCodeValidator" class="com.ttdev.postage.PatronCodeValidator">
<set name="knownPatrons" value="ognl:knownPatrons"/> Call getKnownPatrons() on the
</bean> page object and store the result
<component id="form" type="Form"> into the "knownPatrons"
<binding name="listener" value="listener:onSubmit"/> property of the validator
<binding name="delegate" value="beans.delegate"/>
</component>
<component id="weight" type="TextField">
<binding name="value" value="weight"/>
<binding name="translator" value="translator:number,pattern=#.#"/>
<binding name="validators" value="validators:required[The {0} is missing!],min=0"/>
<binding name="displayName" value="literal:Weight"/>
</component> The bean prefix says what is
<component id="patronCode" type="TextField"> following is the name of a bean.
<binding name="value" value="patronCode"/> The value of the whole expression
<binding name="validators" value="bean:patronCodeValidator"/> is that bean object. If you'd like,
</component> you could write
<component id="errors" type="Delegator"> "ognl:beans.patronCodeValidator"
<binding name="delegate" value="beans.delegate.firstError"/> and achieve the same effect.
</component>
<component id="weightLabel" type="FieldLabel">
<binding name="field" value="component:weight"/>
</component>
</page-specification>
Again, you could use a FieldLabel for the patron code so that it is turned into red. For the moment you're using the
"bean" binding prefix to access the bean as the validator. What if you need to specify two validators? For example, if
the patron code is required, you'd like to add the "required" validator. How to do that? First, it is incorrect to write:
<component id="patronCode" type="TextField">
<binding name="value" value="patronCode"/>
<binding name="validators" value="validators:required,bean:patronCodeValidator"/>
</component>
<page-specification class="com.ttdev.postage.Home">
...
<component id="errors" type="Delegator"> The getFieldTracking()
<binding name="delegate" value="beans.delegate.firstError"/>
method of the
</component>
validation delegate will
<property name="currentFieldTracking"/>
return a list of its rows.
<component id="errors" type="For">
Each row is called a
<binding name="source" value="beans.delegate.fieldTracking"/>
"field tracking".
<binding name="value" value="currentFieldTracking"/>
</component>
<component id="error" type="Delegator">
<binding name="delegate" value="currentFieldTracking.errorRenderer"/>
</component>
</page-specification>
Note that there is an empty <li> there. Why? This is because due to the current implementation of TextField, it will
76 Chapter 3 Validating Input
create a field tracking even if there is no error at all. For example, for the above case, the validation delegate would be
like:
Field name Old value Error renderer
weight "5" null
patronCode "p3" An error render that will render itself as a string "Patron not found".
To solve this problem, modify Home.html:
<html>
<ul>
<span jwcid="errors">
<span jwcid="isInError">
<li><span jwcid="error"/></li>
</span>
</span>
</ul>
...
</html>
The idea is that this "isInError" will check the current field tracking to see if its render is not null. If yes, it will go ahead
to render its body. Otherwise it will render nothing (so the <li> will not be displayed). Define this component in
Home.page:
<page-specification class="com.ttdev.postage.Home">
...
<property name="currentFieldTracking"/>
<component id="errors" type="For">
<binding name="source" value="beans.delegate.fieldTracking"/>
<binding name="value" value="currentFieldTracking"/>
</component>
<component id="error" type="Delegator">
<binding name="delegate" value="currentFieldTracking.errorRenderer"/>
</component>
<component id="isInError" type="If">
<binding name="condition" value="currentFieldTracking.inError"/>
</component>
</page-specification>
To decide to render its body or not, the If component will evaluate its "condition" parameter. In this case, it will call
isInError() method on the current field tracking (it will try getInError() first but there is no such method, so it will try
isInError() next). The isInError() will return true if the error renderer is not null. Now try it again and it should work:
Now run it again and it should continue to work. In addition to the Conditional component, by default the For component
will also render itself as its associated HTML element. For example, the following For component will generate a <tr>
for each value in the source before rendering its body:
<table>
<tr jwcid="XXX">...</tr>
</table>
</li>
</span>
</ul>
...
</body>
</html>
Try it again and enter -20 as the weight:
Both the shipping date and description must be specified. The shipping date must be in the format of yyyy/MM/dd and
must be between December 1, 2006 and May 31, 2007. The description must not exceed 20 characters. To do that,
modify Home.html:
<html jwcid="@Shell" title="Postage">
<body jwcid="@Body">
<ul>
<span jwcid="errors">
<li jwcid="isInError" style="color: red">
<span jwcid="error"/>
</li>
</span>
</ul>
<form jwcid="form">
<table>
<tr>
<td><span jwcid="weightLabel">Weight:</span></td>
<td><input type="text" jwcid="weight"/></td>
</tr>
<tr>
<td>Patron code:</td>
<td><input type="text" jwcid="patronCode"/></td>
</tr>
<tr>
<td>Shipping date:</td>
<td><input type="text" jwcid="shippingDate"/></td>
</tr>
<tr>
80 Chapter 3 Validating Input
<td>Description:</td>
<td><textarea jwcid="desc"></textarea></td>
</tr>
<tr>
<td></td>
<td><input type="submit"/></td>
</tr>
</table>
</form>
</body>
Home.page is:
A minDate validator which will make sure the date is >=
Use a date translator. Set the "pattern" property of the
December 1, 2006. How does Tapestry figure out which
date translator to yyyy/MM/dd, so that the shipping date
number in 12/1/2006 is the year, the month and the day?
value is displayed in this format and it will also expect the
If a number is large (e.g., 2006), it must be the year.
input to be in this format. Internally the date translator
Then for the rest of the numbers, the first number will be
simply uses the Java SimpleDateFormat class to do the
work. So you can lookup its API doc to find other taken as the month and the other as the day. So, you
possible patterns. could have specify the same date as 2006/12/1 without
changing the meaning at all.
<page-specification class="com.ttdev.postage.Home"> A maxDate validator
... which will make sure
<component id="shippingDate" type="DatePicker"> the date is <= May 31,
<binding name="value" value="shippingDate"/> 2005
<binding name="translator" value="translator:date,pattern=yyyy/MM/dd"/>
<binding name="validators" value="validators:required,minDate=12/1/2006,maxDate=5/31/2007"/>
<binding name="displayName" value="literal:Shipping date"/>
</component>
<component id="desc" type="TextArea"> The shipping date is required
<binding name="value" value="desc"/>
<binding name="validators" value="validators:required,maxLength=20"/>
<binding name="displayName" value="literal:Description"/>
</component>
</page-specification>
Provide a display name Limit the length of the
for each so that they description to <= 20
can be used in the error
message (if any) The description is
required
Home.java is:
public abstract class Home extends BasePage {
Define the two
public abstract Date getShippingDate();
properties to store the
public abstract String getDesc();
input
public IPage onSubmit() {
System.out.println(getShippingDate()); Just print them out
System.out.println(getDesc());
...
}
}
Now run the application and it should work:
Validating Input 81
Other validators
You have seen some validators: required, min, minDate, maxDate, maxLength. In addition, there are other validators
coming with Tapestry. Here is a summary:
Validator Purpose
min Checks if a number is >= the given number.
max Checks if a number is <= the given number.
email Checks if a string is an email.
minLength Checks if the length of a string is >= the given length.
maxLength Checks if the length of a string is <= the given length.
82 Chapter 3 Validating Input
Validator Purpose
pattern Checks if a string matches a "regular expression". For example, to check if the input
string is a name, i.e., consisting of one or more letters (a-z) or digits:
<binding name="validators" value="validators:pattern=\w+"/>
In which \w means a word character (letter or digit) and XXX+ means to expect XXX
one or more times. To accept an empty name:
<binding name="validators" value="validators:pattern=\w*"/>
In which XXX* means to expect XXX zero or more times. If you'd like to check if the
input string is a phone # like 123-4567:
<binding name="validators" value="validators:pattern=\d{3}\-\d{4}"/>
In which \d means a digit and XXX{3} means to expect XXX three times.
minDate Checks if a Date is >= the given Date.
maxDate Checks if a Date is <= the given Date.
For the details, check the API documentation.
Summary
You can present any object in a TextField, as long as you provide a translator. The TextField will use it to translate the
value to a string for display and on form submission, it will use it to translate the string input back into an object to store
into the value. If a translator can't translate the string into a value, it will act like a validator and record an error. Most
translators in Tapestry will treat an empty string as valid and return a null. This is used to allow optional input.
To validate the input in a TextField, just specify a list of validators for the TextField. If any validator considers the value
invalid, it will record a field tracking in the validation delegate. How can the validator find out the validation delegate to
use? It finds it from the surrounding Form component, so you must tell the Form component which validation delegate
to use. Most validators in Tapestry will consider null as valid to allow optional input. If the input is mandatory, you should
use a required validator.
In addition to the TextField component, the DatePicker component and TextArea component also support translation
and validation in exactly the same way.
If the validation is not related to a particular input field, you may do it in your submit listener and record the error into the
validation delegate yourself.
A validation delegate is just a list of field trackings. A field tracking records the name of the HTML input field, the old
value (maybe invalid) and an error renderer. This allows us to re-display the page with the invalid input and error
messages when there are errors. A field tracking is not necessarily an error (i.e., when the error renderer is null).
Usually you will use a bean to hold the validation delegate and maybe some validators that require complicated
initialization. As beans they will be created on demand whenever they're used. Some validators support validation using
Javascript. To use this feature, you need to enable client side validation on the Form component. In addition, you
should have a Shell component and a Body component. In addition to using the validators provided by Tapestry, you
can easily create your own by implementing the Validator interface.
To display the first error in the validation delegate, you can use a Delegator component to delegate the rendering to the
first non-null error renderer. To display all errors, you need to use a For component to loop through all the field
trackings. A For component will loop through its "source" parameter and render its body for each element.
If you'd like to optionally skip a something in the HTML page, you can use an If component. If the condition is true, it will
render its body. Otherwise it will skip it.
You can use a FieldLabel along with a TextField so that it shows itself in red if the TextField is in error. It checks if the
TextField is in error by checking the validation delegate.
You can use informal parameters for many Tapestry components. They will be passed on to the HTML element
generated by that component.
83
Chapter 4
Chapter 4 Creating an e-Shop
84 Chapter 4 Creating an e-Shop
Creating an e-shop
Suppose that you'd like to create an e-shop. The front page lists all the products:
As you can see, a product has an id, a name and a price. For example, the first product's id is "p01", its name is
"Pencil" and its price is $1.2. If the user clicks on a product say "Eraser", he will see a detailed description of Eraser:
For simplicity, you will be using strings like "a", "b" and "c" as the detailed descriptions for the products. OK, Let's do it.
First, create a new project named "Shop". Setup the class path, output folder and web.xml as usual. Create a context
descriptor Shop.xml in c:\Tomcat\conf\Catalina\localhost:
<Context
docBase="c:/workspace/Shop/context"
path="/Shop"
reloadable="true"/>
Next, let's display the product listing in the Home page. Where do you get the information of the products? For
simplicity, let's hard code the information. So, create a Catalog class in package com.ttdev.shop:
public class Catalog {
private List products;
private static Catalog globalCatalog;
public Catalog() {
products = new ArrayList();
}
public List getProducts() {
return products;
}
public void add(Product product) {
products.add(product);
}
Creating an e-Shop 85
<page-specification class="com.ttdev.shop.Home">
<property name="currentProduct"/> Store each product into
<component id="products" type="For"> here
<binding name="source" value="products"/>
<binding name="value" value="currentProduct"/>
</component>
<component id="id" type="Insert">
<binding name="value" value="currentProduct.id"/>
</component>
<component id="name" type="Insert">
<binding name="value" value="currentProduct.name"/>
</component>
<component id="price" type="Insert">
<binding name="value" value="currentProduct.price"/>
</component>
</page-specification>
public class Product {
private String id;
Need to provide the private String name;
getters private String desc;
private double price;
...
public String getId() {
return id;
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
}
You need to provide a page object in Home.java to provide the list of Product objects:
public abstract class Home extends BasePage {
public List getProducts() {
return Catalog.getGlobalCatalog().getProducts();
}
}
Now, start Tomcat and run the application. You should see:
<td><span jwcid="id">p01</span></td>
<td><a href="" jwcid="detailsLink"><span jwcid="name">Pencil</span></a></td>
<td><span jwcid="price">1.20</span></td>
</tr>
</table>
</html>
This <a> element is just for preview only. The "detailsLink" component will generate a completely new <a> element to
replace for it. It means you could use <span> or any other HTML element instead of <a>. Next, define the "detailsLink"
component in Home.page:
<page-specification class="com.ttdev.shop.Home">
<property name="currentProduct"/>
<component id="products" type="For">
<binding name="source" value="products"/>
<binding name="value" value="currentProduct"/>
</component>
<component id="id" type="Insert">
<binding name="value" value="currentProduct.id"/>
</component>
<component id="name" type="Insert">
<binding name="value" value="currentProduct.name"/>
</component>
<component id="price" type="Insert">
<binding name="value" value="currentProduct.price"/>
</component>
<component id="detailsLink" type="DirectLink">
<binding name="listener" value="listener:onClickDetailsLink"/>
</component>
</page-specification>
1: Render 2: Render its
public void onClickDetailsLink() {
itself as an body
...
HTML link ...
}
<a href="...">XXX</a> 3: When the user
clicks the link, the
listener will be called.
As shown above, the DirectLink component will render itself as an HTML link. When the user clicks the link, a request
will be sent to the server to call Tapestry. Tapestry will call the onClickDetailsLink() listener. Therefore, you need to
define such a listener method:
public abstract class Home extends BasePage {
...
public void onClickDetailsLink() {
//show the details page of the product
}
}
In this method you should activate the page that shows the details of the product. For simplicity, let's just print a
message to the console and then do nothing (so the original page is displayed again):
public abstract class Home extends BasePage {
public List getProducts() {
return Catalog.getGlobalCatalog().getProducts();
}
public void onClickDetailsLink() {
System.out.println("DetailsLink was clicked");
}
}
Now, run the program. You should see the links:
88 Chapter 4 Creating an e-Shop
You don't have to care much about how the parameters are encoded. In your listener, you need to declare each
parameter as an argument in the method signature:
public abstract class Home extends BasePage {
public List getProducts() {
return Catalog.getGlobalCatalog().getProducts();
}
public void onClickDetailsLink(String productId) {
System.out.println("DetailsLink was clicked for product " + productId);
}
}
Tapestry will automatically decode each parameter and pass it to your listener method as an argument. Let's run the
program and click say the "Eraser". You will see the console output:
DetailsLink was clicked
...
INFO: Reloading this Context has started
...
DetailsLink was clicked for product p02
To actually display the details of a product, create a new page named ProductDetails. Modify ProductDetails.html:
<html>
<head>
<title><span jwcid="name">Pencil</span></title>
</head>
<body>
<h1><span jwcid="name2">Pencil</span></h1>
<span jwcid="desc">xxx</span>
</body>
</html>
Note that you'd like to display the name of the product twice: Once as the page title and another as an <h1> heading.
However, you cannot use a single component twice in a template. This is a limitation of Tapestry. You must use two
components. In addition, each component must have a unique name. So, you have named them "name" and "name2"
respectively.
You need to define the component in ProductDetails.page:
90 Chapter 4 Creating an e-Shop
<page-specification class="com.ttdev.shop.ProductDetails">
<component id="name" type="Insert"> 1: Make a copy
<binding name="value" value="name"/>
</component>
<component id="name2" copy-of="name"/> Component "name2" is a copy
<component id="desc" type="Insert"> of "name1".
<binding name="value" value="desc"/> 2: Change the id to
</component> "name2"
</page-specification>
<component id="name2" type="Insert">
<binding name="value" expression="name"/>
</component>
As shown above, component "name2" is just a copy of "name1". You don't need to specify anything else for it, not even
the component type nor the bindings. This trick is handy when you need to use the same component at multiple places
in the same HTML file.
As these components need to get the name and description of the product from the page object, you must use a
custom page object:
public abstract class ProductDetails extends BasePage {
private String productId;
Someone must call setProductId()
public void setProductId(String id) { to tell it the product id before
this.productId = id; getName() and getDesc() are
} called.
public String getName() {
return lookup().getName();
} Lookup the global catalog to find the
public String getDesc() { product object using the product id. Of
return lookup().getDesc(); course, you need to define this
} method.
private Product lookup() {
return Catalog.getGlobalCatalog().lookup(productId);
}
}
Define this lookup() method:
public class Catalog {
private List products;
...
public Product lookup(String productId) {
for (Iterator iter = products.iterator(); iter.hasNext();) {
Product product = (Product) iter.next();
if (product.getId().equals(productId)) {
return product;
}
}
throw new IllegalArgumentException("Unknown product id: " + productId);
}
}
The Product class also needs to have a getDesc() getter:
public class Product {
private String id;
private String name;
private String desc;
private double price;
...
public String getDesc() {
return desc;
}
}
Now, let's display this new page in your listener:
Creating an e-Shop 91
A meta property is
just a configuration If an XXX.page file doesn't specify a
Java class, then look for an XXX class
setting for Tapestry
in the com.ttdev.shop package. You
could specify a list of packages there:
This file is called the "application specification". It sets some global settings for the whole application. Then you don't
need to set the page class in the .page files. For Home.page:
<page-specification class="com.ttdev.shop.Home">
<property name="currentProduct"/>
<component id="products" type="For">
<binding name="source" value="products"/>
<binding name="value" value="currentProduct"/>
</component>
92 Chapter 4 Creating an e-Shop
If the user clicks "Continue shopping", the product listing page will be displayed:
If the user clicks "Add to cart", a single piece of the product is added to his shopping cart, then the contents of his
shopping cart will be displayed:
From there the user should be able to continue shopping or checkout. Now, let's do it. First, add the two buttons to the
ProductDetails.html:
Creating an e-Shop 93
<html>
<head>
<title><span jwcid="name">Pencil</span></title>
</head>
<body>
<h1><span jwcid="name2">Pencil</span></h1>
<span jwcid="desc">xxx</span>
<form jwcid="productActionForm">
<input type="submit" value="Add to cart" jwcid="addToCart"/>
<input type="submit" value="Continue shopping" jwcid="continueShopping"/>
</form>
</body>
</html>
Note that a button must appear in a form, so you must have a Form component. Then, you may define the components
like this:
<page-specification>
<component id="name" type="Insert">
<binding name="value" value="name"/>
</component>
<component id="name2" copy-of="name"/>
<component id="desc" type="Insert">
<binding name="value" value="desc"/>
</component>
<component id="productActionForm" type="Form">
<binding name="listener" value="listener:actionOnProduct"/>
</component>
<component id="addToCart" type="Submit"/>
<component id="continueShopping" type="Submit"/>
</page-specification>
Of course, for this to work you need to define an actionOnProduct() listener in the page object. However, there is a
problem here: In the listener how can you tell which button was clicked?
...
public void setButtonClicked(int buttonClicked) {
this.buttonClicked = buttonClicked;
}
public String actionOnProduct() {
if (buttonClicked==0) {
//add product to cart
...
return null;
}
if (buttonClicked==1) {
return "Home";
}
return null;
}
}
The tag doesn't have to be an int, it can be any object. For example, you could use strings:
<page-specification>
...
<component id="productActionForm" type="Form">
<binding name="listener" value="listener:actionOnProduct"/>
</component>
<component id="addToCart" type="Submit">
<binding name="selected" value="buttonClicked"/>
<binding name="tag" value="literal:CART"/>
</component>
<component id="continueShopping" type="Submit">
<binding name="selected" value="buttonClicked"/>
<binding name="tag" value="literal:SHOP"/>
</component>
</page-specification>
The Java code should change accordingly:
public abstract class ProductDetails extends BasePage {
private String buttonClicked;
...
public void setButtonClicked(String buttonClicked) {
this.buttonClicked = buttonClicked;
}
public String actionOnProduct() {
if (buttonClicked.equals("CART")) {
//add product to cart
//...
return null;
}
if (buttonClicked.equals("SHOP")) {
return "Home";
}
return null;
}
}
You could even use Java static constants:
public abstract class ProductDetails extends BasePage {
static public final String ADD_TO_CART_BUTTON="CART";
static public final String CONTINUE_SHOPPING_BUTTON="SHOP";
private String buttonClicked;
...
public void setButtonClicked(String buttonClicked) {
this.buttonClicked = buttonClicked;
}
public String actionOnProduct() {
if (buttonClicked.equals(ADD_TO_CART_BUTTON)) {
//add product to cart
//...
return null;
}
if (buttonClicked.equals(CONTINUE_SHOPPING_BUTTON)) {
return "Home";
}
return null;
}
}
The tags should be set like this:
So, using a tag to distinguish which button was clicked is the second solution. The third solution is to have a separate
Creating an e-Shop 95
<page-specification class="com.ttdev.shop.ProductDetails">
...
<component id="addToCart" type="Submit">
<binding name="selected" value="buttonClicked"/>
<binding name="tag"
value="ognl:@com.ttdev.shop.ProductDetails@ADD_TO_CART_BUTTON"/>
</component>
<component id="continueShopping" type="Submit">
<binding name="selected" value="buttonClicked"/>
<binding name="tag"
value="ognl:@com.ttdev.shop.ProductDetails@CONTINUE_SHOPPING_BUTTON"/>
</component>
</page-specification>
components following the "addToCart", they will not have been rendered yet and the user input will not have been
stored into the page object when the listener is called. If this is a problem, you can modify ProductDetails.page like this:
<page-specification>
...
<component id="addToCart" type="Submit">
<binding name="listener" value="listener:addToCart"/>
<binding name="action" value="listener:addToCart"/>
</component>
<component id="continueShopping" type="Submit">
<binding name="listener" value="listener:continueShopping"/>
<binding name="action" value="listener:continueShopping"/>
</component>
</page-specification>
<html>
...
...
...
Tapestry (Direct
</html> 3: The user service) 4: Give me a
submits the form
ProductDetails
page
5: Let the Form or
Submit button call their Page pool
listeners
To solve this problem, you can use a hidden form field to hold the product id:
<html>
...
<form jwcid="productActionForm">
<input type="hidden" jwcid="productId"/>
<input type="submit" value="Add to cart" jwcid="addToCart"/>
<input type="submit" value="Continue shopping" jwcid="continueShopping"/>
</form>
</body>
</html>
You will use a component of type Hidden to render a hidden form field:
<page-specification>
...
<component id="productId" type="Hidden">
<binding name="value" value="productId"/>
</component>
</page-specification>
How does it work? When the component is rendered (see the diagram below), it will call getProductId() on the page
object to get the product id (suppose that it's p01). Then it will output an HTML hidden form field to store p01 as the
value:
98 Chapter 4 Creating an e-Shop
<form ...>
<input
1: Render yourself type="hidden"
name="productId"
4: Output an HTML hidden form value="p01"/>
field ...
Hidden
2: Look, what's my value? Oh,
it's "productId".
...
<page-specification>
...
<component id="productId" type="Hidden">
<binding name="value" value="productId"/>
</component>
</page-specification>
When the form is submitted (see the diagram below), the value in the hidden form field (p01) is included in the HTTP
request. When the Hidden component is asked to handle the form submission, it will get the value p01 from the HTTP
request and then call setProductId() to set the p01 value to the page object. Then the page object will ask your Submit
component to handle the form submission. The latter will call your listener immediately if you're using the "listener"
parameter. At that time you can safely use "productId" in the listener. Of course, this works only if your Hidden
component is placed before your Submit component so that it handles the form submission before the Submit
component does. Otherwise, you may use the "action" parameter of the Submit component.
Creating an e-Shop 99
Hidden
7: Handle the form
submission 5: Look, what's my value? Oh,
it's "productId".
<page-specification ...>
...
<component id="productId" type="Hidden">
<binding name="value" value="productId"/>
</component>
</page-specification>
As you already have the "productId" field and the setProductId() method, you only need to add the getter:
public abstract class ProductDetails extends BasePage {
private String productId;
...
public void setProductId(String id) {
this.productId = id;
}
public String getProductId() {
return productId;
}
public void addToCart() {
System.out.println("Trying to add " + productId + " to cart");
}
}
Now, you know which product you're talking about. The next step is to add the product id to the shopping cart. You
could use a Java List to represent the shopping cart (just store the product ids on the List). If you do it this way:
public abstract class ProductDetails extends BasePage {
private String productId;
private List cart;
public ProductDetails() {
cart = new ArrayList();
}
public void addToCart() {
cart.add(productId);
}
}
Then depending on which page object Tapestry picks up from the pool, I may add a product to your shopping cart and
you may add a product to another user's.
You can't use a global shopping cart either:
public class Cart {
public static List cart = new ArrayList();
}
100 Chapter 4 Creating an e-Shop
Your
application
Name Object
cart ArrayList
foo
... ...
<?xml version="1.0"?>
<module id="com.ttdev.shop" version="1.0.0">
<contribution configuration-id="tapestry.state.ApplicationObjects">
<state-object name="cart" scope="session">
<create-instance class="java.util.ArrayList"/>
</state-object>
</contribution>
</module>
@InjectState("cart")
public abstract List getCart();
Of course, if you have added more products you will see all of them listed here. This is because the session is
accumulating the product id's, and even after the web application is reloaded, the session is not affected at all. In fact,
even if you restart Tomcat, the session is still there because Tomcat will save it to disk and load it back later (for this to
work, the objects you store into the session must implement Serializable. In this case, as the "cart" object is an
ArrayList which does implement Serializable, so it will be fine). To get rid of the old session and get a new one, you may
wait say 30 minutes, but an easier way is to close the browser and open a new one. But how does it work? To
understand it, you need to understand how Tomcat and the browser co-operate to maintain the session.
Click "Privacy" on the top, click "Show Cookies". Locate the site "localhost" and you'll find a cookie whose name is
"JSESSIONID". This is how Tomcat and the browser maintain the session:
When a user first accesses a web application, Tomcat will generate a random number called "session id" (as shown in
the screen shot above, the id is "57D6E808...") and use it to identify the session. That is, inside Tomcat the sessions
(storage areas) may be like:
Creating an e-Shop 105
The path /Shop is the context path of the web application you set in c:\Tomcat\conf\Catalina\localhost\Shop.xml:
<Context
docBase="c:/workspace/Shop/context"
path="/Shop"
reloadable="true"/>
Later when the browser accesses any page of the application (e.g., http://localhost/Shop/app), the browser finds that
there is a cookie associated with this host ("localhost") and that the path /Shop/app being accessed is somewhere
under the path associated with the cookie (/Shop), so it will send the content of the cookie (the session id) to the server.
When Tomcat receives the session id, it can find out which session to use with the id.
It means that if you delete this cookie and then access the application again, Tomcat will treat you as a new user. But
why restarting the browser also works? The cookies are stored on disk and so they are persistent. So, usually
restarting the browser will not delete them. However, each cookie has a maximum age (in seconds). For example, see
the "Expires" field of another cookie shown below:
106 Chapter 4 Creating an e-Shop
If that age is set to -1, it means the browser should delete the cookie when the browser is closed. As shown in the
screen shot below, this is exactly the case with your JSESSIONID cookie ("at end of session"):
Now, you understand how a session is maintained. Let's do one more experiment. Restart the browser and try to
access the application:
Creating an e-Shop 107
Then check if the JSESSIONID cookie is there. Surprisingly, you won't find it. Now, try to add a product to the shopping
cart:
Now check if the cookie is there and you will find it. Why is it so? The previous description on how session is created
was not very accurate. In fact, to save memory, Tomcat will not create a session when a user first accesses a web
application. Instead, it waits until the first time when the web application tries to access (read or write) a session, then it
will create the session on the fly. In your case, this occurs when you call getCart() to ask Tapestry for the application
state object named "cart" (when responding to the user's click on "Add to cart"):
public abstract class ProductDetails extends BasePage {
...
public String addToCart() {
getCart().add(productId);
return "Cart";
}
}
A session will have been created. But how is it maintained? Let's view the HTML source to the web page. It should be
like:
As you can see above, the session id has been added to the action URL of the form. This way, when the form is
submitted, Tomcat will receives the session id. When the next page is rendered, Tomcat will also automatically add the
same session id to the URLs. It means the session is maintained using "URL rewriting". In this case, to get rid of the
session, closing the browser will also work.
The most important point to note here is that a hidden form field is used to store the product id (p02 in this case) so that
Creating an e-Shop 109
when the user clicks on "Add to cart", the listener can find out the product id. What if you'd like to add a link to refresh
the page to make sure the latest data is displayed:
To do that, you can use a direct link and encode the product id as a parameter. However, note that you're using two
methods to transfer the product id back to that page (hidden field and query parameter). All you'd like is that the
ProductDetails page can "remember" the product id, no matter it is activated by a form submission or by a direct link.
Fortunately, Tapestry allows you to do that very easily. First (see the diagram below), in the ProductDetails page you
declare the productId property as a persistent property. Then when the ProductDetails page renders itself, Tapestry will
note that it has such a persistent property, then it will add a hidden form field to each <form> and a query parameter to
each link in the HTML code to store the product id:
Tapestry
4: Generate hidden fields for each <form>
and a query parameter for each link on the
HTML page
3: Look, it has a 1: Render yourself
persistent property.
<html>
...
ProductDetails page <form>
2: Output HTML page <input type="hidden"
Persistent properties name="ProductDetails-productId"
value="p01"/>
productId: p01 ...
</form>
...
<a href="/foo/bar?ProductDetails-
productId=p01">...</a>
</html>
When any form in that HTML page is submitted or when any of the link is clicked, the product id will be included in the
HTTP request. if the request should be handled by a ProductDetails, Tapestry will try to get one from the page pool.
Then it notes that the ProductDetails page has such a persistent property, so it will retrieve the product id from the
HTTP request and store it into the ProductDetails page:
110 Chapter 4 Creating an e-Shop
productId: ???
public void setProductId(String id) { Don't need this anymore. Let Tapestry
this.productId = id; manage the property for you.
}
public String getProductId() { This is a persistent property. "client" means
return productId; that the data will be stored in the HTML
} code in the browser (i.e., client).
@Persist("client")
public abstract String getProductId();
public abstract void setProductId(String productId);
@InjectState("cart")
public abstract List getCart();
The setter is not required in general, but the
public String addToCart() { Home page needs to call it to set the
getCart().add(getProductId()); product id.
return "Cart";
}
public String continueShopping() {
return "Home";
}
public String getName() {
return lookup().getName();
}
public String getDesc() {
return lookup().getDesc();
}
private Product lookup() {
return Catalog.getGlobalCatalog().lookup(getProductId());
}
}
Modify ProductDetails.html to add the refresh link and delete the hidden field:
<html>
<head>
<title><span jwcid="name">Pencil</span></title>
</head>
<body>
<h1><span jwcid="name2">Pencil</span></h1>
<span jwcid="desc">xxx</span>
Creating an e-Shop 111
<form jwcid="productActionForm">
<input type="hidden" jwcid="productId"/>
<input type="submit" value="Add to cart" jwcid="addToCart"/>
<input type="submit" value="Continue shopping" jwcid="continueShopping"/>
</form>
<a href="" jwcid="refresh">Refresh</a>
</body>
</html>
ProductDetails.page is:
<page-specification>
<component id="name" type="Insert">
<binding name="value" value="name"/>
</component>
<component id="name2" copy-of="name"/>
<component id="desc" type="Insert">
<binding name="value" value="desc"/>
</component>
<component id="productActionForm" type="Form">
</component>
<component id="addToCart" type="Submit">
<binding name="listener" value="listener:addToCart"/>
</component>
<component id="continueShopping" type="Submit">
<binding name="listener" value="listener:continueShopping"/>
</component>
<component id="productId" type="Hidden"> It will render itself as an HTML
<binding name="value" value="productId"/> link that will activate a page. The
</component> name of the page is specified
<component id="refresh" type="PageLink"> using its "page" parameter.
<binding name="page" value="literal:ProductDetails"/>
</component>
</page-specification>
Activate the ProductDetails page. The literal
prefix is required otherwise it will try to call
getProductDetails() to get the page name.
Now run the application and both the add to cart function and the refresh function should work. If you check the HTML
code generated:
You can see that all the persistent properties are packed and encoded together and stored under the name
state:<PAGE NAME> (state:ProductDetails in this case).
112 Chapter 4 Creating an e-Shop
Implementing checkout
You have implemented the shopping cart. Now, let's add the checkout function. Suppose that for a user to checkout, he
must first create an account with you (to enter his credit card # and etc.) and login. For simplicity, let's assume that
there are already some existing user accounts:
User id Email Password Credit card #
u001 paul@yahoo.com aaa 1111 2222 3333 4444
u002 john@hotmail.com bbb 2222 3333 4444 5555
u003 mary@gmail.com ccc 3333 4444 5555 6666
Suppose that a user can click a login link on the Home page:
Then the Home page is displayed again. When a user tries to checkout, if he already logged in, he only needs to
confirm the checkout:
Creating an e-Shop 113
But if he hasn't logged in when trying to checkout, he will be asked to login first, then he will see confirm page:
Now, let's add the login link on the Home page. Modify Home.html:
<html>
<head>
<title>Shop</title>
114 Chapter 4 Creating an e-Shop
</head>
<body>
<h1>Product listing</h1>
<table border="1">
<tr jwcid="products">
<td><span jwcid="id">p01</span></td>
<td><a href="" jwcid="detailsLink"><span jwcid="name">Pencil</span></a></td>
<td><span jwcid="price">1.20</span></td>
</tr>
</table>
<p>
<a href="" jwcid="loginLink">Login</a>
</body>
</html>
Define the "loginLink" component in Home.page:
<page-specification>
...
<component id="loginLink" type="PageLink">
<binding name="page" value="literal:Login"/>
</component>
</page-specification>
For this to work, create a new page named Login. Login.html is like:
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form jwcid="loginForm">
<table border="0">
<tr><td>Email:</td><td><input type="text" jwcid="email"/></td></tr>
<tr><td>Password:</td><td><input type="password" jwcid="password"/></td></tr>
<tr><td></td><td><input type="submit" value="Login"/></td></tr>
</table>
</form>
</body>
</html>
Define the components in Login.page:
<page-specification>
<component id="loginForm" type="Form">
<binding name="listener" value="listener:onLogin"/>
</component> Handle the submission in the
<component id="email" type="TextField"> listener of the Form. So, no
<binding name="value" value="email"/> need to make the submit
</component> button a component at all.
<component id="password" type="TextField">
Set "hidden" to true so that it will
<binding name="value" value="password"/>
render itself as a password input
<binding name="hidden" value="true"/>
field instead of a regular text input
</component>
field.
</page-specification>
Login.java is like:
public abstract class Login extends BasePage {
private String email;
private String password;
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
You need to define the classes required:
public class User {
private String id;
private String email;
private String password;
private String creditCardNo;
public Users() {
users = new ArrayList();
}
public void add(User user) {
users.add(user);
}
public User getUser(String email, String password) {
for (Iterator iter = users.iterator(); iter.hasNext();) {
User user = (User) iter.next();
if (user.authenticate(email, password)) {
return user;
}
}
throw new AuthenticationException();
}
public static Users getKnownUsers() {
if (knownUsers == null) {
knownUsers = new Users();
knownUsers.add(new User("u001", "paul@yahoo.com", "aaa", "1111 2222 3333 4444"));
knownUsers.add(new User("u002", "john@hotmail.com", "bbb", "2222 3333 4444 5555"));
knownUsers.add(new User("u003", "mary@gmail.com", "aaa", "3333 4444 5555 6666"));
}
return knownUsers;
}
}
public User() {
}
public User(String id, String email, String password, String creditCardNo) {
this.id = id;
this.email = email;
this.password = password;
this.creditCardNo = creditCardNo;
}
public boolean authenticate(String email, String password) {
return this.email.equals(email) && this.password.equals(password);
}
}
When implementing Serializable, you're strongly recommend to have a static serial version UID. The value can be any
long value. Whenever you change the class that will change its serialized format such as adding a new field, you should
change the version UID. This way if someone sends you a serialized version that is of a different UID, Java will note
that and throw an exception.
Now, after the user logging in, you can save the User object into the session:
public abstract class Login extends BasePage {
private String email;
private String password;
@InjectState("user")
public abstract User getUser();
public User() {
}
public User(String id, String email, String password, String creditCardNo) {
this.id = id;
this.email = email;
this.password = password;
this.creditCardNo = creditCardNo;
Creating an e-Shop 117
}
public boolean authenticate(String email, String password) {
return this.email.equals(email) && this.password.equals(password);
}
public void copyFrom(User user) {
this.id = user.id;
this.email = user.email;
this.password = user.password;
this.creditCardNo = user.creditCardNo;
}
}
Now, restart the application so that the hivemodule.xml is read again. Then test run and try to login using a valid
account. It should work. But what if you use an invalid email or password? You can make use a validation delegate to
display the error. Modify Login.html:
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<span style="color: red"><span jwcid="errorMsg"/></span>
<form jwcid="loginForm">
<table border="0">
<tr><td>Email:</td><td><input type="text" jwcid="email"/></td></tr>
<tr><td>Password:</td><td><input type="password" jwcid="password"/></td></tr>
<tr><td></td><td><input type="submit" value="Login" jwcid="login"/></td></tr>
</table>
</form>
</body>
</html>
Define the "errorMsg" component and the ValidationDelegate bean in Login.page:
<page-specification>
<component id="loginForm" type="Form">
<binding name="listener" value="listener:onLogin"/>
</component>
<component id="email" type="TextField">
<binding name="value" value="email"/>
</component>
<component id="password" type="TextField">
<binding name="value" value="password"/>
<binding name="hidden" value="true"/>
</component>
<component id="errorMsg" type="Delegator">
<binding name="delegate" value="beans.delegate.firstError"/>
</component>
</page-specification>
Provide the validation delegate bean and record any error message in Login.java:
public abstract class Login extends BasePage {
private String email;
private String password;
@InjectState("user")
public abstract User getUser();
@Bean
public abstract ValidationDelegate getDelegate();
total amount and letting him to confirm the purchase. If not, send him to the Login page. That is:
public abstract class Cart extends BasePage {
...
@InjectStateFlag("user")
public abstract boolean getUserExists();
Of course, you don't have to use annotations to inject a state flag. You can achieve the same effect in Cart.page:
<page-specification>
<inject type="state-flag" property="userExists" object="user"/>
...
</page-specification>
Next, you need to create a Confirm page. Confirm.html should be like:
<html>
<head>
<title>Confirmation</title>
</head>
<body>
<h1>Confirm your order</h1>
You're going to pay <span jwcid="total">100</span> with your
credit card <span jwcid="creditCardNo">xxxx yyyy zzzz</span>.
<p>
<form jwcid="confirmForm">
<input type="submit" value="Confirm" jwcid="confirm"/>
<input type="submit" value="Continue shopping" jwcid="continueShopping"/>
</form>
</body>
</html>
Confirm.page is like:
<page-specification>
<component id="total" type="Insert">
<binding name="value" value="total"/>
</component>
<component id="creditCardNo" type="Insert">
<binding name="value" value="creditCardNo"/>
</component>
<component id="confirmForm" type="Form"/>
<component id="confirm" type="Submit">
<binding name="listener" value="listener:onConfirm"/>
</component>
<component id="continueShopping" type="Submit">
<binding name="listener" value="listener:onContinueShopping"/>
</component>
</page-specification>
Confirm.java is like:
public abstract class Confirm extends BasePage {
@InjectState("cart")
public abstract List getCart();
@InjectState("user")
public abstract User getUser();
public User() {
}
public User(String id, String email, String password, String creditCardNo) {
this.id = id;
this.email = email;
this.password = password;
this.creditCardNo = creditCardNo;
}
public boolean authenticate(String email, String password) {
return this.email.equals(email) && this.password.equals(password);
}
public void copyFrom(User user) {
this.id = user.id;
this.email = user.email;
this.password = user.password;
this.creditCardNo = user.creditCardNo;
}
public String getCreditCardNo() {
return creditCardNo;
}
}
Now test run it. Login, add some products, checkout and confirm. It should work fine:
120 Chapter 4 Creating an e-Shop
If the user tries to checkout but he is not logged in yet, he will be sent the Login page:
public abstract class Cart extends BasePage {
...
public String onCheckout() {
if (getUserExists()) {
return "Confirm";
} else {
return "Login";
}
}
}
After logging in, he should be sent to the Confirm page to continue the checkout process:
Login
Cart Confirm
Checkout Already logged in
@Bean
public abstract ValidationDelegate getDelegate();
@InjectState("cart")
public abstract List getCart();
@InjectPage("Login")
public abstract Login getLoginPage();
@InjectPage("Confirm")
public abstract Confirm getConfirmPage();
However, this will not work. The problem is, the "nextPage" variable is indeed properly set when the Login page renders
itself. But when the login form is submitted, Tapestry may get a different Login page from the pool or even create a new
page object. So the value of "nextPage" is lost. To solve this problem, you can either use a hidden field to store the
value of "nextPage" in a hidden form field or make it a client persistent property. Let's take the latter approach as it is
easier:
public abstract class Login extends BasePage {
private String email;
private String password;
private String nextPage = "Home";
@InjectState("user")
public abstract User getUser();
@Bean
public abstract ValidationDelegate getDelegate();
Login
Cart Confirm
Checkout Already logged in
Activate
Malicious user
To solve this problem, the Confirm page should really do the checking to protect itself:
124 Chapter 4 Creating an e-Shop
Login
Cart Confirm
Checkout Activate
Activate
Malicious user
As the Confirm page is now protecting itself, your Cart page no longer needs to protect the Confirm page:
public abstract class Cart extends BasePage {
...
@InjectStateFlag("user")
public abstract boolean getUserExists();
@InjectPage("Login")
public abstract Login getLoginPage();
@InjectPage("Confirm")
public abstract Confirm getConfirmPage();
After logging in, you'd like to return the user to the original ProductDetails page:
</page-specification>
Define the login() listener:
public abstract class ProductDetails extends BasePage {
...
@InjectPage("Login")
public abstract Login getLoginPage();
It is an interface
ICallback representing the next
page
Implements Implements
PageCallback ExternalPageCallback
Page name: XXX Page name: XXX
Parameters: {a, b, c}
Implementing logout
Suppose that you'd like to allow the user to logout:
The minimum that you need to do is to remove the User object from the session. However, a better way is to delete the
session altogether. Tapestry provides a service called "Restart service" that does exactly this: Delete the session and
then display the Home page. To call such the Restart service, modify Home.html:
<html>
<head>
<title>Shop</title>
</head>
<body>
<h1>Product listing</h1>
<table border="1">
<tr jwcid="products">
<td><span jwcid="id">p01</span></td>
<td><a href="" jwcid="detailsLink"><span jwcid="name">Pencil</span></a></td>
<td><span jwcid="price">1.20</span></td>
</tr>
</table>
<p>
<a href="" jwcid="loginLink">Login</a>
<a href="" jwcid="logoutLink">Logout</a>
</body>
</html>
How to call a service using a link? To call a page, you can use a PageLink. To call a listener, you can use a DirectLink.
To call service, you can use a ServiceLink component in Home.page:
Creating an e-Shop 129
<page-specification>
...
<component id="logoutLink" type="ServiceLink">
<binding name="service" value="literal:restart"/>
</component>
</page-specification>
The name of the service to call. The default
prefix is ognl, so you need to tell it this is a
literal here. Or you could quote it with single
quotes.
<page-specification>
...
<component id="logoutLink" type="ServiceLink">
<binding name="service" value="'restart'"/>
</component>
</page-specification>
Summary
To create a link to show a page, use a PageLink component. To create a link to show a page that takes parameters,
either use a DirectLink to pass the parameters to the listener which will activate the page using the bucket brigade
pattern.
If a page takes parameters, it is dangerously easy to come to believe that the parameters are still set when you click a
link or submit the form on that page. This is NOT the case because a new page may be taken from the pool. To
maintain the parameters, either put them into a DirectLink or Hidden fields or store them as the client persistent
properties. If you use client persistent properties, when the page is rendered, the properties will be stored as query
parameters in links and hidden fields in forms. When the user clicks a link or submits a form, if during the handling of
the HTTP request that page is loaded, the properties will be retrieved from the HTTP query and store into the page
object.
You may also store a persistent property into the session. However, you must make sure this data is per-user instead
of per-page, otherwise the "Back" button may cause surprises.
A session is a Map of key-value pairs stored in the memory of the server for each user. The server puts the session id
into a cookie in the browser so that it can lookup the session on the subsequent requests. Or it could can use URL
rewriting to pass the session id along.
Every Tapestry application has a list of application state objects. Each row in the list specifies the name of the object,
its Java class and its scope. The scope can be "session" or "application" and it will determine this object will be put into
the session or into a global area. To add a row to that list, edit the Hivemind module descriptor (at the resource path /
META-INF/hivemodule.xml) to add a contribution to the configuration named tapestry.state.ApplicationObjects. To
access the object, use @InjectState or <inject type="state">. The object will be created automatically if it doesn't exist
yet. Whenever you modify the hivemodule.xml file, don't forget to reload the application because it is only read at start
up. If you're putting an object into the session, your Java class should implement Serializable because the server may
save the data in the session to disk. To check if an application state object exists, use @InjectStateFlag or <inject
type="state-flag">.
If your Form contains multiple Submit components, you need to distinguish which one was clicked. You can have a
different listener for each of them. The listener of the Submit component that is clicked will be called just before the
Form calls its own listener (if any). Another solution is to let the Submit component set its "selected" parameter to its
"tag" value and then in the Form's listener check which one was clicked.
To let a page protect itself, let it implement PageValidateListener and implement the pageValidate() method. It will be
called when the page is activated. If something is wrong, it can redirect to another page by throwing a
PageRedirectException. This is most useful when the page requires that the user has logged in.
Commonly a login page needs to remember the next page to show after the login. The next page can be represented
using an ICallback object. A PageCallback stores just the page name. An ExternalPageCallback stores the page name
and an array of parameters. That page must implement IExternalPage for this to work. The added benefit is that this
page can now be invoked with parameters using a URL.
To implement logout, use the Restart service provided by Tapestry so that the session is deleted. To call it, use a
ServiceLink.
If you need to use a component at two difference locations in the same page with the same parameter bindings, you
should use the copy-of feature.
130 Chapter 4 Creating an e-Shop
Instead of setting the Java class in each .page file, you can list some Java packages in the application specification and
Tapestry will look for a class with the name of the page in those packages.