Bob Ippolito - MochiKit-1.0

Documentation | Source
/*

Originally adapted from http://svn.colorstudy.com/home/ianb/form.js

*/

/***********************************************************************
 *
 * Copyright (c) 2005 Imaginary Landscape LLC and Contributors.
 *
 * Permission is hereby granted, free of charge, to any person obtaining
 * a copy of this software and associated documentation files (the
 * "Software"), to deal in the Software without restriction, including
 * without limitation the rights to use, copy, modify, merge, publish,
 * distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to
 * the following conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
 * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 **********************************************************************/

/*
This follows the WHAT-WG spec roughly, with the intention of following
it more closely in the future.

Its primary feature at the moment is the repeating field model from:
http://www.whatwg.org/specs/web-forms/current-work/#repeatingFormControls

You do this like:

  <form validate="1">

  <fieldset repeat="template" id="address">
  Address: <textarea name="addr-[address]"></textarea><br>
  <button type="remove">Remove this address</button>
  </fieldset>

  <button type="add" template="address">Add an address</button>

  </form>

The validate="1" attribute enables this library on that form.
repeat="template" marks something as a template, the id names the
template.  Fields inside the template have [id] substituted with an
integer.  Two kinds of buttons -- add and remove -- add a new
instance of the template, or remove the containing instance.

There's other stuff in here -- like validation and filling in the
templates -- but it's rough and probably will change to better
fit what WHAT-WG defines.

This library will move in the future, this is just a temporary
location for it.

*/


/* Utility functions */

DOM = MochiKit.DOM;
logError = MochiKit.Logging.logError;
logDebug = MochiKit.Logging.logDebug;



function all_inputs(node) {
    return MochiKit.Base.filter(
        function (el) {
            return (el.tagName == 'INPUT' || el.tagName == 'TEXTAREA'
                    || el.tagName == 'SELECT');
    }, all_child_tags(node));
}

function all_child_tags(node) {
    return DOM.getElementsByTagAndClassName('*', null, node);
}

function forms_assert(v, msg, allowWindow) {
    if (! v || (! allowWindow && v === this)) {
        throw ('Assertion failed (' 
               + (v === this ? 'got window object' : v) + ')'
               + (msg ? ': '+msg : ''));
    }
}

/*****************************************
 * Forms
 *****************************************/

function getForm(formId) {
    var form = getForm.allForms[formId];
    if (! form) {
        throw ('Form ' + formId + ' not found');
    }
    return form;
}

getForm.allForms = {};

getForm.register = function (formId, form) {
    getForm.allForms[formId] = form;
};

function Form(root) {
    forms_assert(this, 'Form');
    root = this.root = DOM.getElement(root);
    if (! this.root.getAttribute('validate')) {
        return;
    }
    this.id = this.root.getAttribute('id');
    getForm.register(this.id, this);
    this.templates = {}
    this.scanTemplates();
    this.scanAddButtons();
    this.scanFormData();
    MochiKit.DOM.addToCallStack(this.root, "onsubmit", validateForm);
}

Form.prototype.validate = function () {
    inputs = all_inputs(this.root);
    good = true;
    for (var i = 0; i<inputs.length; i++) {
        if (inputs[i].getAttribute('form-required')
            && inputs[i].name) {
            good = process_required(inputs[i]) && good;
        }
    }
    return good;
}

function validateForm() {
    var formEl = this;
    var form = getForm(formEl.getAttribute('id'));
    if (form) {
        return form.validate();
    }
}


Form.prototype.scanTemplates = function () {
    var self = this;
    forms_assert(self, 'scanTemplates(this)');
    MochiKit.Iter.forEach(
        DOM.getElementsByTagAndClassName('*', null, this.root),
        function (el) {
            if (el.getAttribute('repeat') == 'template') {
                new Template(self, el);
            }
        });
};

Form.prototype.scanAddButtons = function () {
    /* Find all add buttons */
    var self = this;
    MochiKit.Iter.forEach(
        this.root.getElementsByTagName('button'),
        function (el) {
            if (el.getAttribute('type') == 'add') {
                tmpl = self.templates[el.getAttribute('template')];
                tmpl.prepareAddButton(el);
            }
        });
};

