SPEasyForms: Wizard Container Implementation Details

In this post I’m going to describe how to implement a container extension for SPEasyForms. Most containers are structurally the same; they contain one or more named collections of fields and present those fields on a form in a certain way. For example, the tabs container has one collection of fields per tab, and the name of the collection is the tab name. The container I’m going to build now offers basic wizard functionality. It paginates each field collection, so a page looks like:
image

This page has only a single field, but each page can have as many fields as you like. There are next and previous buttons to navigate through the pages, with each button hidden when it doesn’t make sense (i.e. there is no previous button on the first page). The form can be submitted at any time, but if there are validation errors when save is clicked, the first page with validation errors is displayed. For a more complete description of the functionality, see my previous post Wizard Container User Guide.
The basic plumbing for such a container looks like this:
(function ($, undefined) {

    var containerCollection = $.spEasyForms.containerCollection;
    var baseContainer = $.spEasyForms.baseContainer;

    var wizard = {
        containerType: "Wizard",

        // move fields to this as container configured
        transform: function (options) { return []; },

        // second stage transform, this is called after visibility
        // rules and adapters are applied
        postTransform: function (options) {},

        // an opportunity to do validation tasks prior to 
        // committing an item
        preSaveItem: function (options) {}
    };

    // extending baseContainer takes care of all functionality 
    // for the editor/settings page
    containerCollection.containerImplementations.wizard = 
        $.extend({}, baseContainer, wizard);

})(typeof (spefjQuery) === 'undefined' ? null : spefjQuery);
The most interesting thing here is the last line where we extend an object called baseContainer and wizard onto a new instance and assign it to containerImplementations.wizard. If we were to deploy this code right now, our new wizard container would ‘work’ in the editor (i.e. settings page). In other words, when we hit the Add Container button, we would see wizard as an option type. And if we add a wizard, we can create, add, and delete pages, drag and drop fields onto the pages, and drag and drop the wizard container amongst the other containers in the form. We can also save it and reload the editor and it will remember our wizard just fine. That’s because all of this functionality is exactly the same between all containers with field collections, so it is implemented in baseContainer for us, and assigning it to containerImplementations.wizard is what makes it show up as a container type on the Add Container dialog.
Now I said it would ‘work’ (in quotes) because there is obviously something missing here. We can add it and configure it, but nothing changes in the WYSIWYG or the form. That’s where the three methods we haven’t implemented yet come in. The first of these is the transform method, which draws our container on the form and moves the fields to the appropriate places on our container, and looks like:
// transform the current form based on the configuration of this container
transform: function (options) {
    var opt = $.extend({}, $.spEasyForms.defaults, options);
    opt.result = [];

    // create a div to hold the container
    opt.divId = "spEasyFormsWizardDiv" + opt.index;
    $("#" + opt.containerId).append("<div id=" + opt.divId +
        " class='speasyforms-wizard-outer ui-widget-content" +
        " ui-corner-all' style='margin: 10px;'></div>");
    opt.outerDiv = $("#" + opt.divId);

    // loop through field collections adding them as headers/tables
    // to the container div
    $.each(opt.currentContainerLayout.fieldCollections, 
        function (idx, fieldCollection) {
        // create a header and a div to hold the 
        // current page/field collection
        opt.collectionIndex = opt.index + "" + idx;
        opt.tableClass = "speasyforms-wizard";
        opt.outerDiv.append("<h3 id='page" + opt.collectionIndex +
            "' class='" + opt.tableClass + "' style='padding: 5px;'>" +
            fieldCollection.name + "</h3>" +
            "<div id='pageContent" + opt.collectionIndex +
            "' class='speasyforms-wizard' style='padding: 10px'>" +
            "</div>");

        // add a table to the div with the fields in the field collection
        opt.collectionType = "page";
        opt.parentElement = "pageContent" + opt.collectionIndex;
        opt.fieldCollection = fieldCollection;
        opt.headerOnTop = true;
        $.spEasyForms.baseContainer.appendFieldCollection(opt);

        // apply styles from the jquery ui accordion
        $("#page" + opt.collectionIndex).addClass(
            "ui-accordion-header ui-helper-reset ui-state-default" +
            " ui-corner-all ui-accordion-icons");
    });

    // create next/previous buttons and wire their click events
    this.wireButtons(opt);

    // return an array of the fields added to this container
    return opt.result;
},
image
The basic HTML structure for our container looks like the picture to the right. First we’re going to draw an outer div. Then, for each field collection we’re going to draw a header and a div to be our page. Then we’ll call baseContainer.appendFieldCollection, which will add a table and move each field in the collection to the table. Finally, we’re going to apply some jQuery UI CSS classes to our HTML elements to get them styled the same as the jQuery UI accordion. I don’t want to reinvent the wheel, and more importantly I don’t want to document a style guide so people can re-skin SPEasyForms. jQuery UI themes are already well documented, and there is even a utility on their web site to let you select colors and fonts etc. and roll your own theme. I’m not so much a designer and anyway that’s one less thing I need to document.
I glossed over one very important point above. Don’t draw your own tables of fields. If you extend baseContainer, call baseContainer.appendFieldCollection and let it draw your tables of fields. First, this makes this common markup standardized between different containers of field collections, which is a good practice to begin with. But more importantly, I envision a future where I might possibly allow nested containers, at which point append field collection could take either a field collection or another container, and it would take care of the differences. If you draw your own field collections, you would need to do a significant rewrite when I roll out a change like that. If you extend baseContainer, use it to the fullest and it will insulate you somewhat from changes I might make in the future.
The last thing I do is call this.wireButtons. I’ll include this method below when I get to the various utility methods, but it adds the next and previous buttons and attaches click event handlers to them to implement forward and backward page navigation.
If I were to deploy this code right now, and I configured a form to have a wizard, it would look something like this when rendered:
image
It’s a start, but it’s obviously not very wizard-like just yet. In order to get it to look like a wizard, I need to implement the postTransform method. The reason there is a postTransform method is because of the order of operations when a form is loaded, which looks like this:
  1. The transform method of each container is called. This method draws the basic container elements, and moves the fields from the default form onto the appropriate elements within the container.
  2. The conditional visibility rules are executed. The fields need to already be where they’re going to be before this happens, because for instance the read-only state handler hides the field and inserts a new row below it with a read-only value. If the fields weren’t already where they’re supposed to end up, this read-only row would get inserted into the wrong place and each container would have to know too much about how conditional visibility rules work, because they’d need to move this row as well.
  3. The transform method of each adapter is called. Again, an adapter might modify elements around the field, so if the field wasn’t already moved to it’s container these modifications would be done in the wrong place.
  4. Finally, the postTransform method of each container is called. This allows the container to do things like size the container parts appropriately, or hide container parts that don’t have any visible fields. If you sized the container parts before visibility rules are applied, the sizes could easily be wrong after visibility rules are applied. Same goes for adapter transformations, they might easily affect the size.

