<!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 Form Fragments</h3>
This 4 step wizard is done with a single page holding a single form divided into <strong>form fragments</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 a 5th step - a "bad flow" step.<br/>
<div class="eg">
<t:if test="inEntrySteps">
<t:form t:id="form" class="form-horizontal well">
<t:formfragment visible="inStart">
<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:formfragment>
<t:formfragment visible="inApplicant">
<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:formfragment>
<t:formfragment visible="inSubmit">
<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:formfragment>
</t:form>
</t:if>
<t:if test="inBadFlow">
<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:eventlink event="Restart">Start again</t:eventlink>
</div>
</t:if>
</div>
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/5.4/apidocs/org/apache/tapestry5/corelib/components/FormFragment.html">FormFragment</a>,
<a href="http://tapestry.apache.org/session-storage.html">Session Storage</a>,
<a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/EventContext.html">EventContext</a>,
<a href="http://getbootstrap.com/css/#type-lists">.dl-horizontal</a>,
<a href="http://getbootstrap.com/components/#wells">.well</a>.<br/><br/>
<t:pagelink page="Index">Home</t:pagelink><br/><br/>
<t:tabgroup>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingFormFragments.tml"/>
<t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingFormFragments.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.pages.examples.wizard;
import jumpstart.util.ExceptionUtil;
import jumpstart.web.models.Conversations;
import jumpstart.web.pages.Index;
import jumpstart.web.state.examples.wizard.CreditRequest;
import org.apache.tapestry5.annotations.Import;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.InjectPage;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.annotations.SessionState;
import org.apache.tapestry5.corelib.components.Form;
@Import(stylesheet = "css/examples/wizard.css")
public class WizardUsingFormFragments {
public static final String WIZARD_CONVERSATION_PREFIX = "wiz";
public static final String CREDIT_REQUEST_KEY = "CR";
public enum Step {
START, APPLICANT, SUBMIT, SUCCESS, BAD_FLOW
}
// The activation context
private String conversationId = null;
private Step step = null;
// The conversation and its contents
@SessionState
private Conversations conversations;
@Property
private CreditRequest creditRequest;
// Other pages
@InjectPage
private WizardUsingFormFragmentsSuccess successPage;
@InjectPage
private Index indexPage;
// Generally useful bits and pieces
@InjectComponent
private Form form;
// The code
public void set(Step step, String conversationId) {
this.step = step;
this.conversationId = conversationId;
}
Object onActivate() {
if (step == null) {
startConversation();
step = Step.START;
return this;
}
return null;
}
Object onActivate(Step step, String conversationId) throws Exception {
this.step = step;
this.conversationId = conversationId;
if (this.step == null) {
startConversation();
this.step = Step.START;
return this;
}
// 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 (this.step != Step.BAD_FLOW && restoreCreditRequestFromConversation() == null) {
this.step = Step.BAD_FLOW;
return this;
}
return null;
}
Object[] onPassivate() {
return new Object[] { step, conversationId };
}
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 {
switch (step) {
case START:
creditRequest.validateAmountInfo();
break;
case APPLICANT:
creditRequest.validateApplicantInfo();
break;
case SUBMIT:
// 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();
break;
default:
throw new IllegalStateException("Should not get here. step = " + step);
}
}
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() {
switch (step) {
case START:
step = Step.APPLICANT;
return null;
case APPLICANT:
step = Step.SUBMIT;
return null;
case SUBMIT:
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.
successPage.set(creditRequest.getAmount(), creditRequest.getApplicantName());
return successPage;
default:
throw new IllegalStateException("Should not get here. step = " + step);
}
}
void onPrev() {
switch (step) {
case APPLICANT:
step = Step.START;
break;
case SUBMIT:
step = Step.APPLICANT;
break;
default:
throw new IllegalStateException("Should not get here. step = " + step);
}
}
void onRestart() {
step = null;
}
Object onQuit() {
endConversation();
return indexPage;
}
public void startConversation() {
conversationId = conversations.startConversation(WIZARD_CONVERSATION_PREFIX);
creditRequest = new CreditRequest();
saveCreditRequestToConversation(creditRequest);
}
private void saveCreditRequestToConversation(CreditRequest creditRequest) {
conversations.saveToConversation(conversationId, CREDIT_REQUEST_KEY, creditRequest);
}
private CreditRequest restoreCreditRequestFromConversation() {
return (CreditRequest) conversations.restoreFromConversation(conversationId, CREDIT_REQUEST_KEY);
}
private void endConversation() {
conversations.endConversation(conversationId);
// If conversations SSO is now empty then remove it from the session
if (conversations.isEmpty()) {
conversations = null;
}
}
public boolean isInEntrySteps() {
return step == Step.START || step == Step.APPLICANT || step == Step.SUBMIT;
}
public boolean isInStart() {
return step == Step.START;
}
public boolean isInApplicant() {
return step == Step.APPLICANT;
}
public boolean isInSubmit() {
return step == Step.SUBMIT;
}
public boolean isInBadFlow() {
return step == Step.BAD_FLOW;
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
}
catch (InterruptedException e) {
// Ignore
}
}
}
.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();
}
}