Form.prototype.scanFormData = function () {
    /* Find all form data */
    var self = this;
    MochiKit.Iter.forEach(
        DOM.getElementsByTagAndClassName('*', 'form-data', this.root),
        function (data) {
            tmpl = self.templates[data.getAttribute('template')];
            tmpl.addFormData(data);
        });
}


/*****************************************
 * Templates (repeating forms)
 *****************************************/

function Template(form, root) {
    forms_assert(this, 'Template');
    forms_assert(form, 'Template(form)'); 
    forms_assert(root, 'Template(root)');
    root = this.root = DOM.getElement(root);
    this.id = root.getAttribute('id');
    logDebug('Enabling template ' + this.id
             + ' on form: ' + form.id);
    form.templates[this.id] = this;
    this.form = form;
    this.templateInstances = [];
    this.nextId = 0;
    DOM.hideElement(root);
    var self = this;
    MochiKit.Iter.forEach(
        DOM.getElementsByTagAndClassName('button', null, this.root),
        function (el) {
            if (el.getAttribute('type') == 'add') {
                self.prepareAddButton(el);
            }
        });
}

Template.prototype.prepareAddButton = function (button) {
    button.onclick = addButtonOnClick;
    logDebug('Prepared add button: ' + button + ' for template: '
             + this.id);
};

Template.prototype.prepareRemoveButton = function (button, inst) {
    button.repeatId = inst.getAttribute('id');
    button.onclick = removeButtonOnClick;
    logDebug('Prepared remove button: ' + button + ' for template: '
             + button.templateId);
}

Template.prototype.createInstance = function () {
    var form = this.form;
    var inst = this.root.cloneNode(true);
    forms_assert(inst, 'createInstance: inst');
    DOM.showElement(inst);
    var elements = DOM.getElementsByTagAndClassName('*', null, inst);
    var index = this.nextId;
    inst.setAttribute('id', this.id + index);
    inst.setAttribute('templateIndex', index);
    var templateVar = new RegExp("\\[" + this.id + "\\]", "g");
    var innerTemplates = [];
    this.nextId++;
    for (var i=0; i < elements.length; i++) {
        var el = elements[i];
        for (var j=0; j < el.attributes.length; j++) {
            var attr = el.attributes[j];
            var current = attr.nodeValue;
            var newValue = current.replace(templateVar, index);
            if (current != newValue) {
                // We're trying to avoid DOM manipulation if possible...
                attr.nodeValue = newValue;
            }
        }
        if (el.getAttribute('repeat') == 'template') {
            // We can't initialize these until this template is fully
            // set up
            innerTemplates.push(el);
        }
        if (el.tagName == 'BUTTON' && el.getAttribute('type') == 'add') {
            // @@: Should we call this later?
            this.prepareAddButton(el);
        }
    }
    for (var i=0; i < innerTemplates.length; i++) {
        t = new Template(form, innerTemplates[i]);
    }
    var buttons = inst.getElementsByTagName('button');
    for (var i=0; i < buttons.length; i++) {
        if (buttons[i].getAttribute('type') == 'remove') {
            this.prepareRemoveButton(buttons[i], inst);
        }
    }
    var last = this.lastInsertedTemplate();
    if (last.nextSibling) {
        last.parentNode.insertBefore(inst, last.nextSibling);
    } else {
        last.parentNode.appendChild(inst);
    }
    this.templateInstances.push(inst);
    return inst;
}

Template.prototype.lastInsertedTemplate = function () {
    var insts = this.templateInstances
    for (var i=insts.length-1; i >= 0; i--) {
        if (insts[i] && insts[i].parentNode) {
            return insts[i];
        }
    }
    // When no templates instances have been created yet, the template
    // itself is the place to insert things
    return this.root;
}

