Wizard Using Pages

This 4 step wizard is done with 5 pages.
The first 3 steps are data entry. They share a "conversation" during which the browser's Back and Refresh/Reload buttons are allowed.
The 4th step displays success. Any attempt to return to an ended "conversation" will be redirected to the 5th page - a "bad flow" page.
Operation not allowed because the chosen Credit Request is over. Did you use the Back button after the Request was over?

List conversations
Start again

The best approach?

So which approach is best for writing wizards? Pages or form fragments? The choice is yours.

References: Form, Session Storage.

Home

WizardUsingPagesLayout.tml


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a DOCTYPE to allow us to use special characters like &nbsp; 
    We use a "strict" DTD to make IE follow the alignment rules. -->
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd">
<head>
    <link rel="stylesheet" type="text/css" href="${context:css/examples/wizard.css}"/>
</head>
<body>
    <h1>Wizard Using Pages</h1>

    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>

    <h3>The best approach?</h3> 
    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.3/apidocs/org/apache/tapestry5/corelib/components/Form.html">Form</a>, 
    <a href="http://tapestry.apache.org/session-storage.html">Session Storage</a>.<br/><br/>
    
    <a t:type="pagelink" t:page="Index" href="#">Home</a><br/><br/>

    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/examples/wizard/WizardUsingPagesLayout.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/components/examples/wizard/WizardUsingPagesLayout.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages1.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages1.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages2.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages2.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages3.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPages3.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/base/examples/wizard/WizardConversationalPage.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPagesSuccess.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPagesSuccess.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPagesBadFlow.tml"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/pages/examples/wizard/WizardUsingPagesBadFlow.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/css/examples/wizard.css"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/state/examples/wizard/CreditRequest.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/model/Conversations.java"/>
    <t:sourcecodedisplay src="/web/src/main/java/jumpstart/web/model/Conversation.java"/>
</body>
</html>

WizardUsingPagesLayout.java


package jumpstart.web.components.examples.wizard;

public class WizardUsingPagesLayout {
}

WizardUsingPages1.tml


<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd">

    <form t:type="form" t:id="form">
        <h2>Applying for Credit - Step 1: Start</h2>
        <table>
            <tr>
                <td><t:label for="amount"/></td>
                <td><input t:type="TextField" t:id="amount" value="creditRequest.amount" 
                    t:validate="required, min=10, max=9999" size="10"/></td>
                <td>(required)</td>
            </tr>
        </table><br/>

        <input type="submit" value="Next &gt;"/>
        <button t:type="chenillekit/Button" type="button" t:event="Quit" style="margin-left: 20px;"> Quit </button>

        <t:errors/>
    </form>

</html>

WizardUsingPages1.java


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

    @Component(id = "form")
    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;
    }

}

WizardUsingPages2.tml


<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd">

    <form t:type="form" t:id="form">
        <h2>Applying for Credit - Step 2: The Applicant</h2>
        <table>
            <tr>
                <td><t:label for="name"/></td>
                <td><input t:type="TextField" t:id="name" value="creditRequest.applicantName" t:validate="required"/></td>
                <td>(required)</td>
            </tr>
        </table><br/>
        
        <button t:type="chenillekit/Button" type="button" t:event="Prev">&lt; Prev</button>
        <input type="submit" value="Next &gt;"/>
        <button t:type="chenillekit/Button" type="button" t:event="Quit" style="margin-left: 20px;"> Quit </button>

        <t:errors/>
    </form>

</html>

WizardUsingPages2.java


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

    @Component(id = "form")
    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;
    }

}

WizardUsingPages3.tml


<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd">

    <form t:type="form" t:id="form">
        <h2>Applying for Credit - Step 3: Submit</h2>

        Amount: $${creditRequest.amount}<br/>
        Applicant Name: ${creditRequest.applicantName}<br/><br/>
                
        <button t:type="chenillekit/Button" type="button" t:event="Prev">&lt; Prev</button>
        <input type="submit" value="Submit for Credit Check" onclick="displayProcessingMessage()"/>
        <button t:type="chenillekit/Button" type="button" t:event="Quit" style="margin-left: 20px;"> Quit </button>

        <t:errors/>

        <div id="processingMessage" style="display:none; color:green; font-weight: bold; ">
            <br/>Processing your application. Please wait...
        </div>

        <!-- A script that displays the "processing" message -->
        <script>
                function displayProcessingMessage() {
                    obj = document.getElementById('processingMessage');
                    obj.style.display = ''
                    return true;
                }
        </script>
    </form>

</html>

WizardUsingPages3.java


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

    @Component(id = "form")
    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
        }
    }
}

WizardConversationalPage.java


package jumpstart.web.base.examples.wizard;

import jumpstart.web.model.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;
    }

    String onPassivate() {
        return 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;
    }

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

WizardUsingPagesSuccess.tml


<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd">

    <div class="success">
        <h2>Applying for Credit - Step 4: Success</h2>

        Congratulations!  The credit application for $${approvedAmount} to ${approvedApplicantName} has been accepted.<br/><br/>
            
        <button t:type="chenillekit/Button" type="button" t:event="Restart">Start Again</button>
    </div>

</html>

WizardUsingPagesSuccess.java


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

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

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

WizardUsingPagesBadFlow.tml


<html t:type="examples/wizard/WizardUsingPagesLayout" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd">

    <div class="badflow">
        Operation not allowed because the chosen Credit Request is over. Did you use the Back button after the Request was over?<br/><br/>

        <a t:type="pagelink" t:page="examples/wizard/ConversationsList" href="#">List conversations</a><br/>
        <a t:type="pagelink" t:page="examples/wizard/WizardUsingPages1" href="#">Start again</a>
    </div>

</html>

WizardUsingPagesBadFlow.java


package jumpstart.web.pages.examples.wizard;


public class WizardUsingPagesBadFlow {
}

wizard.css


body, td        { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 13px; font-weight: normal; color: #333;
                    line-height: 17px; }
h1              { font-size: 26px; line-height: 20px; } /* For IE 7 */

.eg             { margin: 20px 0; padding: 20px; 
                    border: 1px solid #ddd; border-radius: 4px; -webkit-border-radius: 4px; -mox-border-radius: 4px; }

a               { text-decoration: none; color: #3D69B6; }
a:hover         { text-decoration: underline; }

.eg form        { background: #eee; margin: 0; padding: 1em; }
.eg h2          { margin-top: 0; }
.eg .success    { background: #eee; padding: 1em; }
.eg .badflow    { background: #eaa; padding: 1em; }
#processingMessage
                { color: green; font-weight: bold; }
.eg .quit       { margin-left: 20px; }

CreditRequest.java


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

}

Conversations.java


package jumpstart.web.model;

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

}

Conversation.java


package jumpstart.web.model;

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