lyma
lyma

Reputation: 341

Knockout-JS Multi-Step Form with Validation

Looking for a sanity check here. I've recently started learning knockout, and have been instructed to convert an existing multi-step form.

The basic idea is to validate each step before allowing the user to continue. There are also certain restrictions set up (not shown) that determine whether to continue onward or submit using all of the current data (ex: if they don't qualify).

Here is a fiddle with a simplified version (the actual form contains about 40 fields over 4 steps)

http://jsfiddle.net/dyngomite/BZcNg/

HTML:

<form id="register">
 <fieldset>
      <h2>About You</h2>
    <ul>
        <li>
            <label for="firstName">First Name:</label>
            <input type="text" data-bind="value: firstName" required="required" />
        </li>
        <li>
            <label for="lastName">Last Name</label>
            <input type="text" data-bind="value: lastName" required="required" />
        </li>
    </ul>
 </fieldset>
 <fieldset>
     <h2>Your Business</h2>

    <ul>
        <li>
            <label for="businessName">Business Name:</label>
            <input type="text" data-bind="value: businessName" required="required" />
        </li>
        <li>
            <label for="currentCustomer">Were you referred by someone?</label>
            <input type="checkbox" data-bind="checked: referred" />
        </li>
    </ul>
</fieldset>
<fieldset>
     <h2>User Info</h2>

    <ul>
        <li>
            <label for="userName">Referrer's First Name:</label>
            <input type="text" data-bind="value: referralFirst" required="required" />
        </li>
        <li>
            <label for="password">Referrer's Last Name:</label>
            <input type="password" data-bind="value: referralLast" required="required" />
        </li>
    </ul>
  </fieldset>
 </form>
<div class="nav-buttons"> <a href="#" data-bind='click: stepForward'>Continue</a>
    <a href="#" data-bind='click: stepBack'>Back</a>
    <a href="#" data-bind='click: resetAll'>Cancel</a>
 </div>

JS:

 $("#register").children().hide().first().show();

ko.validation.init({
   parseInputAttributes: true,
   decorateElement: true,
   writeInputAttributes: true,
   errorElementClass: "error"
});

function myViewModel() {

var self = this;

//observable init
self.firstName = ko.observable();
self.lastName = ko.observable();
self.businessName = ko.observable();
self.referred = ko.observable();
self.referralFirst = ko.observable();
self.referralLast = ko.observable();

//validaiton observable init
self.step1 = ko.validatedObservable({
    firstName: self.firstName,
    lastName: self.lastName
});

self.step2 = ko.validatedObservable({
    businessName: self.businessName,
    referred: self.referred
});

self.step3 = ko.validatedObservable({
    referralFirst: self.referralFirst,
    referralLast: self.referralLast
});

//navigation init
self.currentStep = ko.observable(1);

self.stepForward = function () {
    if(self.currentStep()<4){
        self.changeSection(self.currentStep() + 1);
    }
}

self.stepBack = function () {
    if (self.currentStep() > 1) {
        self.changeSection(self.currentStep() - 1);
    }
}

self.changeSection = function(destIdx){
    var validationObservable = "step" + self.currentStep();
    if(self[validationObservable]().isValid()){
        self.currentStep(destIdx);
        $("#register").children().hide().eq(self.currentStep() - 1).show();
        return true;
    }else{
        self[validationObservable]().errors.showAllMessages();
    }
    return false;
}

self.resetAll = function(){
    //TODO
    return false;
}

}

 ko.applyBindings(new myViewModel());

My questions:

  1. Does it make sense to declare all of the fields initially as observables and then cluster them together into validatedObservables() ?

  2. If at the end I want to submit the entire form, is there a smarter way of accomplishing this than concatenating each step using ko.toJSON(self.step1()). Would I need to create a "full form" observable that contains all of the input observables? In other words, what's the best way to serialize the full form? Would I want to use ko.toJSON(self) ?

  3. What's the best way to reset the form to the initial configuration? Is there a way of re-applying ko.applyBindings(new myViewModel()) ?

Am I going about this correctly?

Thanks for any clarification.

Upvotes: 7

Views: 6094

Answers (2)

79IT
79IT

Reputation: 425

Have a look at ValidatedViewModel from Carl Schroedl.

When used in conjunction with the excellent Knockout Validation plugin you can create validation constraint groups and apply them as required.

On each run of your validation routine you would remove all constraint groups and then apply the constraint groups you want for the given step. Alternatively subscribe to the step observable to set the constraint groups.

(I suggest using a try/catch statement when applying/removing constraint groups as it will error if the constraint group has already been applied/removed.)

There is a bit of a learning curve attached to this but it really helped me create a basket/checkout page with appropriate validation on each step.

Update: Here is an updated jsfiddle using ValidatedViewModel. I made the visible step dependent on the currentStep observable and removed the required tags. All validation is now handled in the model. As a bonus, the CSS in the jsfiddle also styles the validation message with no additional markup required.

ko.validation.init({
    parseInputAttributes: false,
    decorateElement: true,
    insertMessages: true,
    messagesOnModified: true,
    grouping: { deep: true, observable: true }
});

var myViewModel = ValidatedViewModel(function () {
    var self = this;

    //observable init
    self.firstName = ko.observable();
    self.lastName = ko.observable();
    self.businessName = ko.observable();
    self.referred = ko.observable();
    self.referralFirst = ko.observable();
    self.referralLast = ko.observable();

    //navigation init
    self.currentStep = ko.observable(1);

    self.stepForward = function () {
        if(self.currentStep()<4){
            self.changeSection(self.currentStep() + 1);
        }
    }

    self.stepBack = function () {
        if (self.currentStep() > 1) {
            self.changeSection(self.currentStep() - 1);
        }
    }

    self.changeSection = function(destIdx){
        //remove all constraint groups
        try { self.removeConstraintGroup('step1'); } catch (e) { }
        try { self.removeConstraintGroup('step2'); } catch (e) { }
        try { self.removeConstraintGroup('step3'); } catch (e) { }

        //apply constraint group for current step
        try{self.applyConstraintGroup('step' + self.currentStep());} catch(e){}

        var errorCount = self.errors().length;

        self.errors.showAllMessages();
        if(errorCount===0){
            self.currentStep(destIdx);
            return true;
        }
        return false;
    }


    self.constraintGroups = {
        step1: {
            firstName: { required: true },
            lastName: { required: true }
        },
        step2: {
            businessName: { required: true }
        },
        step3: {
            referralFirst: { required: true },
            referralLast: { required: true }
        }

    }

    self.resetAll = function(){
        //TODO
        return false;
    }

    this.errors = ko.validation.group(this);

});

ko.applyBindings(new myViewModel());

The HTML now looks like this:

<form id="register">
    <h1>Current Step: <span data-bind="text:currentStep()"></span></h1>
    <fieldset data-bind="visible: currentStep()===1">
         <h2>About You</h2>

        <ul>
            <li>
                <label for="firstName">First Name:</label>
                <input type="text" data-bind="value: firstName"  />
            </li>
            <li>
                <label for="lastName">Last Name</label>
                <input type="text" data-bind="value: lastName"  />
            </li>
        </ul>
    </fieldset>
    <fieldset data-bind="visible:currentStep()===2">
         <h2>Your Business</h2>

        <ul>
            <li>
                <label for="businessName">Business Name:</label>
                <input type="text" data-bind="value: businessName"  />
            </li>
            <li>
                <label for="currentCustomer">Were you referred by someone?</label>
                <input type="checkbox" data-bind="checked: referred" />
            </li>
        </ul>
    </fieldset>
    <fieldset data-bind="visible:currentStep()===3">
         <h2>User Info</h2>

        <ul>
            <li>
                <label for="userName">Referrer's First Name:</label>
                <input type="text" data-bind="value: referralFirst"  />
            </li>
            <li>
                <label for="password">Referrer's Last Name:</label>
                <input type="password" data-bind="value: referralLast"  />
            </li>
        </ul>
    </fieldset>
</form>
<div class="nav-buttons"> <a href="#" data-bind='click: stepForward'>Continue</a>
 <a href="#" data-bind='click: stepBack'>Back</a>
 <a href="#" data-bind='click: resetAll'>Cancel</a>

</div>

Upvotes: 0

Tomas Kirda
Tomas Kirda

Reputation: 8413

It's a good start. I suggest you manage visibility using knockout and turn to jQuery only when there is no other option. By that I mean managing visibility of fieldsets:

<fieldset data-bind="visible: currentStep() === 1">
  1. Yes, it does make sense to have all fields as observables initially. Good strategy is to get your data as JSON from server and use mapping plugin to convert everything to observables. If you prefer to code everything by hand, that's OK.

  2. At the end just submit whole view model: ko.toJSON(self) will do the job serializing it to JSON. You may want to convert it to JS object: ko.toJS, then cleanup data that you don't want to submit (e.g. lookup data and etc.) and then use JSON.stringify to convert to JSON.

  3. It's hard to reset validation state using validation plugin. To reset the form, just remove existing form from the DOM and applyBindings on the new HTML. Keep HTML somewhere handy on the page:

To reset form then do:

<script type="text/html" id="ko-template">
   <form id="register"> 
   ...
   </form>
</script>

<div id="context"></div>

JavaScript:

var template = $('#ko-template').html();

$('#context').empty().html(template);

ko.applyBindings(new myViewModel(), document.getElementById('context'));

Form tag is not necessary in this case, since you manage everything using JS objects.

Upvotes: 7

Related Questions