Template.prototype.addFormData = function (data) {
    var self = this;
    var inst = this.createInstance();
    forms_assert(inst, 'addFormData: inst');
    var allFields = all_inputs(inst);
    MochiKit.Iter.forEach(
        all_child_tags(data),
        function (field) {
            if (! field.getAttribute) {
                return;
            }
            var fieldName = field.getAttribute('form-name');
            if (! fieldName) {
                return;
            }
            fieldName = fieldName.replace(
                new RegExp('\\[' + self.id + '\\]', "g"),
                inst.getAttribute('templateIndex'));
            var fieldValue = field.getAttribute('form-value');
            var set = false;
            logDebug('Setting field "' + fieldName + '" to value: "'
                     + fieldValue + '" in template: ' + this.id);
            MochiKit.Iter.forEach(
                allFields,
                function (instField) {
                    if (instField.getAttribute('name') == fieldName) {
                        set_input_value(instField, fieldValue);
                        set = true;
                    }
                });
            if (! set) {
                throw ('No template field by the name ' + fieldName + ' found');
            }
        } 
    );
    DOM.swapDOM(data);
}


function removeButtonOnClick() {
    try {
        DOM.swapDOM(this.repeatId);
    } catch (e) {
        logError(e);
        alert('Error removing section: ' + e);
    }
    return false;
}

function addButtonOnClick() {
    try {
        var button = this;
        var form = getForm(button.form.getAttribute('id'));
        var templateId = button.getAttribute('template');
        var template = form.templates[templateId];
        if (! template) {
            throw ('No template named ' + templateId);
        }
        logDebug('Adding template ' + templateId + ' to form '
                 + form + ': ' + template);
        template.createInstance();
    } catch (e) {
        logError(e);
        alert('Error adding section: ' + e);
    }
    return false;
}








/*****************************************
 * Validation
 *****************************************/

/* This stuff is rough, doesn't follow WHAT-WG, and maybe is broken
   at the moment.  */


function process_required(input) {
    var types = input.getAttribute('form-required').split(',');
    var value;
    if (input.errorNode) {
        input.parentNode.removeChild(input.errorNode);
        input.errorNode = null;
    }
    MochiKit.DOM.removeElementClass(input, 'error');
    for (var i=0; i<types.length; i++) {
        var validator = validators[types[i]];
        if (! validator) {
            throw ('Unknown validation type: ' + types[i]);
        }
        result = validator(get_input_value(input), input, types[i]);
        if (result) {
            var err = MochiKit.DOM.DIV({'class': 'error'}, result);
            input.parentNode.insertBefore(err, input);
            MochiKit.DOM.addElementClass(input, 'error');
            input.errorNode = err;
            return false;
        }
    }
    return true;
}

function get_input_value(input) {
    if (input.tagName == 'INPUT' || input.tagName == 'TEXTAREA') {
        return input.value;
    } else if (input.tagName == 'SELECT') {
        return input.options[input.selectedIndex].value;
    } else {
        throw ('Unknown input tag: ' + input.tagName);
    }
}    

function set_input_value(input, value) {
    if (input.tagName == 'INPUT' || input.tagName == 'TEXTAREA') {
        input.value = value;
    } else if (input.tagName == 'SELECT') {
        for (var i=0; i < input.options.length; i++) {
            if (input.options[i].value == value) {
                input.selectedIndex = i;
                return;
            }
        }
        throw ('Value not found in select list ' 
               + input.getAttribute('name') + ': "'
               + value + '"');
    } else {
        throw ('Unknown input tag: ' + input.tagName);
    }
}

var validators = {
    present: function (value) {
        if (! value) {
            return 'Please enter something';
        }
    },
    url: function (value, input) {
        value = MochiKit.Format.strip(value);
        if (value && value.search(/^https?:\/\//) == -1) {
            value = 'http://' + value;
        }
        input.value = value;
    },
    'path-dir': function (value, input) {
        value = MochiKit.Format.strip(value);
        if (value && value.search(/\/$/) == -1) {
            value = value + '/';
        }
    }
};

MochiKit.DOM.addLoadEvent(
    function () {
        MochiKit.Iter.forEach(
            document.getElementsByTagName('form'),
            function (el) {new Form(el)});
    });