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.

Applying for Credit - Step 1: Start

(required)

The best approach?

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

References: Form, Session Storage.

Home


<!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_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 &gt;"/>
                <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">&lt; Prev</t:eventlink>
                <t:submit value="Next &gt;"/>
                <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">&lt; 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();
    }
}