So the postTransform method of our wizard container performs the following actions:

  • Size each page to the height and width of the tallest and widest page. I don’t want my wizard changing size as I navigate through the pages, it just looks too jumpy.
  • If no page is currently marked selected, select the first page that has at least one visible field, show the selected page, and hide all other pages.
  • Set the visibility of the next and previous buttons based on whether there are any next or previous pages with at least one visible field.
The implementation is shown below, but of course it depends on various utility methods that I’ll include at the end of this post.
// second stage transform, this is called after visibility rules and adapters are applied
postTransform: function (options) {
    var opt = $.extend({}, $.spEasyForms.defaults, options);
    var headerSelector = "#spEasyFormsWizardDiv" + opt.index + 
        " h3.speasyforms-wizard-selected";
    if ($(headerSelector).length === 0) {
        // calculate the width and height of the pages
        var width = 400;
        var height = 100;
        var tableSelector = "#spEasyFormsWizardDiv" + opt.index + 
            " table.speasyforms-wizard";
        $(tableSelector).each(function () {
            if ($(this).closest("div").width() > width) {
                width = $(this).closest("div").width();
            }
            if ($(this).closest("div").height() > height) {
                height = $(this).closest("div").height();
            }
        });
        // set the height/width of each page, hide the unselected pages, 
        // and show the selected page
        $(tableSelector).each(function () {
            var selectedHeaderSelector = "#spEasyFormsWizardDiv" + 
                opt.index + 
                " h3.speasyforms-wizard-selected";
            $(this).closest("div").width(width).height(height);
            if ($(selectedHeaderSelector).length > 0) {
                $(this).closest("div").hide().prev().hide();
            }
            else if ($(this).find(wizard.visibileRow).length > 0) {
                $(this).closest("div").
                    addClass("speasyforms-wizard-selected").
                    prev().addClass("speasyforms-wizard-selected");
            }
            else {
                $(this).closest("div").hide().prev().hide();
            }
        });
    }
    wizard.setNextPrevVisibility(opt);
},
There is one more method we need to implement called preSaveItem. This method is similar to SharePoint’s PreSaveItem function, which allows custom code to perform custom validation before a form is submitted and return false to cancel submission if validation errors were found (hopefully also displaying a message to the user indicating the nature of the validation error). But containers aren’t really intended to be used for custom validation, so why do they have this method? Because it also gives the container an opportunity to search the DOM for validation error messages inserted by SharePoint and take appropriate action. For instance, if there are any validation error messages in the page, the wizard container should try to select the first wizard page that contains a validation error message if any, so the user at least sees the message and can take appropriate action and submit the form. So that’s what this preSaveItem implementation does:
// an opportunity to do validation tasks prior to committing an item
preSaveItem: function (options) {
    var opt = $.extend({}, $.spEasyForms.defaults, options);
    // check if there are validation errors on the container
    var errorSelector = "#spEasyFormsWizardDiv" + opt.index + 
        " span.ms-formvalidation";
    var error = $(errorSelector + ":first");
    if (error.length > 0) {
        // if so, select and show the first page with validation errors
        var selectedSelector = "#spEasyFormsWizardDiv" + opt.index + 
            " .speasyforms-wizard-selected";
        $(selectedSelector).hide().
            removeClass("speasyforms-wizard-selected");
        error.closest("div").prev().show().
            addClass("speasyforms-wizard-selected").
            next().show().addClass("speasyforms-wizard-selected");
    }
    wizard.setNextPrevVisibility(opt);
    return true;
},
That about does it for our container implementation, but there is that whole business about the utility methods, all of which are to handle the logic of the next and previous buttons. I’m not going to describe them in detail, it’s more straight jQuery than container specific logic, and there are comments explaining what they do, but the six methods are included below. I’m of course always happy to answer any questions about them here or in the discussion board on the CodePlex site.
The complete source code is available in the source package of the AddOns.2014.01.15 package.
// add next and previous buttons and wire up their events
wireButtons: function (options) {
    var opt = $.extend({}, $.spEasyForms.defaults, options);

    // append next and previous buttons to the container
    opt.outerDiv.append("<div  id='" + opt.divId + 
        "Buttons' align='right' " +
        "style='margin-bottom: 10px; margin-right: 10px; " +
        "font-size: .9em; font-weight: normal;'>" +
        "<button id='" + opt.divId + "Previous' title='Previous' " +
        "class='speasyforms-wizard-prev'>Previous</button>" +
        "<button id='" + opt.divId + "Next' title='Next' " +
        "class='speasyforms-wizard-next'>Next</button>" +
        "</div>");
    $("#" + opt.divId + "Buttons").append(
        "<img class='placeholder' align='right' " +
        "style='display:none;margin:11px;'" +
        " src='/_layouts/images/blank.gif?rev=38' height='1' width='" +
        $("#" + opt.divId + "Next").outerWidth() +
        "' alt='' data-accessibility-nocheck='true'/>");

    // handle previous click event
    $("#" + opt.divId + "Previous").button().click(function () {
        opt.selectedHeader = $("#spEasyFormsWizardDiv" + opt.index +
            " h3.speasyforms-wizard-selected");
        wizard.selectPrevious(opt);
        return false;
    });

    // handle next click event
    $("#" + opt.divId + "Next").button().click(function () {
        opt.selectedHeader = $("#spEasyFormsWizardDiv" + opt.index +
            " h3.speasyforms-wizard-selected");
        wizard.selectNext(opt);
        return false;
    });
},

