/* Copyright (c) 2012 Joshfire - MIT license */
/**
* @fileoverview Core of the JSON Form client-side library.
*
* Generates an HTML form from a structured data model and a layout description.
*
* The library may also validate inputs entered by the user against the data model
* upon form submission and create the structured data object initialized with the
* values that were submitted.
*
* The library depends on:
* - jQuery
* - the underscore library
* - a JSON parser/serializer. Nothing to worry about in modern browsers.
* - the JSONFormValidation library (in jsv.js) for validation purpose
*
* See documentation at:
* http://developer.joshfire.com/doc/dev/ref/jsonform
*
* The library creates and maintains an internal data tree along with the DOM.
* That structure is necessary to handle arrays (and nested arrays!) that are
* dynamic by essence.
*/
/*global window*/
(function(serverside, global, $, _, JSON) {
if (serverside) {
_ = require('underscore');
}
/**
* Regular expressions used to extract array indexes in input field names
*/
var reArray = /\[([0-9]*)\](?=\.|$)/g;
var reArraySingle = /\[([0-9]*)\](?:\.|$)/;
/**
* Template settings for form views
*/
var fieldTemplateSettings = {
evaluate : /<%([\s\S]+?)%>/g,
interpolate : /<%=([\s\S]+?)%>/g
};
/**
* Template settings for value replacement
*/
var valueTemplateSettings = {
evaluate : /\{\[([\s\S]+?)\]\}/g,
interpolate : /\{\{([\s\S]+?)\}\}/g
};
/**
* The jsonform object whose methods will be exposed to the window object
*/
var jsonform = {util:{}};
// From backbonejs
// TODO replace by (new Option(JSON.stringify(html))).innerHTML ?
var escapeHTML = function(string) {
if (!string) return '';
return ('' + string).replace(/&(?!\w+;|#\d+;|#x[\da-f]+;)/gi, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\//g, '/');
};
/**
* Escapes selector name for use with jQuery
*
* All meta-characters listed in jQuery doc are escaped:
* http://api.jquery.com/category/selectors/
*
* @function
* @param {String} selector The jQuery selector to escape
* @return {String} The escaped selector.
*/
var escapeSelector = function (selector) {
return selector.replace(/([\!\"\#\$\%\&\'\(\)\*\+\,\.\/\:\;<\=\>\?\@\[\\\]\^\`\{\|\}\~])/g, '\\$1');
};
/**
* Initializes tabular sections in forms. Such sections are generated by the
* 'selectfieldset' type of elements in JSON Form.
*
* Input fields that are not visible are automatically disabled
* not to appear in the submitted form. That's on purpose, as tabs
* are meant to convey an alternative (and not a sequence of steps).
*
* The tabs menu is not rendered as tabs but rather as a select field because
* it's easier to grasp that it's an alternative.
*
* Code based on bootstrap-tabs.js, updated to:
* - react to option selection instead of tab click
* - disable input fields in non visible tabs
* - disable the possibility to have dropdown menus (no meaning here)
* - act as a regular function instead of as a jQuery plug-in.
*
* @function
* @param {Object} tabs jQuery object that contains the tabular sections
* to initialize. The object may reference more than one element.
*/
var initializeTabs = function (tabs) {
var activate = function (element, container) {
container
.find('> .active')
.removeClass('active');
element.addClass('active');
};
var enableFields = function ($target, targetIndex) {
// Enable all fields in the targeted tab
$target.find('input, textarea, select').removeAttr('disabled');
// Disable all fields in other tabs
$target.parent()
.children(':not([data-idx=' + targetIndex + '])')
.find('input, textarea, select')
.attr('disabled', 'disabled');
};
var optionSelected = function (e) {
var $option = $("option:selected", $(this)),
$select = $(this),
targetIdx = $option.attr('data-idx') || $option.attr('value'),
$target;
e.preventDefault();
if ($option.hasClass('active')) {
return;
}
$target = $(this).parents('.tabbable').eq(0).find('.tab-content [data-idx=' + targetIdx + ']');
activate($option, $select);
activate($target, $target.parent());
enableFields($target, targetIdx);
};
var tabClicked = function (e) {
var $a = $('a', $(this));
var $content = $(this).parents('.tabbable').first()
.find('.tab-content').first();
var targetIdx = $(this).index();
var $target = $content.find('[data-idx=' + targetIdx + ']');
e.preventDefault();
activate($(this), $(this).parent());
activate($target, $target.parent());
if ($(this).parent().hasClass('jsonform-alternative')) {
enableFields($target, targetIdx);
}
};
tabs.each(function () {
$(this).delegate('select.nav', 'change', optionSelected);
$(this).find('select.nav').each(function () {
$(this).val($(this).find('.active').attr('value'));
var targetIdx = $(this).find('option:selected').attr('data-idx') ||
$(this).find('option:selected').attr('value');
var $target = $(this).parents('.tabbable').eq(0).find('.tab-content [data-idx=' + targetIdx + ']');
enableFields($target, targetIdx);
});
$(this).delegate('ul.nav li', 'click', tabClicked);
$(this).find('ul.nav li.active').click();
});
};
// Twitter bootstrap-friendly HTML boilerplate for standard inputs
jsonform.fieldTemplate = function(inner) {
return '
',
'fieldtempate': true,
'inputfield': true,
'onInsert': function (evt, node) {
var activeClass = 'active';
var elt = node.formElement || {};
if (elt.activeClass) {
activeClass += ' ' + elt.activeClass;
}
$(node.el).find('label').on('click', function () {
$(this).parent().find('label').removeClass(activeClass);
$(this).addClass(activeClass);
});
}
},
'checkboxes':{
'template': '
<%= choiceshtml %>
',
'fieldtemplate': true,
'inputfield': true,
'onBeforeRender': function (data, node) {
// Build up choices from the enumeration list
var choices = null;
var choiceshtml = null;
var template = '';
if (!node || !node.schemaElement || !node.schemaElement.items) return;
choices = node.schemaElement.items['enum'] ||
node.schemaElement.items[0]['enum'];
if (!choices) return;
choiceshtml = '';
_.each(choices, function (choice, idx) {
choiceshtml += _.template(template, {
name: node.key + '[' + idx + ']',
value: _.include(node.value, choice),
title: node.formElement.titleMap ? node.formElement.titleMap[choice] : choice,
node: node
}, fieldTemplateSettings);
});
data.choiceshtml = choiceshtml;
}
},
'array': {
'template': '
<%= children %>
' +
'' +
' ' +
'' +
'' +
'
',
'fieldtemplate': true,
'array': true,
'childTemplate': function (inner) {
if ($('').sortable) {
// Insert a "draggable" icon
// floating to the left of the main element
return '
' +
'' +
inner +
'
';
}
else {
return '
' +
inner +
'
';
}
},
'onInsert': function (evt, node) {
var $nodeid = $(node.el).find('#' + escapeSelector(node.id));
var boundaries = node.getArrayBoundaries();
// Switch two nodes in an array
var moveNodeTo = function (fromIdx, toIdx) {
// Note "switchValuesWith" extracts values from the DOM since field
// values are not synchronized with the tree data structure, so calls
// to render are needed at each step to force values down to the DOM
// before next move.
// TODO: synchronize field values and data structure completely and
// call render only once to improve efficiency.
if (fromIdx === toIdx) return;
var incr = (fromIdx < toIdx) ? 1: -1;
var i = 0;
var parentEl = $('> ul', $nodeid);
for (i = fromIdx; i !== toIdx; i += incr) {
node.children[i].switchValuesWith(node.children[i + incr]);
node.children[i].render(parentEl.get(0));
node.children[i + incr].render(parentEl.get(0));
}
// No simple way to prevent DOM reordering with jQuery UI Sortable,
// so we're going to need to move sorted DOM elements back to their
// origin position in the DOM ourselves (we switched values but not
// DOM elements)
var fromEl = $(node.children[fromIdx].el);
var toEl = $(node.children[toIdx].el);
fromEl.detach();
toEl.detach();
if (fromIdx < toIdx) {
if (fromIdx === 0) parentEl.prepend(fromEl);
else $(node.children[fromIdx-1].el).after(fromEl);
$(node.children[toIdx-1].el).after(toEl);
}
else {
if (toIdx === 0) parentEl.prepend(toEl);
else $(node.children[toIdx-1].el).after(toEl);
$(node.children[fromIdx-1].el).after(fromEl);
}
};
$('> span > a._jsonform-array-addmore', $nodeid).click(function (evt) {
evt.preventDefault();
evt.stopPropagation();
var idx = node.children.length;
if (boundaries.maxItems>=0) {
if (idx>boundaries.maxItems-2) $('> span > a._jsonform-array-addmore', $nodeid).addClass("disabled");
if (idx>boundaries.maxItems-1) return false;
}
node.insertArrayItem(idx, $('> ul', $nodeid).get(0));
if (boundaries.minItems > 0 && idx > boundaries.minItems - 1) {
$('> span > a._jsonform-array-deletelast', $nodeid).removeClass("disabled");
}
});
$('> span > a._jsonform-array-deletelast', $nodeid).click(function (evt) {
var idx = node.children.length - 1;
evt.preventDefault();
evt.stopPropagation();
if (boundaries.minItems > 0) {
if (idx < boundaries.minItems + 1) {
$('> span > a._jsonform-array-deletelast', $nodeid).addClass('disabled');
}
if (idx < boundaries.minItems) return false;
}
node.deleteArrayItem(idx);
if (boundaries.maxItems>=0 && idx<=boundaries.maxItems-1) {
$('> span > a._jsonform-array-addmore', $nodeid).removeClass("disabled");
}
});
if ($(node.el).sortable) {
$('> ul', $nodeid).sortable();
$('> ul', $nodeid).bind('sortstop', function (event, ui) {
var idx = $(ui.item).data('idx');
var newIdx = $(ui.item).index();
moveNodeTo(idx, newIdx);
});
}
}
},
'tabarray': {
'template': '
';
},
'onBeforeRender': function (data, node) {
// Generate the initial 'tabs' from the children
var tabs = '';
_.each(node.children, function (child, idx) {
var title = child.legend ||
child.title ||
('Item ' + (idx+1));
tabs += '
';
});
data.tabs = tabs;
},
'onInsert': function (evt, node) {
var $nodeid = $(node.el).find('#' + escapeSelector(node.id));
var boundaries = node.getArrayBoundaries();
var moveNodeTo = function (fromIdx, toIdx) {
// Note "switchValuesWith" extracts values from the DOM since field
// values are not synchronized with the tree data structure, so calls
// to render are needed at each step to force values down to the DOM
// before next move.
// TODO: synchronize field values and data structure completely and
// call render only once to improve efficiency.
if (fromIdx === toIdx) return;
var incr = (fromIdx < toIdx) ? 1: -1;
var i = 0;
var tabEl = $('> .tabbable > .tab-content', $nodeid).get(0);
for (i = fromIdx; i !== toIdx; i += incr) {
node.children[i].switchValuesWith(node.children[i + incr]);
node.children[i].render(tabEl);
node.children[i + incr].render(tabEl);
}
};
// Refreshes the list of tabs
var updateTabs = function (selIdx) {
var tabs = '';
if (selIdx === undefined) {
selIdx = $('> .tabbable > .nav-tabs .active', $nodeid).data('idx');
if (selIdx) {
selIdx = parseInt(selIdx, 10);
}
else {
selIdx = 0;
}
}
if (selIdx >= node.children.length) {
selIdx = node.children.length - 1;
}
_.each(node.children, function (child, idx) {
var title = child.legend ||
child.title ||
('Item ' + (idx+1));
tabs += '
';
},
'onBeforeRender': function (data, node) {
// Before rendering, this function ensures that:
// 1. direct children have IDs (used to show/hide the tabs contents)
// 2. the tab to active is flagged accordingly. The active tab is
// the first one, except if form values are available, in which case
// it's the first tab for which there is some value available (or back
// to the first one if there are none)
// 3. the HTML of the select field used to select tabs is exposed in the
// HTML template data as "tabs"
var children = null;
var choices = [];
if (node.schemaElement) {
choices = node.schemaElement['enum'] || [];
}
if (node.options) {
children = _.map(node.options, function (option, idx) {
var child = node.children[idx];
if (option instanceof Object) {
option = _.extend({ node: child }, option);
option.title = option.title ||
child.legend ||
child.title ||
('Option ' + (child.childPos+1));
option.value = option.value || choices[idx] || idx;
return option;
}
else {
return {
title: option,
value: choices[child.childPos] || child.childPos,
node: child
};
}
});
}
else {
children = _.map(node.children, function (child, idx) {
return {
title: child.legend || child.title || ('Option ' + (child.childPos+1)),
value: choices[child.childPos] || child.childPos,
node: child
};
});
}
var activeChild = null;
if (data.value) {
activeChild = _.find(children, function (child) {
return (child.value === node.value);
});
}
if (!activeChild) {
activeChild = _.find(children, function (child) {
return child.node.hasNonDefaultValue();
});
}
if (!activeChild) {
activeChild = children[0];
}
activeChild.node.active = true;
data.value = activeChild.value;
var elt = node.formElement;
var tabs = '';
data.tabs = tabs;
return data;
},
'onInsert': function (evt, node) {
$(node.el).find('select.nav').first().on('change', function (evt) {
var $option = $(this).find('option:selected');
$(node.el).find('input[type="hidden"]').first().val($option.attr('value'));
});
}
},
'optionfieldset': {
'template': '
'
},
/**
* A "questions" field renders a series of question fields and binds the
* result to the value of a schema key.
*/
'questions': {
'template': '
' +
'' +
'<%= children %>' +
'
',
'fieldtempate': true,
'inputfield': true,
'getElement': function (el) {
return $(el).parent().get(0);
},
'onInsert': function (evt, node) {
if (!node.children || (node.children.length === 0)) return;
_.each(node.children, function (child) {
$(child.el).hide();
});
$(node.children[0].el).show();
}
},
/**
* A "question" field lets user choose a response among possible choices.
* The field is not associated with any schema key. A question should be
* part of a "questions" field that binds a series of questions to a
* schema key.
*/
'question': {
'template': '
',
'fieldtemplate': true,
'onInsert': function (evt, node) {
var activeClass = 'active';
var elt = node.formElement || {};
if (elt.activeClass) {
activeClass += ' ' + elt.activeClass;
}
// Bind to change events on radio buttons
$(node.el).find('input[type="radio"]').on('change', function (evt) {
var questionNode = null;
var option = node.options[$(this).val()];
if (!node.parentNode || !node.parentNode.el) return;
$(this).parent().parent().find('label').removeClass(activeClass);
$(this).parent().addClass(activeClass);
$(node.el).nextAll().hide();
$(node.el).nextAll().find('input[type="radio"]').prop('checked', false);
// Execute possible actions (set key value, form submission, open link,
// move on to next question)
if (option.value) {
// Set the key of the 'Questions' parent
$(node.parentNode.el).find('input[type="hidden"]').val(option.value);
}
if (option.next) {
questionNode = _.find(node.parentNode.children, function (child) {
return (child.formElement && (child.formElement.qid === option.next));
});
$(questionNode.el).show();
$(questionNode.el).nextAll().hide();
$(questionNode.el).nextAll().find('input[type="radio"]').prop('checked', false);
}
if (option.href) {
if (option.target) {
window.open(option.href, option.target);
}
else {
window.location = option.href;
}
}
if (option.submit) {
setTimeout(function () {
node.ownerTree.submit();
}, 0);
}
});
}
}
};
/*
Legacy elements:
'file-jquery-multiple': jsonform.defaultFieldTemplate('
'),
*/
//Allow to access subproperties by splitting "."
/**
* Retrieves the key identified by a path selector in the structured object.
*
* Levels in the path are separated by a dot. Array items are marked
* with [x]. For instance:
* foo.bar[3].baz
*
* @function
* @param {Object} obj Structured object to parse
* @param {String} key Path to the key to retrieve
* @param {boolean} ignoreArrays True to use first element in an array when
* stucked on a property. This parameter is basically only useful when
* parsing a JSON schema for which the "items" property may either be an
* object or an array with one object (only one because JSON form does not
* support mix of items for arrays).
* @return {Object} The key's value.
*/
jsonform.util.getObjKey = function (obj, key, ignoreArrays) {
var innerobj = obj;
var keyparts = key.split(".");
var subkey = null;
var arrayMatch = null;
for (var i = 0; i < keyparts.length; i++) {
if ((innerobj === null) || (typeof innerobj !== "object")) return null;
subkey = keyparts[i];
arrayMatch = subkey.match(reArraySingle);
if (arrayMatch) {
// Subkey is part of an array
subkey = subkey.replace(reArraySingle, '');
if (!_.isArray(innerobj[subkey])) {
return null;
}
innerobj = innerobj[subkey][parseInt(arrayMatch[1], 10)];
}
else if (ignoreArrays && !innerobj[subkey] && _.isArray(innerobj) && innerobj[0]) {
innerobj = innerobj[0][subkey];
}
else {
innerobj = innerobj[subkey];
}
}
if (ignoreArrays && _.isArray(innerobj) && innerobj[0]) {
return innerobj[0];
}
else {
return innerobj;
}
};
/**
* Sets the key identified by a path selector to the given value.
*
* Levels in the path are separated by a dot. Array items are marked
* with [x]. For instance:
* foo.bar[3].baz
*
* The hierarchy is automatically created if it does not exist yet.
*
* @function
* @param {Object} obj The object to build
* @param {String} key The path to the key to set where each level
* is separated by a dot, and array items are flagged with [x].
* @param {Object} value The value to set, may be of any type.
*/
jsonform.util.setObjKey = function(obj,key,value) {
var innerobj = obj;
var keyparts = key.split(".");
var subkey = null;
var arrayMatch = null;
for (var i = 0; i < keyparts.length-1; i++) {
subkey = keyparts[i];
arrayMatch = subkey.match(reArraySingle);
if (arrayMatch) {
// Subkey is part of an array
subkey = subkey.replace(reArraySingle, '');
if (!_.isArray(innerobj[subkey])) {
innerobj[subkey] = [];
}
if ((typeof innerobj[subkey][parseInt(arrayMatch[1], 10)] !== 'object') ||
(innerobj[subkey][parseInt(arrayMatch[1], 10)] === null)) {
innerobj[subkey][parseInt(arrayMatch[1], 10)] = {};
}
innerobj = innerobj[subkey][parseInt(arrayMatch[1], 10)];
}
else {
// "Normal" subkey
if ((typeof innerobj[subkey] !== 'object') ||
(innerobj[subkey] === null)) {
innerobj[subkey] = {};
}
innerobj = innerobj[subkey];
}
}
// Set the final value
subkey = keyparts[keyparts.length - 1];
arrayMatch = subkey.match(reArraySingle);
if (arrayMatch) {
subkey = subkey.replace(reArraySingle, '');
if (!_.isArray(innerobj[subkey])) {
innerobj[subkey] = [];
}
innerobj[subkey][parseInt(arrayMatch[1], 10)] = value;
}
else {
innerobj[subkey] = value;
}
};
/**
* Retrieves the key definition from the given schema.
*
* The key is identified by the path that leads to the key in the
* structured object that the schema would generate. Each level is
* separated by a '.'. Array levels are marked with []. For instance:
* foo.bar[].baz
* ... to retrieve the definition of the key at the following location
* in the JSON schema (using a dotted path notation):
* foo.properties.bar.items.properties.baz
*
* @function
* @param {Object} schema The JSON schema to retrieve the key from
* @param {String} key The path to the key, each level being separated
* by a dot and array items being flagged with [].
* @return {Object} The key definition in the schema, null if not found.
*/
var getSchemaKey = function(schema,key) {
var schemaKey = key
.replace(/\./g, '.properties.')
.replace(/\[[0-9]*\](\.|$)/g, '.items$1');
return jsonform.util.getObjKey(schema, schemaKey, true);
};
/**
* Truncates the key path to the requested depth.
*
* For instance, if the key path is:
* foo.bar[].baz.toto[].truc[].bidule
* and the requested depth is 1, the returned key will be:
* foo.bar[].baz.toto
*
* Note the function includes the path up to the next depth level.
*
* @function
* @param {String} key The path to the key in the schema, each level being
* separated by a dot and array items being flagged with [].
* @param {Number} depth The array depth
* @return {String} The path to the key truncated to the given depth.
*/
var truncateToArrayDepth = function (key, arrayDepth) {
var depth = 0;
var pos = 0;
if (!key) return null;
if (arrayDepth > 0) {
while (depth < arrayDepth) {
pos = key.indexOf('[]', pos);
if (pos === -1) {
// Key path is not "deep" enough, simply return the full key
return key;
}
pos = pos + 2;
depth += 1;
}
}
// Move one step further to the right without including the final []
pos = key.indexOf('[]', pos);
if (pos === -1) return key;
else return key.substring(0, pos);
};
/**
* Applies the array path to the key path.
*
* For instance, if the key path is:
* foo.bar[].baz.toto[].truc[].bidule
* and the arrayPath [4, 2], the returned key will be:
* foo.bar[4].baz.toto[2].truc[].bidule
*
* @function
* @param {String} key The path to the key in the schema, each level being
* separated by a dot and array items being flagged with [].
* @param {Array(Number)} arrayPath The array path to apply, e.g. [4, 2]
* @return {String} The path to the key that matches the array path.
*/
var applyArrayPath = function (key, arrayPath) {
var depth = 0;
if (!key) return null;
if (!arrayPath || (arrayPath.length === 0)) return key;
var newKey = key.replace(reArray, function (str, p1) {
// Note this function gets called as many times as there are [x] in the ID,
// from left to right in the string. The goal is to replace the [x] with
// the appropriate index in the new array path, if defined.
var newIndex = str;
if (!_.isUndefined(arrayPath[depth]) && !_.isNull(arrayPath[depth])) {
newIndex = '[' + arrayPath[depth] + ']';
}
depth += 1;
return newIndex;
});
return newKey;
};
/**
* Returns the initial value that a field identified by its key
* should take.
*
* The "initial" value is defined as:
* 1. the previously submitted value if already submitted
* 2. the default value defined in the layout of the form
* 3. the default value defined in the schema
*
* The "value" returned is intended for rendering purpose,
* meaning that, for fields that define a titleMap property,
* the function returns the label, and not the intrinsic value.
*
* The function handles values that contains template strings,
* e.g. {{values.foo[].bar}} or {{idx}}.
*
* @function
* @param {Object} formObject The JSON Form object
* @param {String} key The generic key path (e.g. foo[].bar.baz[])
* @param {Array(Number)} arrayPath The array path that identifies
* the unique value in the submitted form (e.g. [1, 3])
* @param {Object} tpldata Template data object
* @param {Boolean} usePreviousValues true to use previously submitted values
* if defined.
*/
var getInitialValue = function (formObject, key, arrayPath, tpldata, usePreviousValues) {
var value = null;
// Complete template data for template function
tpldata = tpldata || {};
tpldata.idx = tpldata.idx ||
(arrayPath ? arrayPath[arrayPath.length-1] : 1);
tpldata.value = tpldata.value || '';
tpldata.getValue = tpldata.getValue || function (key) {
return getInitialValue(formObject, key, arrayPath, tpldata, usePreviousValues);
};
// Helper function that returns the form element that explicitly
// references the given key in the schema.
var getFormElement = function (elements, key) {
var formElement = null;
if (!elements || !elements.length) return null;
_.each(elements, function (elt) {
if (formElement) return;
if (elt === key) {
formElement = { key: elt };
return;
}
if (_.isString(elt)) return;
if (elt.key === key) {
formElement = elt;
}
else if (elt.items) {
formElement = getFormElement(elt.items, key);
}
});
return formElement;
};
var formElement = getFormElement(formObject.form || [], key);
if (usePreviousValues && formObject.value) {
// If values were previously submitted, use them directly if defined
value = jsonform.util.getObjKey(formObject.value, applyArrayPath(key, arrayPath));
}
if ((typeof value === 'undefined') || (value === null)) {
if (formElement && (typeof formElement['value'] !== 'undefined')) {
// Extract the definition of the form field associated with
// the key as it may override the schema's default value
value = formElement['value'];
}
else {
// Simply extract the default value from the schema
var schemaElement = getSchemaKey(
formObject.schema.properties, key);
if (schemaElement) {
value = schemaElement['default'] || '';
}
}
if (value && value.indexOf('{{values.') !== -1) {
// This label wants to use the value of another input field.
// Convert that construct into {{getValue(key)}} for
// Underscore to call the appropriate function of formData
// when template gets called (note calling a function is not
// exactly Mustache-friendly but is supported by Underscore).
value = value.replace(
/\{\{values\.([^\}]+)\}\}/g,
'{{getValue("$1")}}');
}
if (value) {
value = _.template(value, tpldata, valueTemplateSettings);
}
}
// Apply titleMap if needed
if ((typeof value !== 'undefined') && (value !== null) &&
formElement && formElement.titleMap &&
formElement.titleMap[value]) {
value = _.template(formElement.titleMap[value],
tpldata, valueTemplateSettings);
}
if (typeof value === 'undefined') return null;
else return value;
};
/**
* Represents a node in the form.
*
* Nodes that have an ID are linked to the corresponding DOM element
* when rendered
*
* Note the form element and the schema elements that gave birth to the
* node may be shared among multiple nodes (in the case of arrays).
*
* @class
*/
var formNode = function () {
/**
* The node's ID (may not be set)
*/
this.id = null;
/**
* The node's key path (may not be set)
*/
this.key = null;
/**
* DOM element associated witht the form element.
*
* The DOM element is set when the form element is rendered.
*/
this.el = null;
/**
* Link to the form element that describes the node's layout
* (note the form element is shared among nodes in arrays)
*/
this.formElement = null;
/**
* Link to the schema element that describes the node's value constraints
* (note the schema element is shared among nodes in arrays)
*/
this.schemaElement = null;
/**
* Pointer to the "view" associated with the node, typically the right
* object in jsonform.elementTypes
*/
this.view = null;
/**
* Node's subtree (if one is defined)
*/
this.children = [];
/**
* A pointer to the form tree the node is attached to
*/
this.ownerTree = null;
/**
* A pointer to the parent node of the node in the tree
*/
this.parentNode = null;
/**
* Child template for array-like nodes.
*
* The child template gets cloned to create new array items.
*/
this.childTemplate = null;
/**
* Direct children of array-like containers may use the value of a
* specific input field in their subtree as legend. The link to the
* legend child is kept here and initialized in computeInitialValues
* when a child sets "valueInLegend"
*/
this.legendChild = null;
/**
* The path of indexes that lead to the current node when the
* form element is not at the root array level.
*
* Note a form element may well be nested element and still be
* at the root array level. That's typically the case for "fieldset"
* elements. An array level only gets created when a form element
* is of type "array" (or a derivated type such as "tabarray").
*
* The array path of a form element linked to the foo[2].bar.baz[3].toto
* element in the submitted values is [2, 3] for instance.
*
* The array path is typically used to compute the right ID for input
* fields. It is also used to update positions when an array item is
* created, moved around or suppressed.
*
* @type {Array(Number)}
*/
this.arrayPath = [];
/**
* Position of the node in the list of children of its parents
*/
this.childPos = 0;
};
/**
* Clones a node
*
* @function
* @param {formNode} New parent node to attach the node to
* @return {formNode} Cloned node
*/
formNode.prototype.clone = function (parentNode) {
var node = new formNode();
node.arrayPath = _.clone(this.arrayPath);
node.ownerTree = this.ownerTree;
node.parentNode = parentNode || this.parentNode;
node.formElement = this.formElement;
node.schemaElement = this.schemaElement;
node.view = this.view;
node.children = _.map(this.children, function (child) {
return child.clone(node);
});
if (this.childTemplate) {
node.childTemplate = this.childTemplate.clone(node);
}
return node;
};
/**
* Returns true if the subtree that starts at the current node
* has some non empty value attached to it
*/
formNode.prototype.hasNonDefaultValue = function () {
// hidden elements don't count because they could make the wrong selectfieldset element active
if (this.formElement && this.formElement.type=="hidden") {
return false;
}
if (this.value && !this.defaultValue) {
return true;
}
var child = _.find(this.children, function (child) {
return child.hasNonDefaultValue();
});
return !!child;
};
/**
* Attaches a child node to the current node.
*
* The child node is appended to the end of the list.
*
* @function
* @param {formNode} node The child node to append
* @return {formNode} The inserted node (same as the one given as parameter)
*/
formNode.prototype.appendChild = function (node) {
node.parentNode = this;
node.childPos = this.children.length;
this.children.push(node);
return node;
};
/**
* Removes the last child of the node.
*
* @function
*/
formNode.prototype.removeChild = function () {
var child = this.children[this.children.length-1];
if (!child) return;
// Remove the child from the DOM
$(child.el).remove();
// Remove the child from the array
return this.children.pop();
};
/**
* Moves the user entered values set in the current node's subtree to the
* given node's subtree.
*
* The target node must follow the same structure as the current node
* (typically, they should have been generated from the same node template)
*
* The current node MUST be rendered in the DOM.
*
* TODO: when current node is not in the DOM, extract values from formNode.value
* properties, so that the function be available even when current node is not
* in the DOM.
*
* Moving values around allows to insert/remove array items at arbitrary
* positions.
*
* @function
* @param {formNode} node Target node.
*/
formNode.prototype.moveValuesTo = function (node) {
var values = this.getFormValues(node.arrayPath);
node.resetValues();
node.computeInitialValues(values, true);
};
/**
* Switches nodes user entered values.
*
* The target node must follow the same structure as the current node
* (typically, they should have been generated from the same node template)
*
* Both nodes MUST be rendered in the DOM.
*
* TODO: update getFormValues to work even if node is not rendered, using
* formNode's "value" property.
*
* @function
* @param {formNode} node Target node
*/
formNode.prototype.switchValuesWith = function (node) {
var values = this.getFormValues(node.arrayPath);
var nodeValues = node.getFormValues(this.arrayPath);
node.resetValues();
node.computeInitialValues(values, true);
this.resetValues();
this.computeInitialValues(nodeValues, true);
};
/**
* Resets all DOM values in the node's subtree.
*
* This operation also drops all array item nodes.
* Note values are not reset to their default values, they are rather removed!
*
* @function
*/
formNode.prototype.resetValues = function () {
var params = null;
var idx = 0;
// Reset value
this.value = null;
// Propagate the array path from the parent node
// (adding the position of the child for nodes that are direct
// children of array-like nodes)
if (this.parentNode) {
this.arrayPath = _.clone(this.parentNode.arrayPath);
if (this.parentNode.view && this.parentNode.view.array) {
this.arrayPath.push(this.childPos);
}
}
else {
this.arrayPath = [];
}
if (this.view && this.view.inputfield) {
// Simple input field, extract the value from the origin,
// set the target value and reset the origin value
params = $(':input', this.el).serializeArray();
_.each(params, function (param) {
// TODO: check this, there may exist corner cases with this approach
// (with multiple checkboxes for instance)
$('[name="' + escapeSelector(param.name) + '"]', $(this.el)).val('');
}, this);
}
else if (this.view && this.view.array) {
// The current node is an array, drop all children
while (this.children.length > 0) {
this.removeChild();
}
}
// Recurse down the tree
_.each(this.children, function (child) {
child.resetValues();
});
};
/**
* Sets the child template node for the current node.
*
* The child template node is used to create additional children
* in an array-like form element. The template is never rendered.
*
* @function
* @param {formNode} node The child template node to set
*/
formNode.prototype.setChildTemplate = function (node) {
this.childTemplate = node;
node.parentNode = this;
};
/**
* Recursively sets values to all nodes of the current subtree
* based on previously submitted values, or based on default
* values when the submitted values are not enough
*
* The function should be called once in the lifetime of a node
* in the tree. It expects its parent's arrayPath to be up to date.
*
* Three cases may arise:
* 1. if the form element is a simple input field, the value is
* extracted from previously submitted values of from default values
* defined in the schema.
* 2. if the form element is an array-like node, the child template
* is used to create as many children as possible (and at least one).
* 3. the function simply recurses down the node's subtree otherwise
* (this happens when the form element is a fieldset-like element).
*
* @function
* @param {Object} values Previously submitted values for the form
* @param {Boolean} ignoreDefaultValues Ignore default values defined in the
* schema when set.
*/
formNode.prototype.computeInitialValues = function (values, ignoreDefaultValues) {
var self = this;
var node = null;
var nbChildren = 1;
var i = 0;
var formData = this.ownerTree.formDesc.tpldata || {};
// Propagate the array path from the parent node
// (adding the position of the child for nodes that are direct
// children of array-like nodes)
if (this.parentNode) {
this.arrayPath = _.clone(this.parentNode.arrayPath);
if (this.parentNode.view && this.parentNode.view.array) {
this.arrayPath.push(this.childPos);
}
}
else {
this.arrayPath = [];
}
// Prepare special data param "idx" for templated values
// (is is the index of the child in its wrapping array, starting
// at 1 since that's more human-friendly than a zero-based index)
formData.idx = (this.arrayPath.length > 0) ?
this.arrayPath[this.arrayPath.length-1] + 1 :
this.childPos + 1;
// Prepare special data param "value" for templated values
formData.value = '';
// Prepare special function to compute the value of another field
formData.getValue = function (key) {
return getInitialValue(self.ownerTree.formDesc,
key, self.arrayPath,
formData, !!values);
};
if (this.formElement) {
// Compute the ID of the field (if needed)
if (this.formElement.id) {
this.id = applyArrayPath(this.formElement.id, this.arrayPath);
}
else if (this.view && this.view.array) {
this.id = escapeSelector(this.ownerTree.formDesc.prefix) +
'-elt-counter-' + _.uniqueId();
}
else if (this.parentNode && this.parentNode.view &&
this.parentNode.view.array) {
// Array items need an array to associate the right DOM element
// to the form node when the parent is rendered.
this.id = escapeSelector(this.ownerTree.formDesc.prefix) +
'-elt-counter-' + _.uniqueId();
}
else if ((this.formElement.type === 'button') ||
(this.formElement.type === 'selectfieldset') ||
(this.formElement.type === 'question') ||
(this.formElement.type === 'buttonquestion')) {
// Buttons do need an id for "onClick" purpose
this.id = escapeSelector(this.ownerTree.formDesc.prefix) +
'-elt-counter-' + _.uniqueId();
}
// Compute the actual key (the form element's key is index-free,
// i.e. it looks like foo[].bar.baz[].truc, so we need to apply
// the array path of the node to get foo[4].bar.baz[2].truc)
if (this.formElement.key) {
this.key = applyArrayPath(this.formElement.key, this.arrayPath);
this.keydash = this.key.replace(/\./g, '---');
}
// Same idea for the field's name
this.name = applyArrayPath(this.formElement.name, this.arrayPath);
// Consider that label values are template values and apply the
// form's data appropriately (note we also apply the array path
// although that probably doesn't make much sense for labels...)
_.each([
'title',
'legend',
'description',
'append',
'prepend',
'inlinetitle',
'helpvalue',
'value',
'disabled'
], function (prop) {
if (_.isString(this.formElement[prop])) {
if (this.formElement[prop].indexOf('{{values.') !== -1) {
// This label wants to use the value of another input field.
// Convert that construct into {{jsonform.getValue(key)}} for
// Underscore to call the appropriate function of formData
// when template gets called (note calling a function is not
// exactly Mustache-friendly but is supported by Underscore).
this[prop] = this.formElement[prop].replace(
/\{\{values\.([^\}]+)\}\}/g,
'{{getValue("$1")}}');
}
else {
// Note applying the array path probably doesn't make any sense,
// but some geek might want to have a label "foo[].bar[].baz",
// with the [] replaced by the appropriate array path.
this[prop] = applyArrayPath(this.formElement[prop], this.arrayPath);
}
if (this[prop]) {
this[prop] = _.template(this[prop], formData, valueTemplateSettings);
}
}
else {
this[prop] = this.formElement[prop];
}
}, this);
// Apply templating to options created with "titleMap" as well
if (this.formElement.options) {
this.options = _.map(this.formElement.options, function (option) {
var title = null;
if (_.isObject(option) && option.title) {
// See a few lines above for more details about templating
// preparation here.
if (option.title.indexOf('{{values.') !== -1) {
title = option.title.replace(
/\{\{values\.([^\}]+)\}\}/g,
'{{getValue("$1")}}');
}
else {
title = applyArrayPath(option.title, self.arrayPath);
}
return _.extend({}, option, {
value: ((typeof option.value !== 'undefined') ? option.value : ''),
title: _.template(title, formData, valueTemplateSettings)
});
}
else {
return option;
}
});
}
}
if (this.view && this.view.inputfield && this.schemaElement) {
// Case 1: simple input field
if (values) {
// Form has already been submitted, use former value if defined.
// Note we won't set the field to its default value otherwise
// (since the user has already rejected it)
if (jsonform.util.getObjKey(values, this.key)) {
this.value = jsonform.util.getObjKey(values, this.key);
}
}
else if (!ignoreDefaultValues) {
// No previously submitted form result, use default value
// defined in the schema if it's available and not already
// defined in the form element
if (!this.value && this.schemaElement['default']) {
this.value = this.schemaElement['default'];
if (_.isString(this.value)) {
if (this.value.indexOf('{{values.') !== -1) {
// This label wants to use the value of another input field.
// Convert that construct into {{jsonform.getValue(key)}} for
// Underscore to call the appropriate function of formData
// when template gets called (note calling a function is not
// exactly Mustache-friendly but is supported by Underscore).
this.value = this.value.replace(
/\{\{values\.([^\}]+)\}\}/g,
'{{getValue("$1")}}');
}
else {
// Note applying the array path probably doesn't make any sense,
// but some geek might want to have a label "foo[].bar[].baz",
// with the [] replaced by the appropriate array path.
this.value = applyArrayPath(this.value, this.arrayPath);
}
if (this.value) {
this.value = _.template(this.value, formData, valueTemplateSettings);
}
}
this.defaultValue = true;
}
}
}
else if (this.view && this.view.array) {
// Case 2: array-like node
nbChildren = 0;
if (values) {
nbChildren = this.getPreviousNumberOfItems(values, this.arrayPath);
}
// TODO: use default values at the array level when form has not been
// submitted before. Note it's not that easy because each value may
// be a complex structure that needs to be pushed down the subtree.
// The easiest way is probably to generate a "values" object and
// compute initial values from that object
/*
else if (this.schemaElement['default']) {
nbChildren = this.schemaElement['default'].length;
}
*/
if (nbChildren === 0) nbChildren = 1;
for (i = 0; i < nbChildren; i++) {
this.appendChild(this.childTemplate.clone());
}
}
// Case 3 and in any case: recurse through the list of children
_.each(this.children, function (child) {
child.computeInitialValues(values, ignoreDefaultValues);
});
// If the node's value is to be used as legend for its "container"
// (typically the array the node belongs to), ensure that the container
// has a direct link to the node for the corresponding tab.
if (this.formElement && this.formElement.valueInLegend) {
node = this;
while (node) {
if (node.parentNode &&
node.parentNode.view &&
node.parentNode.view.array) {
node.legendChild = this;
if (node.formElement && node.formElement.legend) {
node.legend = applyArrayPath(node.formElement.legend, node.arrayPath);
formData.idx = (node.arrayPath.length > 0) ?
node.arrayPath[node.arrayPath.length-1] + 1 :
node.childPos + 1;
formData.value = this.value || '';
node.legend = _.template(node.legend, formData, valueTemplateSettings);
break;
}
}
node = node.parentNode;
}
}
};
/**
* Returns the number of items that the array node should have based on
* previously submitted values.
*
* The whole difficulty is that values may be hidden deep in the subtree
* of the node and may actually target different arrays in the JSON schema.
*
* @function
* @param {Object} values Previously submitted values
* @param {Array(Number)} arrayPath the array path we're interested in
* @return {Number} The number of items in the array
*/
formNode.prototype.getPreviousNumberOfItems = function (values, arrayPath) {
var key = null;
var arrayValue = null;
var childNumbers = null;
var idx = 0;
if (!values) {
// No previously submitted values, no need to go any further
return 0;
}
if (this.view.inputfield && this.schemaElement) {
// Case 1: node is a simple input field that links to a key in the schema.
// The schema key looks typically like:
// foo.bar[].baz.toto[].truc[].bidule
// The goal is to apply the array path and truncate the key to the last
// array we're interested in, e.g. with an arrayPath [4, 2]:
// foo.bar[4].baz.toto[2]
key = truncateToArrayDepth(this.formElement.key, arrayPath.length);
key = applyArrayPath(key, arrayPath);
arrayValue = jsonform.util.getObjKey(values, key);
if (!arrayValue) {
// No key? That means this field had been left empty
// in previous submit
return 0;
}
childNumbers = _.map(this.children, function (child) {
return child.getPreviousNumberOfItems(values, arrayPath);
});
return _.max([_.max(childNumbers) || 0, arrayValue.length]);
}
else if (this.view.array) {
// Case 2: node is an array-like node, look for input fields
// in its child template
return this.childTemplate.getPreviousNumberOfItems(values, arrayPath);
}
else {
// Case 3: node is a leaf or a container,
// recurse through the list of children and return the maximum
// number of items found in each subtree
childNumbers = _.map(this.children, function (child) {
return child.getPreviousNumberOfItems(values, arrayPath);
});
return _.max(childNumbers) || 0;
}
};
/**
* Returns the structured object that corresponds to the form values entered
* by the user for the node's subtree.
*
* The returned object follows the structure of the JSON schema that gave
* birth to the form.
*
* Obviously, the node must have been rendered before that function may
* be called.
*
* @function
* @param {Array(Number)} updateArrayPath Array path to use to pretend that
* the entered values were actually entered for another item in an array
* (this is used to move values around when an item is inserted/removed/moved
* in an array)
* @return {Object} The object that follows the data schema and matches the
* values entered by the user.
*/
formNode.prototype.getFormValues = function (updateArrayPath) {
// The values object that will be returned
var values = {};
if (!this.el) {
throw new Error('formNode.getFormValues can only be called on nodes that are associated with a DOM element in the tree');
}
// Form fields values
var formArray = $(':input', this.el).serializeArray();
if (updateArrayPath) {
_.each(formArray, function (param) {
param.name = applyArrayPath(param.name, updateArrayPath);
});
}
// The underlying data schema
var formSchema = this.ownerTree.formDesc.schema;
for (var i = 0; i < formArray.length; i++) {
// Retrieve the key definition from the data schema
var name = formArray[i].name;
var eltSchema = getSchemaKey(formSchema.properties, name);
var arrayMatch = null;
var cval = null;
// Skip the input field if it's not part of the schema
if (!eltSchema) continue;
// Handle multiple checkboxes separately as the idea is to generate
// an array that contains the list of enumeration items that the user
// selected.
if (eltSchema._jsonform_checkboxes_as_array) {
arrayMatch = name.match(/\[([0-9]*)\]$/);
if (arrayMatch) {
name = name.replace(/\[([0-9]*)\]$/, '');
cval = jsonform.util.getObjKey(values, name) || [];
if (formArray[i].value === '1') {
// Value selected, push the corresponding enumeration item
// to the data result
cval.push(eltSchema['enum'][parseInt(arrayMatch[1],10)]);
}
jsonform.util.setObjKey(values, name, cval);
continue;
}
}
// Type casting
if (eltSchema.type === 'boolean') {
if (formArray[i].value === '0') {
formArray[i].value = false;
} else {
formArray[i].value = !!formArray[i].value;
}
}
if ((eltSchema.type === 'number') ||
(eltSchema.type === 'integer')) {
if (_.isString(formArray[i].value)) {
if (!formArray[i].value.length) {
formArray[i].value = undefined;
} else if (!isNaN(Number(formArray[i].value))) {
formArray[i].value = Number(formArray[i].value);
}
}
}
if ((eltSchema.type === 'string') &&
(formArray[i].value === '') &&
!eltSchema._jsonform_allowEmpty) {
formArray[i].value=null;
}
if ((eltSchema.type === 'object') &&
_.isString(formArray[i].value) &&
(formArray[i].value.substring(0,1) === '{')) {
try {
formArray[i].value = JSON.parse(formArray[i].value);
} catch (e) {
formArray[i].value = {};
}
}
//TODO is this due to a serialization bug?
if ((eltSchema.type === 'object') &&
(formArray[i].value === 'null' || formArray[i].value === '')) {
formArray[i].value = null;
}
if (formArray[i].name && (formArray[i].value !== null)) {
jsonform.util.setObjKey(values, formArray[i].name, formArray[i].value);
}
}
// console.log("Form value",values);
return values;
};
/**
* Renders the node.
*
* Rendering is done in three steps: HTML generation, DOM element creation
* and insertion, and an enhance step to bind event handlers.
*
* @function
* @param {Node} el The DOM element where the node is to be rendered. The
* node is inserted at the right position based on its "childPos" property.
*/
formNode.prototype.render = function (el) {
var html = this.generate();
this.setContent(html, el);
this.enhance();
};
/**
* Inserts/Updates the HTML content of the node in the DOM.
*
* If the HTML is an update, the new HTML content replaces the old one.
* The new HTML content is not moved around in the DOM in particular.
*
* The HTML is inserted at the right position in its parent's DOM subtree
* otherwise (well, provided there are enough children, but that should always
* be the case).
*
* @function
* @param {string} html The HTML content to render
* @param {Node} parentEl The DOM element that is to contain the DOM node.
* This parameter is optional (the node's parent is used otherwise) and
* is ignored if the node to render is already in the DOM tree.
*/
formNode.prototype.setContent = function (html, parentEl) {
var node = $(html);
var parentNode = parentEl ||
(this.parentNode ? this.parentNode.el : this.ownerTree.domRoot);
var nextSibling = null;
if (this.el) {
// Replace the contents of the DOM element if the node is already in the tree
$(this.el).replaceWith(node);
}
else {
// Insert the node in the DOM if it's not already there
nextSibling = $(parentNode).children().get(this.childPos);
if (nextSibling) {
$(nextSibling).before(node);
}
else {
$(parentNode).append(node);
}
}
// Save the link between the form node and the generated HTML
this.el = node;
// Update the node's subtree, extracting DOM elements that match the nodes
// from the generated HTML
this.updateElement(this.el);
};
/**
* Updates the DOM element associated with the node.
*
* Only nodes that have ID are directly associated with a DOM element.
*
* @function
*/
formNode.prototype.updateElement = function (domNode) {
if (this.id) {
this.el = $('#' + escapeSelector(this.id), domNode).get(0);
if (this.view && this.view.getElement) {
this.el = this.view.getElement(this.el);
}
if ((this.fieldtemplate !== false) &&
this.view && this.view.fieldtemplate) {
// The field template wraps the element two or three level deep
// in the DOM tree, depending on whether there is anything prepended
// or appended to the input field
this.el = $(this.el).parent().parent();
if (this.prepend || this.prepend) {
this.el = this.el.parent();
}
this.el = this.el.get(0);
}
if (this.parentNode && this.parentNode.view &&
this.parentNode.view.childTemplate) {
// TODO: the child template may introduce more than one level,
// so the number of levels introduced should rather be exposed
// somehow in jsonform.fieldtemplate.
this.el = $(this.el).parent().get(0);
}
}
_.each(this.children, function (child) {
child.updateElement(this.el || domNode);
});
};
/**
* Generates the view's HTML content for the underlying model.
*
* @function
*/
formNode.prototype.generate = function () {
var data = {
id: this.id,
keydash: this.keydash,
elt: this.formElement,
schema: this.schemaElement,
node: this,
value: this.value || '',
escape: escapeHTML
};
var template = null;
var html = '';
// Complete the data context if needed
if (this.ownerTree.formDesc.onBeforeRender) {
this.ownerTree.formDesc.onBeforeRender(data, this);
}
if (this.view.onBeforeRender) {
this.view.onBeforeRender(data, this);
}
// Use the template that 'onBeforeRender' may have set,
// falling back to that of the form element otherwise
if (this.template) {
template = this.template;
}
else if (this.formElement && this.formElement.template) {
template = this.formElement.template;
}
else {
template = this.view.template;
}
// Wrap the view template in the generic field template
// (note the strict equality to 'false', needed as we fallback
// to the view's setting otherwise)
if ((this.fieldtemplate !== false) &&
(this.fieldtemplate || this.view.fieldtemplate)) {
template = jsonform.fieldTemplate(template);
}
// Wrap the content in the child template of its parent if necessary.
if (this.parentNode && this.parentNode.view &&
this.parentNode.view.childTemplate) {
template = this.parentNode.view.childTemplate(template);
}
// Prepare the HTML of the children
var childrenhtml = '';
_.each(this.children, function (child) {
childrenhtml += child.generate();
});
data.children = childrenhtml;
// Apply the HTML template
html = _.template(template, data, fieldTemplateSettings);
return html;
};
/**
* Enhances the view with additional logic, binding event handlers
* in particular.
*
* The function also runs the "insert" event handler of the view and
* form element if they exist (starting with that of the view)
*
* @function
*/
formNode.prototype.enhance = function () {
var node = this;
var handlers = null;
var handler = null;
var formData = _.clone(this.ownerTree.formDesc.tpldata) || {};
if (this.formElement) {
// Check the view associated with the node as it may define an "onInsert"
// event handler to be run right away
if (this.view.onInsert) {
this.view.onInsert({ target: $(this.el) }, this);
}
handlers = this.handlers || this.formElement.handlers;
// Trigger the "insert" event handler
handler = this.onInsert || this.formElement.onInsert;
if (handler) {
handler({ target: $(this.el) }, this);
}
if (handlers) {
_.each(handlers, function (handler, onevent) {
if (onevent === 'insert') {
handler({ target: $(this.el) }, this);
}
}, this);
}
// No way to register event handlers if the DOM element is unknown
// TODO: find some way to register event handlers even when this.el is not set.
if (this.el) {
// Register specific event handlers
// TODO: Add support for other event handlers
if (this.onChange)
$(this.el).bind('change', function(evt) { node.onChange(evt, node); });
if (this.view.onChange)
$(this.el).bind('change', function(evt) { node.view.onChange(evt, node); });
if (this.formElement.onChange)
$(this.el).bind('change', function(evt) { node.formElement.onChange(evt, node); });
if (this.onClick)
$(this.el).bind('click', function(evt) { node.onClick(evt, node); });
if (this.view.onClick)
$(this.el).bind('click', function(evt) { node.view.onClick(evt, node); });
if (this.formElement.onClick)
$(this.el).bind('click', function(evt) { node.formElement.onClick(evt, node); });
if (this.onKeyUp)
$(this.el).bind('keyup', function(evt) { node.onKeyUp(evt, node); });
if (this.view.onKeyUp)
$(this.el).bind('keyup', function(evt) { node.view.onKeyUp(evt, node); });
if (this.formElement.onKeyUp)
$(this.el).bind('keyup', function(evt) { node.formElement.onKeyUp(evt, node); });
if (handlers) {
_.each(handlers, function (handler, onevent) {
if (onevent !== 'insert') {
$(this.el).bind(onevent, function(evt) { handler(evt, node); });
}
}, this);
}
}
// Auto-update legend based on the input field that's associated with it
if (this.legendChild && this.legendChild.formElement) {
$(this.legendChild.el).bind('keyup', function (evt) {
if (node.formElement && node.formElement.legend && node.parentNode) {
node.legend = applyArrayPath(node.formElement.legend, node.arrayPath);
formData.idx = (node.arrayPath.length > 0) ?
node.arrayPath[node.arrayPath.length-1] + 1 :
node.childPos + 1;
formData.value = $(evt.target).val();
node.legend = _.template(node.legend, formData, valueTemplateSettings);
$(node.parentNode.el).trigger('legendUpdated');
}
});
}
}
// Recurse down the tree to enhance children
_.each(this.children, function (child) {
child.enhance();
});
};
/**
* Inserts an item in the array at the requested position and renders the item.
*
* @function
* @param {Number} idx Insertion index
*/
formNode.prototype.insertArrayItem = function (idx, domElement) {
var i = 0;
// Insert element at the end of the array if index is not given
if (idx === undefined) {
idx = this.children.length;
}
// Create the additional array item at the end of the list,
// using the item template created when tree was initialized
// (the call to resetValues ensures that 'arrayPath' is correctly set)
var child = this.childTemplate.clone();
this.appendChild(child);
child.resetValues();
// To create a blank array item at the requested position,
// shift values down starting at the requested position
// one to insert (note we start with the end of the array on purpose)
for (i = this.children.length-2; i >= idx; i--) {
this.children[i].moveValuesTo(this.children[i+1]);
}
// Initialize the blank node we've created with default values
this.children[idx].resetValues();
this.children[idx].computeInitialValues();
// Re-render all children that have changed
for (i = idx; i < this.children.length; i++) {
this.children[i].render(domElement);
}
};
/**
* Remove an item from an array
*
* @function
* @param {Number} idx The index number of the item to remove
*/
formNode.prototype.deleteArrayItem = function (idx) {
var i = 0;
var child = null;
// Delete last item if no index is given
if (idx === undefined) {
idx = this.children.length - 1;
}
// Move values up in the array
for (i = idx; i < this.children.length-1; i++) {
this.children[i+1].moveValuesTo(this.children[i]);
this.children[i].render();
}
// Remove the last array item from the DOM tree and from the form tree,
// except if the item is the last one left, in which case it is simply reset.
if (this.children.length > 1) {
this.removeChild();
}
else {
this.children[0].resetValues();
this.children[0].computeInitialValues();
this.children[0].render();
}
};
/**
* Returns the minimum/maximum number of items that an array field
* is allowed to have according to the schema definition of the fields
* it contains.
*
* The function parses the schema definitions of the array items that
* compose the current "array" node and returns the minimum value of
* "maxItems" it encounters as the maximum number of items, and the
* maximum value of "minItems" as the minimum number of items.
*
* The function reports a -1 for either of the boundaries if the schema
* does not put any constraint on the number of elements the current
* array may have of if the current node is not an array.
*
* Note that array boundaries should be defined in the JSON Schema using
* "minItems" and "maxItems". The code also supports "minLength" and
* "maxLength" as a fallback, mostly because it used to by mistake (see #22)
* and because other people could make the same mistake.
*
* @function
* @return {Object} An object with properties "minItems" and "maxItems"
* that reports the corresponding number of items that the array may
* have (value is -1 when there is no constraint for that boundary)
*/
formNode.prototype.getArrayBoundaries = function () {
var boundaries = {
minItems: -1,
maxItems: -1
};
if (!this.view || !this.view.array) return boundaries;
var getNodeBoundaries = function (node, initialNode) {
var schemaKey = null;
var boundaries = {
minItems: -1,
maxItems: -1
};
initialNode = initialNode || node;
if (node.view && node.view.array && (node !== initialNode)) {
// New array level not linked to an array in the schema,
// so no size constraints
return boundaries;
}
if (node.key) {
// Note the conversion to target the actual array definition in the
// schema where minItems/maxItems may be defined,
// e.g. from foo[0].bar[3].baz to foo[].bar
schemaKey = getSchemaKey(
node.ownerTree.formDesc.schema.properties,
node.key
.replace(/\[[0-9]+\]/g, '[]')
.replace(/\[\][^\[\]]*$/, '')
);
if (!schemaKey) return boundaries;
return {
minItems: schemaKey.minItems || schemaKey.maxLength || -1,
maxItems: schemaKey.maxItems || schemaKey.maxLength || -1
};
}
else {
_.each(node.children, function (child) {
var subBoundaries = getNodeBoundaries(child, initialNode);
if (subBoundaries.minItems !== -1) {
if (boundaries.minItems !== -1) {
boundaries.minItems = Math.max(
boundaries.minItems,
subBoundaries.minItems
);
}
else {
boundaries.minItems = subBoundaries.minItems;
}
}
if (subBoundaries.maxItems !== -1) {
if (boundaries.maxItems !== -1) {
boundaries.maxItems = Math.min(
boundaries.maxItems,
subBoundaries.maxItems
);
}
else {
boundaries.maxItems = subBoundaries.maxItems;
}
}
});
}
return boundaries;
};
return getNodeBoundaries(this);
};
/**
* Form tree class.
*
* Holds the internal representation of the form.
* The tree is always in sync with the rendered form, this allows to parse
* it easily.
*
* @class
*/
var formTree = function () {
this.eventhandlers = [];
this.root = null;
this.formDesc = null;
};
/**
* Initializes the form tree structure from the JSONForm object
*
* This function is the main entry point of the JSONForm library.
*
* Initialization steps:
* 1. the internal tree structure that matches the JSONForm object
* gets created (call to buildTree)
* 2. initial values are computed from previously submitted values
* or from the default values defined in the JSON schema.
*
* When the function returns, the tree is ready to be rendered through
* a call to "render".
*
* @function
*/
formTree.prototype.initialize = function (formDesc) {
formDesc = formDesc || {};
// Keep a pointer to the initial JSONForm
// (note clone returns a shallow copy, only first-level is cloned)
this.formDesc = _.clone(formDesc);
// Compute form prefix if no prefix is given.
this.formDesc.prefix = this.formDesc.prefix ||
'jsonform-' + _.uniqueId();
// JSON schema shorthand
if (this.formDesc.schema && !this.formDesc.schema.properties) {
this.formDesc.schema = {
properties: this.formDesc.schema
};
}
// Ensure layout is set
this.formDesc.form = this.formDesc.form || [
'*',
{
type: 'actions',
items: [
{
type: 'submit',
value: 'Submit'
}
]
}
];
this.formDesc.form = (_.isArray(this.formDesc.form) ?
this.formDesc.form :
[this.formDesc.form]);
// Create the root of the tree
this.root = new formNode();
this.root.ownerTree = this;
this.root.view = jsonform.elementTypes['root'];
// Generate the tree from the form description
this.buildTree();
// Compute the values associated with each node
// (for arrays, the computation actually creates the form nodes)
this.computeInitialValues();
};
/**
* Constructs the tree from the form description.
*
* The function must be called once when the tree is first created.
*
* @function
*/
formTree.prototype.buildTree = function () {
// Parse and generate the form structure based on the elements encountered:
// - '*' means "generate all possible fields using default layout"
// - a key reference to target a specific data element
// - a more complex object to generate specific form sections
_.each(this.formDesc.form, function (formElement) {
if (formElement === '*') {
_.each(this.formDesc.schema.properties, function (element, key) {
this.root.appendChild(this.buildFromLayout({
key: key
}));
}, this);
}
else {
if (_.isString(formElement)) {
formElement = {
key: formElement
};
}
this.root.appendChild(this.buildFromLayout(formElement));
}
}, this);
};
/**
* Builds the internal form tree representation from the requested layout.
*
* The function is recursive, generating the node children as necessary.
* The function extracts the values from the previously submitted values
* (this.formDesc.value) or from default values defined in the schema.
*
* @function
* @param {Object} formElement JSONForm element to render
* @param {Object} context The parsing context (the array depth in particular)
* @return {Object} The node that matches the element.
*/
formTree.prototype.buildFromLayout = function (formElement, context) {
var schemaElement = null;
var node = new formNode();
var view = null;
var key = null;
// The form element parameter directly comes from the initial
// JSONForm object. We'll make a shallow copy of it and of its children
// not to pollute the original object.
// (note JSON.parse(JSON.stringify()) cannot be used since there may be
// event handlers in there!)
formElement = _.clone(formElement);
if (formElement.items) {
if (_.isArray(formElement.items)) {
formElement.items = _.map(formElement.items, _.clone);
}
else {
formElement.items = [ _.clone(formElement.items) ];
}
}
if (formElement.key) {
// The form element is directly linked to an element in the JSON
// schema. The properties of the form element override those of the
// element in the JSON schema. Properties from the JSON schema complete
// those of the form element otherwise.
// Retrieve the element from the JSON schema
schemaElement = getSchemaKey(
this.formDesc.schema.properties,
formElement.key);
if (!schemaElement) {
// The JSON Form is invalid!
throw new Error('The JSONForm object references the schema key "' +
formElement.key + '" but that key does not exist in the JSON schema');
}
// Schema element has just been found, let's trigger the
// "onElementSchema" event
// (tidoust: not sure what the use case for this is, keeping the
// code for backward compatibility)
if (this.formDesc.onElementSchema) {
this.formDesc.onElementSchema(formElement, schemaElement);
}
formElement.name = formElement.name ||
formElement.key;
formElement.title = formElement.title ||
schemaElement.title;
formElement.description = formElement.description ||
schemaElement.description;
// Compute the ID of the input field
if (!formElement.id) {
formElement.id = escapeSelector(this.formDesc.prefix) +
'-elt-' + formElement.key;
}
// Should empty strings be included in the final value?
// TODO: it's rather unclean to pass it through the schema.
if (formElement.allowEmpty) {
schemaElement._jsonform_allowEmpty = true;
}
// If the form element does not define its type, use the type of
// the schema element.
if (!formElement.type) {
if ((schemaElement.type === 'string') &&
(schemaElement.format === 'color')) {
formElement.type = 'color';
} else if ((schemaElement.type === 'number' ||
schemaElement.type === 'integer' ||
schemaElement.type === 'string' ||
schemaElement.type === 'any') &&
!schemaElement['enum']) {
formElement.type = 'text';
} else if (schemaElement.type === 'boolean') {
formElement.type = 'checkbox';
} else if (schemaElement.type === 'object') {
formElement.type = 'fieldset';
} else if (!_.isUndefined(schemaElement['enum'])) {
formElement.type = 'select';
} else {
formElement.type = schemaElement.type;
}
}
// Unless overridden in the definition of the form element (or unless
// there's a titleMap defined), use the enumeration list defined in
// the schema
if (!formElement.options && schemaElement['enum']) {
if (formElement.titleMap) {
formElement.options = _.map(schemaElement['enum'], function (value) {
return {
value: value,
title: formElement.titleMap[value] || value
};
});
}
else {
formElement.options = schemaElement['enum'];
}
}
// Flag a list of checkboxes with multiple choices
if ((formElement.type === 'checkboxes') && schemaElement.items) {
var itemsEnum = schemaElement.items['enum'];
if (itemsEnum) {
schemaElement.items._jsonform_checkboxes_as_array = true;
}
if (!itemsEnum && schemaElement.items[0]) {
itemsEnum = schemaElement.items[0]['enum'];
if (itemsEnum) {
schemaElement.items[0]._jsonform_checkboxes_as_array = true;
}
}
}
// If the form element targets an "object" in the JSON schema,
// we need to recurse through the list of children to create an
// input field per child property of the object in the JSON schema
if (schemaElement.type === 'object') {
_.each(schemaElement.properties, function (prop, propName) {
node.appendChild(this.buildFromLayout({
key: formElement.key + '.' + propName
}));
}, this);
}
}
if (!formElement.type) {
if (formElement.parentNode) {
formElement.type = 'none';
}
else {
formElement.type = 'root';
}
}
view = jsonform.elementTypes[formElement.type];
if (!view) {
throw new Error('The JSONForm contains an element whose type is unknown: "' +
formElement.type + '"');
}
if (schemaElement) {
// The form element is linked to an element in the schema.
// Let's make sure the types are compatible.
// In particular, the element must not be a "container"
// (or must be an "object" or "array" container)
if (!view.inputfield && !view.array &&
(formElement.type !== 'selectfieldset') &&
(schemaElement.type !== 'object')) {
throw new Error('The JSONForm contains an element that links to an ' +
'element in the JSON schema (key: "' + formElement.key + '") ' +
'and that should not based on its type ("' + formElement.type + '")');
}
}
else {
// The form element is not linked to an element in the schema.
// This means the form element must be a "container" element,
// and must not define an input field.
if (view.inputfield && (formElement.type !== 'selectfieldset')) {
throw new Error('The JSONForm defines an element of type ' +
'"' + formElement.type + '" ' +
'but no "key" property to link the input field to the JSON schema');
}
}
// A few characters need to be escaped to use the ID as jQuery selector
formElement.iddot = escapeSelector(formElement.id || '');
// Initialize the form node from the form element and schema element
node.formElement = formElement;
node.schemaElement = schemaElement;
node.view = view;
node.ownerTree = this;
// Set event handlers
if (!formElement.handlers) {
formElement.handlers = {};
}
// Parse children recursively
if (node.view.array) {
// The form element is an array. The number of items in an array
// is by definition dynamic, up to the form user (through "Add more",
// "Delete" commands). The positions of the items in the array may
// also change over time (through "Move up", "Move down" commands).
//
// The form node stores a "template" node that serves as basis for
// the creation of an item in the array.
//
// Array items may be complex forms themselves, allowing for nesting.
//
// The initial values set the initial number of items in the array.
// Note a form element contains at least one item when it is rendered.
if (formElement.items) {
key = formElement.items[0] || formElement.items;
}
else {
key = formElement.key + '[]';
}
if (_.isString(key)) {
key = { key: key };
}
node.setChildTemplate(this.buildFromLayout(key));
}
else if (formElement.items) {
// The form element defines children elements
_.each(formElement.items, function (item) {
if (_.isString(item)) {
item = { key: item };
}
node.appendChild(this.buildFromLayout(item));
}, this);
}
return node;
};
/**
* Computes the values associated with each input field in the tree based
* on previously submitted values or default values in the JSON schema.
*
* For arrays, the function actually creates and inserts additional
* nodes in the tree based on previously submitted values (also ensuring
* that the array has at least one item).
*
* The function sets the array path on all nodes.
* It should be called once in the lifetime of a form tree right after
* the tree structure has been created.
*
* @function
*/
formTree.prototype.computeInitialValues = function () {
this.root.computeInitialValues(this.formDesc.value);
};
/**
* Renders the form tree
*
* @function
* @param {Node} domRoot The "form" element in the DOM tree that serves as
* root for the form
*/
formTree.prototype.render = function (domRoot) {
if (!domRoot) return;
this.domRoot = domRoot;
this.root.render();
};
/**
* Walks down the element tree with a callback
*
* @function
* @param {Function} callback The callback to call on each element
*/
formTree.prototype.forEachElement = function (callback) {
var f = function(root) {
for (var i=0;i tag in the DOM
* @return {Object} The object that follows the data schema and matches the
* values entered by the user.
*/
jsonform.getFormValue = function (formelt) {
var form = $(formelt).data('jsonform-tree');
if (!form) return null;
return form.root.getFormValues();
};
/**
* Highlights errors reported by the JSON schema validator in the document.
*
* @function
* @param {Object} errors List of errors reported by the JSON schema validator
* @param {Object} options The JSON Form object that describes the form
* (unused for the time being, could be useful to store example values or
* specific error messages)
*/
$.fn.jsonFormErrors = function(errors, options) {
$(".error", this).removeClass("error");
$(".warning", this).removeClass("warning");
$(".jsonform-errortext", this).hide();
if (!errors) return;
for (var i = 0; i < errors.length; i++) {
// Compute the address of the input field in the form from the URI
// returned by the JSON schema validator.
// These URIs typically look like:
// urn:uuid:cccc265e-ffdd-4e40-8c97-977f7a512853#/pictures/1/thumbnail
// What we need from that is the path in the value object:
// pictures[1].thumbnail
// ... and the jQuery-friendly class selector of the input field:
// .jsonform-error-pictures\[1\]---thumbnail
var key = errors[i].uri
.replace(/.*#\//, '')
.replace(/\//g, '.')
.replace(/\.([0-9]+)(\.|$)/, '[$1]$2');
var errormarkerclass = ".jsonform-error-" +
escapeSelector(key.replace(/\./g,"---"));
var errorType = errors[i].type || "error";
$(errormarkerclass, this).addClass(errorType);
$(errormarkerclass + " .jsonform-errortext", this).html(errors[i].message).show();
}
};
/**
* Generates the HTML form from the given JSON Form object and renders the form.
*
* Main entry point of the library. Defined as a jQuery function that typically
* needs to be applied to a