<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a DOCTYPE to allow us to use special characters like
We use a "strict" DTD to make IE follow the alignment rules. -->
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
<body class="container">
<h3>Wizard Using Pages</h3>
This 4 step wizard is done with 5 <strong>pages</strong>.<br/>
The first 3 steps are data entry. They share a "conversation" during which the browser's Back and Refresh/Reload buttons are allowed.<br/>
The 4th step displays success. Any attempt to return to an ended "conversation" will be redirected to the 5th page - a "bad flow" page.<br/>
<div class="eg">
<t:body/>
</div>
<h4>The best approach?</h4>
So which approach is best for writing wizards? Pages or form fragments? The choice is yours.<br/><br/>
References:
<a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/corelib/components/Form.html">Form</a>,
<a href="http://tapestry.apache.org/session-storage.html">Session Storage</a>.<br/><br/>
<t:pagelink page="Index">Home</t:pagelink><br/><br/>
<t:tabgroup>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/examples/wizard/WizardUsingPagesLayout.tml"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/components/examples/wizard/WizardUsingPagesLayout.java"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages1.tml"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages1.java"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages2.tml"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages2.java"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages3.tml"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages3.java"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/base/examples/wizard/WizardConversationalPage.java"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPagesSuccess.tml"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPagesSuccess.java"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPagesBadFlow.tml"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPagesBadFlow.java"/>
<t:sourcecodetab src="/web/src/main/resources/META-INF/assets/css/examples/wizard.css"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/state/examples/wizard/CreditRequest.java"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/models/Conversations.java"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/models/Conversation.java"/>
</t:tabgroup>
</body>
</html>
package jumpstart.web.components.examples.wizard;
import org.apache.tapestry5.annotations.Import;
@Import(stylesheet = "css/examples/wizard.css")
public class WizardUsingPagesLayout {
}
<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
<t:form t:id="form" class="form-horizontal well">
<h4>Applying for Credit - Step 1: Start</h4>
<div class="form-group">
<t:label for="amount" class="col-sm-2"/>
<div class="col-sm-3">
<t:textfield t:id="amount" value="creditRequest.amount" validate="required, min=10, max=9999" size="10"/>
</div>
<div class="col-sm-2">
<p class="form-control-static">(required)</p>
</div>
</div>
<div class="form-group">
<div class="col-sm-5 col-sm-offset-2">
<t:submit value="Next >"/>
<t:eventlink event="Quit" class="btn btn-default">Quit</t:eventlink>
</div>
</div>
<t:errors globalOnly="true"/>
</t:form>
</html>
package jumpstart.web.pages.examples.wizard;
import jumpstart.util.ExceptionUtil;
import jumpstart.web.base.examples.wizard.WizardConversationalPage;
import jumpstart.web.pages.Index;
import jumpstart.web.state.examples.wizard.CreditRequest;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.InjectPage;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.Form;
public class WizardUsingPages1 extends WizardConversationalPage {
// The conversation contents
@Property
private CreditRequest creditRequest;
// Other pages
@InjectPage
private WizardUsingPages2 nextPage;
@InjectPage
private Index indexPage;
// Generally useful bits and pieces
@InjectComponent
private Form form;
// The code
@Override
public void startConversation() {
super.startConversation();
creditRequest = new CreditRequest();
saveCreditRequestToConversation(creditRequest);
}
void onPrepare() {
if (creditRequest == null) {
// Get objects for the form fields to overlay.
creditRequest = restoreCreditRequestFromConversation();
}
}
void onValidateFromForm() {
if (form.getHasErrors()) {
// We get here only if a server-side validator detected an error.
return;
}
saveCreditRequestToConversation(creditRequest);
try {
creditRequest.validateAmountInfo();
}
catch (Exception e) {
// Display the cause. In a real system we would try harder to get a user-friendly message.
form.recordError(ExceptionUtil.getRootCauseMessage(e));
}
}
Object onSuccess() {
nextPage.set(getConversationId());
return nextPage;
}
Object onQuit() {
endConversation();
return indexPage;
}
}
<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
<t:form t:id="form" class="form-horizontal well">
<h4>Applying for Credit - Step 2: The Applicant</h4>
<div class="form-group">
<t:label for="name" class="col-sm-2"/>
<div class="col-sm-3">
<t:textfield t:id="name" value="creditRequest.applicantName" validate="required"/>
</div>
<div class="col-sm-2">
<p class="form-control-static">(required)</p>
</div>
</div>
<div class="form-group">
<div class="col-sm-5 col-sm-offset-2">
<t:eventlink event="Prev" class="btn btn-default">< Prev</t:eventlink>
<t:submit value="Next >"/>
<t:eventlink event="Quit" class="btn btn-default">Quit</t:eventlink>
</div>
</div>
<t:errors globalOnly="true"/>
</t:form>
</html>
package jumpstart.web.pages.examples.wizard;
import jumpstart.util.ExceptionUtil;
import jumpstart.web.base.examples.wizard.WizardConversationalPage;
import jumpstart.web.pages.Index;
import jumpstart.web.state.examples.wizard.CreditRequest;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.InjectPage;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.Form;
public class WizardUsingPages2 extends WizardConversationalPage {
// The conversation contents
@Property
private CreditRequest creditRequest;
// Other pages
@InjectPage
private WizardUsingPages1 prevPage;
@InjectPage
private WizardUsingPages3 nextPage;
@InjectPage
private Index indexPage;
// Generally useful bits and pieces
@InjectComponent
private Form form;
// The code
void onPrepare() {
if (creditRequest == null) {
// Get objects for the form fields to overlay.
creditRequest = restoreCreditRequestFromConversation();
}
}
void onValidateFromForm() {
if (form.getHasErrors()) {
// We get here only if a server-side validator detected an error.
return;
}
saveCreditRequestToConversation(creditRequest);
try {
creditRequest.validateApplicantInfo();
}
catch (Exception e) {
// Display the cause. In a real system we would try harder to get a user-friendly message.
form.recordError(ExceptionUtil.getRootCauseMessage(e));
}
}
Object onSuccess() {
nextPage.set(getConversationId());
return nextPage;
}
Object onPrev() {
prevPage.set(getConversationId());
return prevPage;
}
Object onQuit() {
endConversation();
return indexPage;
}
}
<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
<t:form t:id="form" class="form-horizontal well">
<h4>Applying for Credit - Step 3: Submit</h4>
<dl class="dl-horizontal">
<dt>Amount:</dt>
<dd>$${creditRequest.amount}</dd>
<dt>Applicant Name:</dt>
<dd>${creditRequest.applicantName}</dd>
</dl>
<div class="form-group">
<t:eventlink event="Prev" class="btn btn-default">< Prev</t:eventlink>
<t:submit value="Submit for Credit Check" onclick="displayProcessingMessage()" class="btn btn-info"/>
<t:eventlink event="Quit" class="btn btn-default">Quit</t:eventlink>
</div>
<t:errors globalOnly="true"/>
<div id="processingMessage" style="display:none;">
<br/>Processing your application. Please wait...
</div>
<!-- A script that displays the "processing" message -->
<script>
function displayProcessingMessage() {
// This no longer works in modern browsers - they defer the update until the submit's response is received.
obj = document.getElementById('processingMessage');
obj.style.display = ''
return true;
}
</script>
</t:form>
</html>
package jumpstart.web.pages.examples.wizard;
import jumpstart.util.ExceptionUtil;
import jumpstart.web.base.examples.wizard.WizardConversationalPage;
import jumpstart.web.pages.Index;
import jumpstart.web.state.examples.wizard.CreditRequest;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.InjectPage;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.Form;
public class WizardUsingPages3 extends WizardConversationalPage {
// The conversation contents
@Property
private CreditRequest creditRequest;
// Other pages
@InjectPage
private WizardUsingPages2 prevPage;
@InjectPage
private WizardUsingPagesSuccess nextPage;
@InjectPage
private Index indexPage;
// Generally useful bits and pieces
@InjectComponent
private Form form;
// The code
void onPrepare() {
if (creditRequest == null) {
// Get objects for the form fields to overlay.
creditRequest = restoreCreditRequestFromConversation();
}
}
void onValidateFromForm() {
saveCreditRequestToConversation(creditRequest);
try {
// In the real world we would probably submit it to the business layer here
// but we're not, so let's simulate a busy period then complete the request!
sleep(5000);
creditRequest.complete();
}
catch (Exception e) {
// Display the cause. In a real system we would try harder to get a user-friendly message.
form.recordError(ExceptionUtil.getRootCauseMessage(e));
}
}
Object onSuccess() {
endConversation();
// In the real world we would now have a credit request in the database and the success page would want its
// id instead of these two fields.
nextPage.set(creditRequest.getAmount(), creditRequest.getApplicantName());
return nextPage;
}
Object onPrev() {
prevPage.set(getConversationId());
return prevPage;
}
Object onQuit() {
endConversation();
return indexPage;
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
}
catch (InterruptedException e) {
// Ignore
}
}
}
package jumpstart.web.base.examples.wizard;
import jumpstart.web.models.Conversations;
import jumpstart.web.pages.examples.wizard.WizardUsingPagesBadFlow;
import jumpstart.web.state.examples.wizard.CreditRequest;
import org.apache.tapestry5.annotations.InjectPage;
import org.apache.tapestry5.annotations.SessionState;
public class WizardConversationalPage {
public static final String WIZARD_CONVERSATION_PREFIX = "wiz";
public static final String CREDIT_REQUEST_KEY = "CR";
// The conversation
@SessionState
private Conversations conversations;
private String conversationId = null;
// Other pages
@InjectPage
private WizardUsingPagesBadFlow badFlowPage;
// The code
public void set(String conversationId) {
this.conversationId = conversationId;
}
Object onActivate() throws Exception {
if (getConversationId() == null) {
startConversation();
return this;
}
return null;
}
protected Object onActivate(String conversationId) throws Exception {
this.conversationId = conversationId;
// If the conversation does not contain the model
// then it means the Back/Reload/Refresh button has been used to reach an old conversation,
// so redirect to the bad-flow-step
if (restoreCreditRequestFromConversation() == null) {
return badFlowPage;
}
return null;
}
String onPassivate() {
return conversationId;
}
protected void startConversation() {
conversationId = conversations.startConversation(WIZARD_CONVERSATION_PREFIX);
}
protected void saveCreditRequestToConversation(CreditRequest creditRequest) {
conversations.saveToConversation(conversationId, CREDIT_REQUEST_KEY, creditRequest);
}
protected CreditRequest restoreCreditRequestFromConversation() {
return (CreditRequest) conversations.restoreFromConversation(conversationId, CREDIT_REQUEST_KEY);
}
protected void endConversation() {
conversations.endConversation(conversationId);
// If conversations SSO is now empty then remove it from the session
if (conversations.isEmpty()) {
conversations = null;
}
}
protected String getConversationId() {
return conversationId;
}
}
<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
<div class="well">
<h4>Applying for Credit - Step 4: Success</h4>
<p>Congratulations! The credit application for $${approvedAmount} to ${approvedApplicantName} has been accepted.</p>
<t:eventlink event="Restart" class="btn btn-default">Start Again</t:eventlink>
</div>
</html>
package jumpstart.web.pages.examples.wizard;
import org.apache.tapestry5.annotations.Property;
public class WizardUsingPagesSuccess {
// The activation context
@Property
private int approvedAmount;
@Property
private String approvedApplicantName;
// The code
public void set(int approvedAmount, String approvedApplicantName) {
// In the real world we would typically receive the credit request's id instead of these fields
this.approvedAmount = approvedAmount;
this.approvedApplicantName = approvedApplicantName;
}
void onActivate(int approvedAmount, String approvedApplicantName) throws Exception {
// In the real world we would typically receive the credit request's id instead of these fields
this.approvedAmount = approvedAmount;
this.approvedApplicantName = approvedApplicantName;
}
Object[] onPassivate() {
// In the real world we would typically passivate the credit request's id instead of these fields
return new Object[] { approvedAmount, approvedApplicantName };
}
void setupRender() {
// In the real world we would typically have been passed the persisted credit requests's id, so we'd retrieve
// the credit request from the database, but in this example we were passed the fields to render.
}
Object onRestart() {
return WizardUsingPages1.class;
}
}
<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
<div class="alert alert-danger">
Operation not allowed because the chosen Credit Request is over. Did you use the Back button after the Request was over?<br/><br/>
<t:pagelink page="examples/wizard/ConversationsList">List conversations</t:pagelink><br/>
<t:pagelink page="examples/wizard/WizardUsingPages1">Start again</t:pagelink>
</div>
</html>
package jumpstart.web.pages.examples.wizard;
public class WizardUsingPagesBadFlow {
}
.eg {
margin: 20px 0;
padding: 14px;
border: 1px solid #ddd;
border-radius: 6px;
-webkit-border-radius: 6px;
-mox-border-radius: 6px;
}
#processingMessage {
color: green;
font-weight: bold;
}
package jumpstart.web.state.examples.wizard;
import java.io.Serializable;
// In the real world we'd typically make this a business domain entity
//@Entity
@SuppressWarnings("serial")
public class CreditRequest implements Serializable {
private int amount = 0;
private String applicantName = "";
private Status status = Status.INCOMPLETE;
public enum Status {
INCOMPLETE, COMPLETE
}
public String toString() {
final String DIVIDER = ", ";
StringBuilder buf = new StringBuilder();
buf.append(this.getClass().getSimpleName() + ": ");
buf.append("[");
buf.append("amount=" + amount + DIVIDER);
buf.append("applicantName=" + applicantName + DIVIDER);
buf.append("status=" + status);
buf.append("]");
return buf.toString();
}
public CreditRequest() {
}
public void validateAmountInfo() throws Exception {
if (amount < 10 || amount > 9999) {
throw new Exception("Amount must be between 10 and 9999.");
}
}
public void validateApplicantInfo() throws Exception {
if (applicantName == null || applicantName.trim().equals("")) {
throw new Exception("Applicant name is required.");
}
}
public void validate() throws Exception {
validateAmountInfo();
validateApplicantInfo();
}
public void complete() throws Exception {
validate();
status = Status.COMPLETE;
}
public Status getStatus() {
return status;
}
public int getAmount() {
return amount;
}
public void setAmount(int amount) {
this.amount = amount;
}
public String getApplicantName() {
return applicantName;
}
public void setApplicantName(String applicantName) {
this.applicantName = applicantName;
}
}
package jumpstart.web.models;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
public class Conversations {
private Map<String, Integer> counters = new HashMap<String, Integer>();
private Map<String, Conversation> conversations = new HashMap<String, Conversation>();
public String startConversation() {
return startConversation("dEfAuLt");
}
public synchronized String startConversation(String conversationIdPrefix) {
int conversationNumber = incrementCounter(conversationIdPrefix);
String conversationId = conversationIdPrefix + Integer.toString(conversationNumber);
startConversationForId(conversationId);
return conversationId;
}
public synchronized void startConversationForId(String conversationId) {
Conversation conversation = new Conversation(conversationId);
add(conversation);
}
public void saveToConversation(String conversationId, Object key, Object value) {
Conversation conversation = get(conversationId);
// Save a new reference to the object, just in case Tapestry cleans up the other one as we leave the page.
Object valueNewRef = value;
conversation.setObject(key, valueNewRef);
}
public Object restoreFromConversation(String conversationId, Object key) {
Conversation conversation = get(conversationId);
return conversation == null ? null : conversation.getObject(key);
}
public void endConversation(String conversationId) {
remove(conversationId);
}
public Collection<Conversation> getAll() {
return conversations.values();
}
public boolean isEmpty() {
return conversations.isEmpty();
}
private synchronized void add(Conversation conversation) {
if (conversations.containsKey(conversation.getId())) {
throw new IllegalArgumentException("Conversation already exists. conversationId = " + conversation.getId());
}
conversations.put(conversation.getId(), conversation);
}
public Conversation get(String conversationId) {
return conversations.get(conversationId);
}
private void remove(String conversationId) {
Object obj = conversations.remove(conversationId);
if (obj == null) {
throw new IllegalArgumentException("Conversation did not exist. conversationId = " + conversationId);
}
}
public synchronized int incrementCounter(String counterKey) {
if (counters == null) {
counters = new HashMap<String, Integer>(2);
}
Integer counterValue = counters.get(counterKey);
if (counterValue == null) {
counterValue = 1;
}
else {
counterValue++;
}
counters.put(counterKey, counterValue);
return counterValue;
}
public String toString() {
final String DIVIDER = ", ";
StringBuilder buf = new StringBuilder();
buf.append(this.getClass().getSimpleName() + ": ");
buf.append("[");
buf.append("counters=");
if (counters == null) {
buf.append("null");
}
else {
buf.append("{");
for (Iterator<String> iterator = counters.keySet().iterator(); iterator.hasNext();) {
String key = (String) iterator.next();
buf.append("(" + key + ", " + counters.get(key) + ")");
}
buf.append("}");
}
buf.append(DIVIDER);
buf.append("conversations=");
if (conversations == null) {
buf.append("null");
}
else {
buf.append("{");
for (Iterator<String> iterator = conversations.keySet().iterator(); iterator.hasNext();) {
String key = (String) iterator.next();
buf.append("(" + key + ", " + conversations.get(key) + ")");
}
buf.append("}");
}
buf.append("]");
return buf.toString();
}
}
package jumpstart.web.models;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
public class Conversation {
private String id;
private Map<Object, Object> objectsByKey = null;
public Conversation(String id) {
this.id = id;
}
public String getId() {
return id;
}
public void setObject(Object key, Object obj) {
if (objectsByKey == null) {
objectsByKey = new HashMap<Object, Object>(1);
}
objectsByKey.put(key, obj);
}
public Object getObject(Object key) {
if (objectsByKey == null) {
return null;
}
else {
return objectsByKey.get(key);
}
}
public String toString() {
final String DIVIDER = ", ";
StringBuilder buf = new StringBuilder();
buf.append(this.getClass().getSimpleName() + ":");
buf.append(" [");
buf.append("id=" + id + DIVIDER);
buf.append("objectsByKey=");
if (objectsByKey == null) {
buf.append("null");
}
else {
buf.append("{");
for (Iterator<Object> iterator = objectsByKey.keySet().iterator(); iterator.hasNext();) {
Object key = (Object) iterator.next();
buf.append("(" + key + "," + "<" + objectsByKey.get(key) == null ? "null" : objectsByKey.get(key).getClass()
.getSimpleName()
+ ">" + ")");
}
buf.append("}");
}
buf.append("] ");
return buf.toString();
}
}