// determine the visibility of the next and previous buttons,
// based on whether there
// is a next or previous page with visible fields.
setNextPrevVisibility: function (options) {
    var opt = $.extend({}, $.spEasyForms.defaults, options);
    opt.selectedHeader = $("#spEasyFormsWizardDiv" + opt.index +
        " h3.speasyforms-wizard-selected");
    // hide or show the previous button based on whether the previous page
    // is null (i.e. no previous page with visible fields)
    var tmp = this.getPrevious(opt);
    if (!tmp || tmp.length === 0) {
        opt.selectedHeader.closest("div.speasyforms-wizard-outer").
            find(".speasyforms-wizard-prev").hide();
    }
    else {
        opt.selectedHeader.closest("div.speasyforms-wizard-outer").
            find(".speasyforms-wizard-prev").show();

    }
    // hide or show the next button based on whether the next page is 
    // null (i.e. no next page with visible fields)
    tmp = this.getNext(opt);
    if (!tmp || tmp.length === 0) {
        opt.selectedHeader.closest("div.speasyforms-wizard-outer").
            find(".speasyforms-wizard-next").hide();
        opt.selectedHeader.closest("div.speasyforms-wizard-outer").
            find(".placeholder").show();
    }
    else {
        opt.selectedHeader.closest("div.speasyforms-wizard-outer").
            find(".speasyforms-wizard-next").show();
        opt.selectedHeader.closest("div.speasyforms-wizard-outer").
            find(".placeholder").hide();
    }
},
visibileRow: "tr:not([data-visibilityhidden='true'])" +
    " td.ms-formbody",

