Knockout-JS Multi-Step Form with Validation
Asked Answered
E

2

7

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.

Erelia answered 23/5, 2013 at 19:15 Comment(0)
A
7

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.

Agle answered 24/5, 2013 at 3:52 Comment(1)
Thanks. I now see that binding the visibility to the currentStep() is a cleaner approach. 1. I plan on integrating the mapping plugin after I've recreated all base functionality. 2. I think you are right that I will need to use ko.toJS() and then limit the fields being passed. 3. That's interesting, but is it necessary to use a KO-template? In that case I'll have the form markup inside of the script tag as well as inside of div#context. Would it be a bad idea to avoid a template and just save the form HTML inside of a global var on document ready and then follow the rest of your procedure?Erelia
E
0

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>
Ecru answered 24/5, 2013 at 15:15 Comment(4)
That's an interesting approach, but I'm not really seeing the benefit of using a validatedViewModel over clustering the individual steps into a validatedObservable and then calling the isValid() method. The end result seems the same, without the need for the try/catch, no?Erelia
Also, I'm setting parseInputAttributes = true and keep my validation constraints inline to leverage native browser behaviors when available, but that's personal preference.Erelia
This approach is all about scalability. Your initial approach will work fine if the model is small and your validation requirements relatively basic. Consider what happens when you need different validation options, like a password/confirm password fields. How will you implement that parsing the attributes? The password field needs to be say a minimum of 8 characters and the password and confirmation password must match. You also want to display an appropriate validation message. With constraint groups this is clean and tidy and separate from the view - but gets very tricky without it.Ecru
The idea is pretty nice, although I don't understand why you'd create a function to handle steps, as you basically hardcode steps now. Why not subscribe to currentStep() and then change things accordingly to the newValue?Stoltzfus

© 2022 - 2024 — McMap. All rights reserved.