Sometimes it's necessary to create a form that has multiple steps. Examples of this might include user registration, surveys, and any kind of form where the selections influence the options available on subsequent pages. Application integration can have predefined sets of steps that are required are risky to leave in the hands of content authors who may miss things out or misinterpret the way they need to be set up.
It's much better in some cases to roll these together and have a single self contained component than worry about training content editors and dealing with the potential for strange configurations that can have unexpected results. The penalty is a reduction in flexibility, but for cases such as user registration there is a minimum of modifications that can be made if the form has to support a service that lives outside of the CMS.
This is a design and sample code for a wizard-style form in CQ5. I'll walk though it and highlight the areas where it could be extended to take better advantage of the facilities in CQ5. The basis of this design is that the form uses HTTP POST requests throughout, and that all fields in the form are always present between requests. If the data is not visible on the current page, they are added to the form as hidden fields. It uses a 'mock MVC' style, with a controller, views, and data modelling aspects.
GET.jsp: the view controller
This JSP script does some basic initialisation of the request attributes in order to prevent 'null' appearing in the form (unless null was deliberately typed in), and to select the first page in the wizard if this is the first access to the page. Finally, it selects the view script to use based on the value of 'displayPage'. There are two variants - an input form and a summary - which it can use. To illustrate the view logic I've used a simple string contains call to use the 'input.jsp' script if the display page attribute contains 'input', otherwise display the summary.
<%@page import="java.util.List" %><%
%><%@page import="com.antonyh.multipageform.*" %><%
%><%@include file="/libs/foundation/global.jsp"%>
<%
//init; set first page
if (request.getAttribute("multipageform/components/sample/form/displayPage") == null) {
request.setAttribute("multipageform/components/sample/form/displayPage", "inputPage1");
}
//init; set field defaults
List fields = com.antonyh.multipageform.SampleUtil.getFieldNames();
for (FormField field: fields) {
if (request.getAttribute("multipageform/components/sample/form/" + field.getName()) == null) {
request.setAttribute("multipageform/components/sample/form/" + field.getName(), "");
}
}
//-------
//very simple display logic. If it's an 'input' page use the input script, otherwise display a summary.
//extend this as needed.
String displayPage = (String) request.getAttribute("multipageform/components/sample/form/displayPage");
if (displayPage == null || displayPage.startsWith("input")) {
%>
<%
} else /*if (displayPage.equals("summary"))*/ {
%>
<%
}
%>
This script would be extended if there are more types of view or if additional components are needed under certain circumstances. It would be trivial to include additional code branches here or to load extra data.
The Utility Class: the data model
For the sake of this demo, I've implemented this as a static utility class with some POJO objects to define the data model. The utility class can get all the pages in the form, all the fields in all the form pages, and a static text field with a label for the form. It sets up the data using a static block.
package com.antonyh.multipageform;
import java.util.ArrayList;
import java.util.List;
/**
* Utility class used by script.
*
*/
public class SampleUtil {
//example only; this wouldn't be held in the object in a real application
static final List FIELDNAMES = new ArrayList();
//example only; this wouldn't be held in the object in a real application
static final List FORMPAGES = new ArrayList();
/*
* This block initialises the lists. In reality this would source data
* from webservices and/or CRX.
*
*/
static{
List fields1 = new ArrayList();
fields1.add(new FormField("FieldA1"));
FORMPAGES.add( new FormPage("inputPage1", "inputPage2", fields1));
List fields2 = new ArrayList();
fields2.add(new FormField("FieldB1"));
FORMPAGES.add(new FormPage("inputPage2", "inputPage3", fields2));
List fields3 = new ArrayList();
fields3.add(new FormField("FieldC1"));
FORMPAGES.add(new FormPage("inputPage3", "summary", fields3));
for(FormPage formPage: FORMPAGES){
FIELDNAMES.addAll(formPage.getFields());
}
}
public static String getText() {
return "Form Example";
}
public static List getFieldNames() {
ArrayList x = new ArrayList();
x.addAll(FIELDNAMES);
return x;
}
public static List getFormPages() {
ArrayList x = new ArrayList();
x.addAll(FORMPAGES);
return x;
}
}
In a real world example this would be changed to an OSGi service and draw data dynamically from external sources or other OSGi services. It might be used to get information from the component configuration if there are editable sections on the form. The number of pages and their contents could be managed through the author interface with a service to obtain the settings and return them to the calling code.
The Servlet: the post controller
The servlet accepts all POST requests with a resource type of 'multipageform/components/sample'. This is in the ':formtype' hidden field in the form. It works by taking all the submitted form fields except those that start with ':' and adding them to the request parameters. The Sling default POST servlet uses parameters prefixed with a colon as control values; these include :redirect, :formtype, and :formpath. We remove them because we don't need to store them as they are already in our scripts.
Once it has stored the field values for use by the next view script, it works out the action it needs to take. In the example there are two actions, 'back' and 'next'. The back button submits the form with a value 'submit=back' and the servlet looks up the previous page in the sequence by finding the page with a matching next page. It sets this value as the display page and issues a redirect. Conversely, for the 'next' action, it finds the current page and sets the display page to that pages next page value before redirecting.
package com.antonyh.multipageform.impl;
import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.Property;
import org.apache.felix.scr.annotations.Service;
import org.apache.sling.api.servlets.OptingServlet;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.List;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import org.apache.sling.api.resource.Resource;
import com.antonyh.multipageform.FormPage;
@Service
@Component
public class MultipageFormSampleServlet extends SlingAllMethodsServlet implements OptingServlet, Filter {
static final long serialVersionUID = 1l;
protected static final Logger log = LoggerFactory.getLogger(MultipageFormSampleServlet.class);
@Property(value = "multipageform/components/sample")
static final String SLING_SERVLET_RESOURCETYPES = "sling.servlet.resourceTypes";
@Property(value = "POST")
static final String SLING_SERVLET_METHODS = "sling.servlet.methods";
@Property(value = "request", propertyPrivate = true)
static final String FILTER_SCOPE = "filter.scope";
@Property(value = "-600", propertyPrivate = true)
static final String FILTER_ORDER = "filter.order";
static final String ATTR_RESOURCE = "multipageform/components/sample/resource";
/*
* (non-Javadoc)
*
* @see
* org.apache.sling.api.servlets.SlingAllMethodsServlet#doPost(org.apache
* .sling.api.SlingHttpServletRequest,
* org.apache.sling.api.SlingHttpServletResponse)
*/
protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws ServletException, IOException {
log.debug("POST into MultipageFormSampleServlet.");
//put all the field values in the request attributes
java.util.Enumeration fields = request.getParameterNames();
while (fields.hasMoreElements()) {
String field = (String) fields.nextElement();
if (field.startsWith(":")) {
continue; //skip parameters that start with a ':' - we don't need to keep these
}
request.setAttribute("multipageform/components/sample/form/" + field, request.getParameter(field));
}
List formPages = com.antonyh.multipageform.SampleUtil.getFormPages();
//display page logic. defaults to first page in the list
String displayPage = formPages.iterator().next().getPageName();
if (request.getParameter("displayPage") != null) {
displayPage = request.getParameter("displayPage");
}
if (displayPage.equals("null")) {
displayPage = formPages.iterator().next().getPageName();
}
//Back & forward button logic
if (request.getParameter("back") != null) {
for(FormPage formPage : formPages){
if (displayPage.equals(formPage.getNextPageName())) {
displayPage=formPage.getPageName();
break;
}
}
}else if (request.getParameter("next") != null){
for(FormPage formPage : formPages){
if (displayPage.equals(formPage.getPageName())) {
displayPage=formPage.getNextPageName();
break;
}
}
}
request.setAttribute("multipageform/components/sample/form/displayPage", displayPage);
// take user back to the form page.
final Resource rsrc = (Resource) request.getAttribute(ATTR_RESOURCE);
request.removeAttribute(ATTR_RESOURCE);
SlingHttpServletRequest formsRequest = new FormsHandlingRequest(request);
request.getRequestDispatcher(rsrc).forward(formsRequest, response);
return;
}
/*
* (non-Javadoc)
*
* @see
* org.apache.sling.api.servlets.OptingServlet#accepts(org.apache.sling.
* api.SlingHttpServletRequest)
*/
public boolean accepts(SlingHttpServletRequest request) {
//only accept .html requests
return "html".equals(request.getRequestPathInfo().getExtension());
}
/*
* (non-Javadoc)
*
* @see javax.servlet.Filter#doFilter(javax.servlet.ServletRequest,
* javax.servlet.ServletResponse, javax.servlet.FilterChain)
*/
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (request instanceof SlingHttpServletRequest) {
final SlingHttpServletRequest req = (SlingHttpServletRequest) request;
if ("POST".equalsIgnoreCase(req.getMethod()) && req.getParameter(":formtype") != null && req.getParameter(":formtype").equals("multipageform/components/sample")) {
// store original resource as request attribute
req.setAttribute(ATTR_RESOURCE, req.getResource());
final StringBuilder sb = new StringBuilder();
// forward to the path where the form component is, so that it
// matches the "sling.servlet.resourceTypes" property define at
// the beginning of this class. As a result, the doPost() method
// is executed.
final String formPath = req.getParameter(":formpath");
if (!formPath.startsWith("/")) {
sb.append(req.getResource().getPath());
sb.append('/');
}
sb.append(formPath);
sb.append(".html");
// forward to forms handling servlet
final String forwardPath = sb.toString();
req.getRequestDispatcher(forwardPath).forward(request, response);
return;
}
}
chain.doFilter(request, response);
}
/*
* (non-Javadoc)
*
* @see javax.servlet.Filter#init(javax.servlet.FilterConfig)
*/
public void init(FilterConfig arg0) throws ServletException {
// no init required.
}
}
I've opted to use a copy of the 'FormsHandlingRequest' class to convert the request object back to a GET.
The servlet is the main controller for the form. Validation should be placed here as it affects the flow of the application. It would also need to be extended to take an action at the end of the sequence such as sending an email, saving a node, or submitting the data to a webservice.
input.jsp: the main view
The basic form view.
<%@page import="com.antonyh.multipageform.*"%>
<%@page import="java.util.List"%>
<%@include file="/libs/foundation/global.jsp"%>
<%@taglib prefix="sling" uri="http://sling.apache.org/taglibs/sling/1.0"%>
<sling:defineObjects />
<%@ page contentType="text/html;charset=UTF-8"%><%
String displayPage = (String) request.getAttribute("multipageform/components/sample/form/displayPage");
//get form defintions
List<FormField> allfields = com.antonyh.multipageform.SampleUtil.getFieldNames();
List<FormPage> formPages = com.antonyh.multipageform.SampleUtil.getFormPages();
//get the form object for the current form page
FormPage formPage = formPages.iterator().next();
for (FormPage formPageObj: formPages) {
if (formPageObj.getPageName().equals(displayPage)){
formPage = formPageObj;
break;
}
}
%>
<div><%=com.antonyh.multipageform.SampleUtil.getText()%> <%=displayPage%>
<form name="sampleform" id="sampleform" action="<%=resourceResolver.map(currentPage.getPath())%>.html" method="POST" enctype="multipart/form-data">
<input type="hidden" name=":formpath" value="<%=currentNode.getPath()%>" />
<input type="hidden" name=":formtype" value="<%=component.getResourceType()%>" />
<input type="hidden" name=":redirect" value="<%=resourceResolver.map(currentPage.getPath())%>.html" />
<input type="hidden" name="_charset_" value="utf-8" />
<input type="hidden" name="displayPage" value="<%=displayPage%>" />
<%
//hide fields not on this page.
//show fields on this page.
//all fields are always present.
allfields.removeAll(formPage.getFields());
for (FormField field: formPage.getFields()) {
%> Field <%=field.getName()%> <input type="text" name="<%=field.getName()%>" required="required"
value="<%=request.getAttribute("multipageform/components/sample/form/" + field.getName())%>" /><br /><%
}
for (FormField field: allfields) {
%><input type="hidden" name="<%=field.getName()%>"
value="<%=request.getAttribute("multipageform/components/sample/form/" + field.getName())%>" /><%
}
%>
<input type="submit" name="back" value="back" />
<input type="submit" name="next" value="next" />
</form>
</div>
This does contain some aspects of logic that could easily be moved into Java code, but in general should be fairly self-explanatory.
summary.jsp: a secondary view
A summary view that is not editable. This view is intended as a demonstration of how to add additional scripts with different display and without a next button. In the example, it is shown at the end of the sequence.
<%@page import="com.antonyh.multipageform.*"%>
<%@page import="java.util.List"%>
<%@include file="/libs/foundation/global.jsp" %>
<%@taglib prefix="sling" uri="http://sling.apache.org/taglibs/sling/1.0"%>
<sling:defineObjects/>
<%@ page contentType="text/html;charset=UTF-8" %><%
List<FormField> fields = com.antonyh.multipageform.SampleUtil.getFieldNames();
%>
<div>
<%= com.antonyh.multipageform.SampleUtil.getText() %> SUMMARY
<form name="sampleform" id="sampleform" action="<%=resourceResolver.map(currentPage.getPath())%>.html" method="POST" enctype="multipart/form-data">
<input type="hidden" name=":formpath" value="<%= currentNode.getPath() %>" />
<input type="hidden" name=":formtype" value="<%= component.getResourceType() %>" />
<input type="hidden" name=":redirect" value="<%= resourceResolver.map(currentPage.getPath())%>.thankyou.html" />
<input type="hidden" name="_charset_" value="utf-8" />
<input type="hidden" name="displayPage" value="<%=request.getAttribute("multipageform/components/sample/form/displayPage") %>"/>
<%
//simple display of the values as a summary
//use hidden fields to keep the data for use by the back button
for (FormField field: fields) {
%>
Field :
<input type="hidden" name="<%=field.getName() %>" value="<%=request.getAttribute("multipageform/components/sample/form/" + field.getName() ) %>"/>
<%=request.getAttribute("multipageform/components/sample/form/" + field.getName() ) %><br/>
<%
}
%>
<input type="submit" name="back" value="back"/>
</form>
</div>
This is almost the same as the input.jsp, except that it is able to make the assumption that all the field data is in hidden fields. It needs to have the form and all the fields so that the back button is used - if this was a final page from which it was not possible to go back it wouldn't need this.
Conclusion
The form is sent via a POST request to a servlet. This stores the values in request attributes, and forwards the request back to the form. There is a view controller in the component called GET.jsp that does some basic work and includes a view script depending on the page.
This code was built in CQ5.4 but is expected to work in CQ5.5. There is a lot of things that could go into improving this before using it in a production application. It does demonstrate one approach to implement a multi-page form wizard in CQ5. It needs a lot more work to create a working form especially if it is complex or needs a lot of flexibility but it's a good starting point for when a programatic solution is required.
The display of the source here isn't very useful or clever but might save you some time. If you need the code, you can download the source as a CQ5 package from http://antonyh.co.uk/storage/cq5/packages/multipage-form.zip