// hide the current page and show the nearest previous page with
// visible fields
selectPrevious: function (options) {
    var opt = $.extend({}, $.spEasyForms.defaults, options);
    var prev = this.getPrevious(opt);
    if (prev) {
        opt.selectedHeader.removeClass("speasyforms-wizard-selected").hide().
            next().removeClass("speasyforms-wizard-selected").hide();
        prev.addClass("speasyforms-wizard-selected").show().
            next().addClass("speasyforms-wizard-selected").show();
    }
    wizard.setNextPrevVisibility(opt);
},

// returns the header node for the previous page, or null if there
// is no previous page with visible fields
getPrevious: function (options) {
    var opt = $.extend({}, $.spEasyForms.defaults, options);
    var prev = null;
    if (opt.selectedHeader.prev().prev() && opt.selectedHeader.prev().prev()) {
        prev = opt.selectedHeader.prev().prev();
        while (prev && prev.length && prev.next().find(this.visibileRow).length === 0) {
            if (prev.prev().prev() && prev.prev().prev()) {
                prev = prev.prev().prev();
            }
            else {
                prev = null;
            }
        }
        if (prev && prev.closest("div").find(this.visibileRow).length === 0) {
            prev = null;
        }
    }
    return prev;
},

// hide the current page and show the nearest next page with 
// visible fields
selectNext: function (options) {
    var opt = $.extend({}, $.spEasyForms.defaults, options);
    var next = this.getNext(opt);
    if (next) {
        opt.selectedHeader.removeClass("speasyforms-wizard-selected").hide().
            next().removeClass("speasyforms-wizard-selected").hide();
        next.addClass("speasyforms-wizard-selected").show().
            next().addClass("speasyforms-wizard-selected").show();
    }
    wizard.setNextPrevVisibility(opt);
},

// returns the header node for the next page, or null if there
//  is no next page with visible fields
getNext: function (options) {
    var opt = $.extend({}, $.spEasyForms.defaults, options);
    var next = null;
    if (opt.selectedHeader.next() && opt.selectedHeader.next().next()) {
        next = opt.selectedHeader.next().next();
        while (next && next.length &&
            next.next().find(this.visibileRow).length === 0) {
            if (next.next() && next.next().next()) {
                next = next.next().next();
            }
            else {
                next = null;
            }
        }
        if (next &&
            next.closest("div").find(this.visibileRow).length === 0) {
            next = null;
        }
    }
    return next;
},

Leave a Reply

Scroll to top