Wizard Using Form Fragments

This 4 step wizard is done with a single page holding a single form divided into form fragments.
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 a 5th step - a "bad flow" step.
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
References: Form, FormFragment, Session Storage, EventContext, .dl-horizontal, .well.

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 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 &gt;"/>
                            <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">&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: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">&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: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();
    }
}