/* 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; /** * 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 }; /** * Returns true if given value is neither "undefined" nor null */ var isSet = function (value) { return !(_.isUndefined(value) || _.isNull(value)); }; /** * Returns true if given property is directly property of an object */ var hasOwnProperty = function (obj, prop) { return typeof obj === 'object' && obj.hasOwnProperty(prop); } /** * The jsonform object whose methods will be exposed to the window object */ var jsonform = {util:{}}; // From backbonejs var escapeHTML = function (string) { if (!isSet(string)) { return ''; } string = '' + 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'); }; /** * * Slugifies a string by replacing spaces with _. Used to create * valid classnames and ids for the form. * * @function * @param {String} str The string to slugify * @return {String} The slugified string. */ var slugify = function(str) { return str.replace(/\ /g, '_'); } /** * 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), // do not use .attr() as it sometimes unexplicably fails targetIdx = $option.get(0).getAttribute('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')); // do not use .attr() as it sometimes unexplicably fails var targetIdx = $(this).find('option:selected').get(0).getAttribute('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 '
' + '<%= key %>="<%= elt.htmlMetaData[key] %>" ' + '<% }%>' + 'class="form-group jsonform-error-<%= keydash %>' + '<%= elt.htmlClass ? " " + elt.htmlClass : "" %>' + '<%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " jsonform-required" : "") %>' + '<%= (node.readOnly ? " jsonform-readonly" : "") %>' + '<%= (node.disabled ? " jsonform-disabled" : "") %>' + '">' + '<% if (!elt.notitle) { %>' + '' + '<% } %>' + '
' + '<% if (node.prepend || node.append) { %>' + '
' + '<% if (node.prepend) { %>' + '<%= node.prepend %>' + '<% } %>' + '<% } %>' + inner + '<% if (node.append) { %>' + '<%= node.append %>' + '<% } %>' + '<% if (node.prepend || node.append) { %>' + '
' + '<% } %>' + '<% if (node.description) { %>' + '<%= node.description %>' + '<% } %>' + '' + '
'; }; var fileDisplayTemplate = '
' + '<% if (value.type=="image") { %>' + '' + '<% } else { %>' + '<%= value.name %> (<%= Math.ceil(value.size/1024) %>kB)' + '<% } %>' + '
' + ' '; var inputFieldTemplate = function (type) { return { 'template': '\'' + 'name="<%= node.name %>" value="<%= escape(value) %>" id="<%= id %>"' + '<%= (node.disabled? " disabled" : "")%>' + '<%= (node.readOnly ? " readonly=\'readonly\'" : "") %>' + '<%= (node.schemaElement && (node.schemaElement.step > 0 || node.schemaElement.step == "any") ? " step=\'" + node.schemaElement.step + "\'" : "") %>' + '<%= (node.schemaElement && node.schemaElement.maxLength ? " maxlength=\'" + node.schemaElement.maxLength + "\'" : "") %>' + '<%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " required=\'required\'" : "") %>' + '<%= (node.placeholder? " placeholder=" + \'"\' + escape(node.placeholder) + \'"\' : "")%>' + ' />', 'fieldtemplate': true, 'inputfield': true } }; jsonform.elementTypes = { 'none': { 'template': '' }, 'root': { 'template': '
<%= children %>
' }, 'text': inputFieldTemplate('text'), 'password': inputFieldTemplate('password'), 'date': inputFieldTemplate('date'), 'datetime': inputFieldTemplate('datetime'), 'datetime-local': inputFieldTemplate('datetime-local'), 'email': inputFieldTemplate('email'), 'month': inputFieldTemplate('month'), 'number': inputFieldTemplate('number'), 'search': inputFieldTemplate('search'), 'tel': inputFieldTemplate('tel'), 'time': inputFieldTemplate('time'), 'url': inputFieldTemplate('url'), 'week': inputFieldTemplate('week'), 'range': { 'template': '' + 'name="<%= node.name %>" value="<%= escape(value) %>" id="<%= id %>"' + '<%= (node.disabled? " disabled" : "")%>' + ' min=<%= range.min %>' + ' max=<%= range.max %>' + ' step=<%= range.step %>' + '<%= (node.schemaElement && node.schemaElement.required ? " required=\'required\'" : "") %>' + ' />', 'fieldtemplate': true, 'inputfield': true, 'onBeforeRender': function (data, node) { data.range = { min: 1, max: 100, step: 1 }; if (!node || !node.schemaElement) return; if (node.formElement && node.formElement.step) { data.range.step = node.formElement.step; } if (typeof node.schemaElement.minimum !== 'undefined') { if (node.schemaElement.exclusiveMinimum) { data.range.min = node.schemaElement.minimum + data.range.step; } else { data.range.min = node.schemaElement.minimum; } } if (typeof node.schemaElement.maximum !== 'undefined') { if (node.schemaElement.exclusiveMaximum) { data.range.max = node.schemaElement.maximum - data.range.step; } else { data.range.max = node.schemaElement.maximum; } } } }, 'color':{ 'template':'' + 'name="<%= node.name %>" value="<%= escape(value) %>" id="<%= id %>"' + '<%= (node.disabled? " disabled" : "")%>' + '<%= (node.schemaElement && node.schemaElement.required ? " required=\'required\'" : "") %>' + ' />', 'fieldtemplate': true, 'inputfield': true, 'onInsert': function(evt, node) { $(node.el).find('#' + escapeSelector(node.id)).spectrum({ preferredFormat: "hex", showInput: true }); } }, 'textarea':{ 'template':'', 'fieldtemplate': true, 'inputfield': true }, 'wysihtml5':{ 'template':'', 'fieldtemplate': true, 'inputfield': true, 'onInsert': function (evt, node) { var setup = function () { //protect from double init if ($(node.el).data("wysihtml5")) return; $(node.el).data("wysihtml5_loaded",true); $(node.el).find('#' + escapeSelector(node.id)).wysihtml5({ "html": true, "link": true, "font-styles":true, "image": false, "events": { "load": function () { // In chrome, if an element is required and hidden, it leads to // the error 'An invalid form control with name='' is not focusable' // See http://stackoverflow.com/questions/7168645/invalid-form-control-only-in-google-chrome $(this.textareaElement).removeAttr('required'); } } }); }; // Is there a setup hook? if (window.jsonform_wysihtml5_setup) { window.jsonform_wysihtml5_setup(setup); return; } // Wait until wysihtml5 is loaded var itv = window.setInterval(function() { if (window.wysihtml5) { window.clearInterval(itv); setup(); } },1000); } }, 'ace':{ 'template':'
;">
;height:<%= elt.height || "300px" %>;">
', 'fieldtemplate': true, 'inputfield': true, 'onInsert': function (evt, node) { var setup = function () { var formElement = node.formElement || {}; var ace = window.ace; var editor = ace.edit($(node.el).find('#' + escapeSelector(node.id) + '__ace').get(0)); var idSelector = '#' + escapeSelector(node.id) + '__hidden'; // Force editor to use "\n" for new lines, not to bump into ACE "\r" conversion issue // (ACE is ok with "\r" on pasting but fails to return "\r" when value is extracted) editor.getSession().setNewLineMode('unix'); editor.renderer.setShowPrintMargin(false); editor.setTheme("ace/theme/"+(formElement.aceTheme||"twilight")); if (formElement.aceMode) { editor.getSession().setMode("ace/mode/"+formElement.aceMode); } editor.getSession().setTabSize(2); // Set the contents of the initial manifest file editor.getSession().setValue(node.value||""); //TODO this is clearly sub-optimal // 'Lazily' bind to the onchange 'ace' event to give // priority to user edits var lazyChanged = _.debounce(function () { $(node.el).find(idSelector).val(editor.getSession().getValue()); $(node.el).find(idSelector).change(); }, 600); editor.getSession().on('change', lazyChanged); editor.on('blur', function() { $(node.el).find(idSelector).change(); $(node.el).find(idSelector).trigger("blur"); }); editor.on('focus', function() { $(node.el).find(idSelector).trigger("focus"); }); }; // Is there a setup hook? if (window.jsonform_ace_setup) { window.jsonform_ace_setup(setup); return; } // Wait until ACE is loaded var itv = window.setInterval(function() { if (window.ace) { window.clearInterval(itv); setup(); } },1000); } }, 'checkbox':{ 'template': '
', 'fieldtemplate': true, 'inputfield': true, 'getElement': function (el) { return $(el).parent().get(0); } }, 'file':{ 'template':'' + '/>', 'fieldtemplate': true, 'inputfield': true }, 'file-hosted-public':{ 'template':'<% if (value && (value.type||value.url)) { %>'+fileDisplayTemplate+'<% } %>\' />', 'fieldtemplate': true, 'inputfield': true, 'getElement': function (el) { return $(el).parent().get(0); }, 'onBeforeRender': function (data, node) { if (!node.ownerTree._transloadit_generic_public_index) { node.ownerTree._transloadit_generic_public_index=1; } else { node.ownerTree._transloadit_generic_public_index++; } data.transloaditname = "_transloadit_jsonform_genericupload_public_"+node.ownerTree._transloadit_generic_public_index; if (!node.ownerTree._transloadit_generic_elts) node.ownerTree._transloadit_generic_elts = {}; node.ownerTree._transloadit_generic_elts[data.transloaditname] = node; }, 'onChange': function(evt,elt) { // The "transloadit" function should be called only once to enable // the service when the form is submitted. Has it already been done? if (elt.ownerTree._transloadit_bound) { return false; } elt.ownerTree._transloadit_bound = true; // Call the "transloadit" function on the form element var formElt = $(elt.ownerTree.domRoot); formElt.transloadit({ autoSubmit: false, wait: true, onSuccess: function (assembly) { // Image has been uploaded. Check the "results" property that // contains the list of files that Transloadit produced. There // should be one image per file input in the form at most. // console.log(assembly.results); var results = _.values(assembly.results); results = _.flatten(results); _.each(results, function (result) { // Save the assembly result in the right hidden input field var id = elt.ownerTree._transloadit_generic_elts[result.field].id; var input = formElt.find('#' + escapeSelector(id)); var nonEmptyKeys = _.filter(_.keys(result.meta), function (key) { return !!isSet(result.meta[key]); }); result.meta = _.pick(result.meta, nonEmptyKeys); input.val(JSON.stringify(result)); }); // Unbind transloadit from the form elt.ownerTree._transloadit_bound = false; formElt.unbind('submit.transloadit'); // Submit the form on next tick _.delay(function () { console.log('submit form'); elt.ownerTree.submit(); }, 10); }, onError: function (assembly) { // TODO: report the error to the user console.log('assembly error', assembly); } }); }, 'onInsert': function (evt, node) { $(node.el).find('a._jsonform-delete').on('click', function (evt) { $(node.el).find('._jsonform-preview').remove(); $(node.el).find('a._jsonform-delete').remove(); $(node.el).find('#' + escapeSelector(node.id)).val(''); evt.preventDefault(); return false; }); }, 'onSubmit':function(evt, elt) { if (elt.ownerTree._transloadit_bound) { return false; } return true; } }, 'file-transloadit': { 'template': '<% if (value && (value.type||value.url)) { %>'+fileDisplayTemplate+'<% } %>\' />', 'fieldtemplate': true, 'inputfield': true, 'getElement': function (el) { return $(el).parent().get(0); }, 'onChange': function (evt, elt) { // The "transloadit" function should be called only once to enable // the service when the form is submitted. Has it already been done? if (elt.ownerTree._transloadit_bound) { return false; } elt.ownerTree._transloadit_bound = true; // Call the "transloadit" function on the form element var formElt = $(elt.ownerTree.domRoot); formElt.transloadit({ autoSubmit: false, wait: true, onSuccess: function (assembly) { // Image has been uploaded. Check the "results" property that // contains the list of files that Transloadit produced. Note // JSONForm only supports 1-to-1 associations, meaning it // expects the "results" property to contain only one image // per file input in the form. // console.log(assembly.results); var results = _.values(assembly.results); results = _.flatten(results); _.each(results, function (result) { // Save the assembly result in the right hidden input field var input = formElt.find('input[name="' + result.field.replace(/^_transloadit_/, '') + '"]'); var nonEmptyKeys = _.filter(_.keys(result.meta), function (key) { return !!isSet(result.meta[key]); }); result.meta = _.pick(result.meta, nonEmptyKeys); input.val(JSON.stringify(result)); }); // Unbind transloadit from the form elt.ownerTree._transloadit_bound = false; formElt.unbind('submit.transloadit'); // Submit the form on next tick _.delay(function () { console.log('submit form'); elt.ownerTree.submit(); }, 10); }, onError: function (assembly) { // TODO: report the error to the user console.log('assembly error', assembly); } }); }, 'onInsert': function (evt, node) { $(node.el).find('a._jsonform-delete').on('click', function (evt) { $(node.el).find('._jsonform-preview').remove(); $(node.el).find('a._jsonform-delete').remove(); $(node.el).find('#' + escapeSelector(node.id)).val(''); evt.preventDefault(); return false; }); }, 'onSubmit': function (evt, elt) { if (elt.ownerTree._transloadit_bound) { return false; } return true; } }, 'select':{ 'template':'', 'fieldtemplate': true, 'inputfield': true }, 'imageselect': { 'template': '
' + '' + '' + '
', 'fieldtemplate': true, 'inputfield': true, 'onBeforeRender': function (data, node) { var elt = node.formElement || {}; var nbRows = null; var maxColumns = elt.imageSelectorColumns || 5; data.buttonTitle = elt.imageSelectorTitle || 'Select...'; data.prefix = elt.imagePrefix || ''; data.suffix = elt.imageSuffix || ''; data.width = elt.imageWidth || 32; data.height = elt.imageHeight || 32; data.buttonClass = elt.imageButtonClass || false; if (node.options.length > maxColumns) { nbRows = Math.ceil(node.options.length / maxColumns); data.columns = Math.ceil(node.options.length / nbRows); } else { data.columns = maxColumns; } }, 'getElement': function (el) { return $(el).parent().get(0); }, 'onInsert': function (evt, node) { $(node.el).on('click', '.dropdown-menu a', function (evt) { evt.preventDefault(); evt.stopPropagation(); var img = (evt.target.nodeName.toLowerCase() === 'img') ? $(evt.target) : $(evt.target).find('img'); var value = img.attr('src'); var elt = node.formElement || {}; var prefix = elt.imagePrefix || ''; var suffix = elt.imageSuffix || ''; var width = elt.imageWidth || 32; var height = elt.imageHeight || 32; if (value) { if (value.indexOf(prefix) === 0) { value = value.substring(prefix.length); } value = value.substring(0, value.length - suffix.length); $(node.el).find('input').attr('value', value); $(node.el).find('a[data-toggle="dropdown"]') .addClass(elt.imageButtonClass) .attr('style', 'max-width:' + width + 'px;max-height:' + height + 'px') .html(''); } else { $(node.el).find('input').attr('value', ''); $(node.el).find('a[data-toggle="dropdown"]') .removeClass(elt.imageButtonClass) .removeAttr('style') .html(elt.imageSelectorTitle || 'Select...'); } }); } }, 'iconselect': { 'template': '
' + '' + '' + '
', 'fieldtemplate': true, 'inputfield': true, 'onBeforeRender': function (data, node) { var elt = node.formElement || {}; var nbRows = null; var maxColumns = elt.imageSelectorColumns || 5; data.buttonTitle = elt.imageSelectorTitle || 'Select...'; data.buttonClass = elt.imageButtonClass || false; if (node.options.length > maxColumns) { nbRows = Math.ceil(node.options.length / maxColumns); data.columns = Math.ceil(node.options.length / nbRows); } else { data.columns = maxColumns; } }, 'getElement': function (el) { return $(el).parent().get(0); }, 'onInsert': function (evt, node) { $(node.el).on('click', '.dropdown-menu a', function (evt) { evt.preventDefault(); evt.stopPropagation(); var i = (evt.target.nodeName.toLowerCase() === 'i') ? $(evt.target) : $(evt.target).find('i'); var value = i.attr('class'); var elt = node.formElement || {}; if (value) { value = value; $(node.el).find('input').attr('value', value); $(node.el).find('a[data-toggle="dropdown"]') .addClass(elt.imageButtonClass) .html(''); } else { $(node.el).find('input').attr('value', ''); $(node.el).find('a[data-toggle="dropdown"]') .removeClass(elt.imageButtonClass) .html(elt.imageSelectorTitle || 'Select...'); } }); } }, 'radios':{ 'template': '
<% _.each(node.options, function(key, val) { %>
<% }); %>
', 'fieldtemplate': true, 'inputfield': true }, 'radiobuttons': { 'template': '
' + '<% _.each(node.options, function(key, val) { %>' + ' ' + '<% }); %>' + '
', 'fieldtemplate': 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) return; if (node.schemaElement.items) { choices = node.schemaElement.items["enum"] || node.schemaElement.items[0]["enum"]; } else { choices = node.schemaElement["enum"]; } if (!choices) return; choiceshtml = ''; _.each(choices, function (choice, idx) { choiceshtml += _.template(template, fieldTemplateSettings)({ name: node.key + '[' + idx + ']', value: _.include(node.value, choice), title: hasOwnProperty(node.formElement.titleMap, choice) ? node.formElement.titleMap[choice] : choice, node: node }); }); 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 (node.children.length > boundaries.maxItems - 2) { $nodeid.find('> span > a._jsonform-array-addmore') .addClass('disabled'); } if (node.children.length > boundaries.maxItems - 1) { return false; } } node.insertArrayItem(idx, $('> ul', $nodeid).get(0)); if ((boundaries.minItems <= 0) || ((boundaries.minItems > 0) && (node.children.length > boundaries.minItems - 1))) { $nodeid.find('> span > a._jsonform-array-deletelast') .removeClass('disabled'); } }); //Simulate Users click to setup the form with its minItems var curItems = $('> ul > li', $nodeid).length; if ((boundaries.minItems > 0) && (curItems < boundaries.minItems)) { for (var i = 0; i < (boundaries.minItems - 1) && ($nodeid.find('> ul > li').length < boundaries.minItems); i++) { //console.log('Calling click: ',$nodeid); //$('> span > a._jsonform-array-addmore', $nodeid).click(); node.insertArrayItem(curItems, $nodeid.find('> ul').get(0)); } } if ((boundaries.minItems > 0) && (node.children.length <= boundaries.minItems)) { $nodeid.find('> span > a._jsonform-array-deletelast') .addClass('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 (node.children.length < boundaries.minItems + 2) { $nodeid.find('> span > a._jsonform-array-deletelast') .addClass('disabled'); } if (node.children.length <= boundaries.minItems) { return false; } } else if (node.children.length === 1) { $nodeid.find('> span > a._jsonform-array-deletelast') .addClass('disabled'); } node.deleteArrayItem(idx); if ((boundaries.maxItems >= 0) && (idx <= boundaries.maxItems - 1)) { $nodeid.find('> span > a._jsonform-array-addmore') .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': '
    ' + '' + '
    ' + '<%= children %>' + '
    ' + '
    ' + ' ' + '
    ', 'fieldtemplate': true, 'array': true, 'childTemplate': function (inner) { return '
    ' + inner + '
    '; }, '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 += '
  • ' + escapeHTML(title) + '
  • '; }); 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 = ''; var activateFirstTab = false; if (selIdx === undefined) { selIdx = $('> .tabbable > .nav-tabs .active', $nodeid).data('idx'); if (selIdx) { selIdx = parseInt(selIdx, 10); } else { activateFirstTab = true; selIdx = 0; } } if (selIdx >= node.children.length) { selIdx = node.children.length - 1; } _.each(node.children, function (child, idx) { $('> .tabbable > .tab-content > [data-idx="' + idx + '"] > fieldset > legend', $nodeid).html(child.legend); var title = child.legend || child.title || ('Item ' + (idx+1)); tabs += '
  • ' + '' + escapeHTML(title) + '
  • '; }); $('> .tabbable > .nav-tabs', $nodeid).html(tabs); if (activateFirstTab) { $('> .tabbable > .nav-tabs [data-idx="0"]', $nodeid).addClass('active'); } $('> .tabbable > .nav-tabs [data-toggle="tab"]', $nodeid).eq(selIdx).click(); }; $('> a._jsonform-array-deleteitem', $nodeid).click(function (evt) { var idx = $('> .tabbable > .nav-tabs .active', $nodeid).data('idx'); evt.preventDefault(); evt.stopPropagation(); if (boundaries.minItems > 0) { if (node.children.length < boundaries.minItems + 1) { $nodeid.find('> a._jsonform-array-deleteitem') .addClass('disabled'); } if (node.children.length <= boundaries.minItems) return false; } node.deleteArrayItem(idx); updateTabs(); if ((node.children.length < boundaries.minItems + 1) || (node.children.length === 0)) { $nodeid.find('> a._jsonform-array-deleteitem').addClass('disabled'); } if ((boundaries.maxItems >= 0) && (node.children.length <= boundaries.maxItems)) { $nodeid.find('> a._jsonform-array-addmore').removeClass('disabled'); } }); $('> a._jsonform-array-addmore', $nodeid).click(function (evt) { var idx = node.children.length; if (boundaries.maxItems>=0) { if (node.children.length>boundaries.maxItems-2) { $('> a._jsonform-array-addmore', $nodeid).addClass("disabled"); } if (node.children.length > boundaries.maxItems - 1) { return false; } } evt.preventDefault(); evt.stopPropagation(); node.insertArrayItem(idx, $nodeid.find('> .tabbable > .tab-content').get(0)); updateTabs(idx); if ((boundaries.minItems <= 0) || ((boundaries.minItems > 0) && (idx > boundaries.minItems - 1))) { $nodeid.find('> a._jsonform-array-deleteitem').removeClass('disabled'); } }); $(node.el).on('legendUpdated', function (evt) { updateTabs(); evt.preventDefault(); evt.stopPropagation(); }); if ($(node.el).sortable) { $('> .tabbable > .nav-tabs', $nodeid).sortable({ containment: node.el, tolerance: 'pointer' }); $('> .tabbable > .nav-tabs', $nodeid).bind('sortstop', function (event, ui) { var idx = $(ui.item).data('idx'); var newIdx = $(ui.item).index(); moveNodeTo(idx, newIdx); updateTabs(newIdx); }); } // Simulate User's click to setup the form with its minItems if (boundaries.minItems >= 0) { for (var i = 0; i < (boundaries.minItems - 1); i++) { $nodeid.find('> a._jsonform-array-addmore').click(); } $nodeid.find('> a._jsonform-array-deleteitem').addClass('disabled'); updateTabs(); } if ((boundaries.maxItems >= 0) && (node.children.length >= boundaries.maxItems)) { $nodeid.find('> a._jsonform-array-addmore').addClass('disabled'); } if ((boundaries.minItems >= 0) && (node.children.length <= boundaries.minItems)) { $nodeid.find('> a._jsonform-array-deleteitem').addClass('disabled'); } } }, 'help': { 'template':'<%= elt.helpvalue %>', 'fieldtemplate': true }, 'msg': { 'template': '<%= elt.msg %>' }, 'fieldset': { 'template': '
    " ' + '<% if (id) { %> id="<%= id %>"<% } %>' + '>' + '<% if (node.title || node.legend) { %><%= node.title || node.legend %><% } %>' + '<% if (elt.expandable) { %>
    <% } %>' + '<%= children %>' + '<% if (elt.expandable) { %>
    <% } %>' + '
    ', onInsert: function (evt, node) { $('.expandable > div, .expandable > fieldset', node.el).hide(); // See #233 $(".expandable", node.el).removeClass("expanded"); } }, 'advancedfieldset': { 'template': ' id="<%= id %>"<% } %>' + ' class="expandable <%= elt.htmlClass?elt.htmlClass:"" %>">' + '<%= (node.title || node.legend) ? (node.title || node.legend) : "Advanced options" %>' + '
    ' + '<%= children %>' + '
    ' + '', onInsert: function (evt, node) { $('.expandable > div, .expandable > fieldset', node.el).hide(); // See #233 $(".expandable", node.el).removeClass("expanded"); } }, 'authfieldset': { 'template': ' id="<%= id %>"<% } %>' + ' class="expandable <%= elt.htmlClass?elt.htmlClass:"" %>">' + '<%= (node.title || node.legend) ? (node.title || node.legend) : "Authentication settings" %>' + '
    ' + '<%= children %>' + '
    ' + '', onInsert: function (evt, node) { $('.expandable > div, .expandable > fieldset', node.el).hide(); // See #233 $(".expandable", node.el).removeClass("expanded"); } }, 'submit':{ 'template':' id="<%= id %>" <% } %> class="btn btn-primary <%= elt.htmlClass?elt.htmlClass:"" %>" value="<%= value || node.title %>"<%= (node.disabled? " disabled" : "")%>/>' }, 'button':{ 'template':' ' }, 'actions':{ 'template':'
    "><%= children %>
    ' }, 'hidden':{ 'template':'', 'inputfield': true }, 'selectfieldset': { 'template': '
    ">' + '<% if (node.legend) { %><%= node.legend %><% } %>' + '<% if (node.formElement.key) { %><% } else { %>' + '<% } %>' + '
    ' + '
    ">' + '<% if (!elt.notitle) { %><% } %>' + '
    <%= tabs %>
    ' + '
    ' + '
    ' + '<%= children %>' + '
    ' + '
    ' + '
    ', 'inputfield': true, 'getElement': function (el) { return $(el).parent().get(0); }, 'childTemplate': function (inner) { return '
    ' + inner + '
    '; }, '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]; child.childPos = idx; // When nested the childPos is always 0. if (option instanceof Object) { option = _.extend({ node: child }, option); option.title = option.title || child.legend || child.title || ('Option ' + (child.childPos+1)); option.value = isSet(option.value) ? option.value : isSet(choices[idx]) ? choices[idx] : idx; return option; } else { return { title: option, value: isSet(choices[child.childPos]) ? 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': ' id="<%= node.id %>"<% } %>' + '>' + '<%= children %>' + '' }, 'section': { 'template': ' id="<%= node.id %>"<% } %>' + '><%= children %>' }, /** * A "questions" field renders a series of question fields and binds the * result to the value of a schema key. */ 'questions': { 'template': '
    ' + '' + '<%= children %>' + '
    ', 'fieldtemplate': 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': '
    <% _.each(node.options, function(key, val) { %> <% }); %>
    ', '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); } }); } } }; //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; var prop = null; for (var i = 0; i < keyparts.length; i++) { if ((innerobj === null) || (typeof innerobj !== "object")) return null; subkey = keyparts[i]; prop = subkey.replace(reArray, ''); reArray.lastIndex = 0; arrayMatch = reArray.exec(subkey); if (arrayMatch) { while (true) { if (prop && !_.isArray(innerobj[prop])) return null; innerobj = prop ? innerobj[prop][parseInt(arrayMatch[1])] : innerobj[parseInt(arrayMatch[1])]; arrayMatch = reArray.exec(subkey); if (!arrayMatch) break; // In the case of multidimensional arrays, // we should not take innerobj[prop][0] anymore, // but innerobj[0] directly prop = null; } } else if (ignoreArrays && !innerobj[prop] && _.isArray(innerobj) && innerobj[0]) { innerobj = innerobj[0][prop]; } else { innerobj = innerobj[prop]; } } 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; var prop = null; for (var i = 0; i < keyparts.length-1; i++) { subkey = keyparts[i]; prop = subkey.replace(reArray, ''); reArray.lastIndex = 0; arrayMatch = reArray.exec(subkey); if (arrayMatch) { // Subkey is part of an array while (true) { if (!_.isArray(innerobj[prop])) { innerobj[prop] = []; } innerobj = innerobj[prop]; prop = parseInt(arrayMatch[1], 10); arrayMatch = reArray.exec(subkey); if (!arrayMatch) break; } if ((typeof innerobj[prop] !== 'object') || (innerobj[prop] === null)) { innerobj[prop] = {}; } innerobj = innerobj[prop]; } else { // "Normal" subkey if ((typeof innerobj[prop] !== 'object') || (innerobj[prop] === null)) { innerobj[prop] = {}; } innerobj = innerobj[prop]; } } // Set the final value subkey = keyparts[keyparts.length - 1]; prop = subkey.replace(reArray, ''); reArray.lastIndex = 0; arrayMatch = reArray.exec(subkey); if (arrayMatch) { while (true) { if (!_.isArray(innerobj[prop])) { innerobj[prop] = []; } innerobj = innerobj[prop]; prop = parseInt(arrayMatch[1], 10); arrayMatch = reArray.exec(subkey); if (!arrayMatch) break; } innerobj[prop] = value; } else { innerobj[prop] = 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'); var schemaDef = jsonform.util.getObjKey(schema, schemaKey, true); if (schemaDef && schemaDef.$ref) { throw new Error('JSONForm does not yet support schemas that use the ' + '$ref keyword. See: https://github.com/joshfire/jsonform/issues/54'); } return schemaDef; }; /** * 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 (isSet(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}}. * * When the form is a string, the function truncates the resulting string * to meet a potential "maxLength" constraint defined in the schema, using * "..." to mark the truncation. Note it does not validate the resulting * string against other constraints (e.g. minLength, pattern) as it would * be hard to come up with an automated course of action to "fix" the value. * * @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 = isSet(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); var schemaElement = getSchemaKey(formObject.schema.properties, 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 (!isSet(value)) { 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 // (note a "null" value overrides a schema default value as well) value = formElement['value']; } else if (schemaElement) { // Simply extract the default value from the schema if (isSet(schemaElement['default'])) { 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, valueTemplateSettings)(tpldata); } } // TODO: handle on the formElement.options, because user can setup it too. // Apply titleMap if needed if (isSet(value) && formElement && hasOwnProperty(formElement.titleMap, value)) { value = _.template(formElement.titleMap[value], valueTemplateSettings)(tpldata); } // Check maximum length of a string if (value && _.isString(value) && schemaElement && schemaElement.maxLength) { if (value.length > schemaElement.maxLength) { // Truncate value to maximum length, adding continuation dots value = value.substr(0, schemaElement.maxLength - 1) + '…'; } } if (!isSet(value)) { 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 = slugify(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', 'placeholder', 'readOnly' ], 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], valueTemplateSettings)(formData); } } 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: (isSet(option.value) ? option.value : ''), title: _.template(title, valueTemplateSettings)(formData) }); } 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 (isSet(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 (!isSet(this.value) && isSet(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, valueTemplateSettings)(formData); } } 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; } */ else if (nbChildren === 0) { // If form has already been submitted with no children, the array // needs to be rendered without children. If there are no previously // submitted values, the array gets rendered with one empty item as // it's more natural from a user experience perspective. That item can // be removed with a click on the "-" button. 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 = isSet(this.value) ? this.value : ''; node.legend = _.template(node.legend, valueTemplateSettings)(formData); 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(); // Set values to false for unset checkboxes and radio buttons // because serializeArray() ignores them formArray = formArray.concat( $(':input[type=checkbox]:not(:disabled):not(:checked)', this.el).map( function() { return {"name": this.name, "value": this.checked} }).get() ); 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 = null; } 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: isSet(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; data.fieldHtmlClass = ''; if (this.ownerTree && this.ownerTree.formDesc && this.ownerTree.formDesc.params && this.ownerTree.formDesc.params.fieldHtmlClass) { data.fieldHtmlClass = this.ownerTree.formDesc.params.fieldHtmlClass; } if (this.formElement && (typeof this.formElement.fieldHtmlClass !== 'undefined')) { data.fieldHtmlClass = this.formElement.fieldHtmlClass; } // Apply the HTML template html = _.template(template, fieldTemplateSettings)(data); 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, valueTemplateSettings)(formData); $(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 this.removeChild(); }; /** * 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 arrayKey = 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. If we're still looking // at the initial node, the goal is to convert from: // foo[0].bar[3].baz to foo[].bar[].baz // If we're not looking at the initial node, the goal is to look at the // closest array parent: // foo[0].bar[3].baz to foo[].bar arrayKey = node.key.replace(/\[[0-9]+\]/g, '[]'); if (node !== initialNode) { arrayKey = arrayKey.replace(/\[\][^\[\]]*$/, ''); } schemaKey = getSchemaKey( node.ownerTree.formDesc.schema.properties, arrayKey ); if (!schemaKey) return boundaries; return { minItems: schemaKey.minItems || schemaKey.minLength || -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]); this.formDesc.params = this.formDesc.params || {}; // 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; formElement.readOnly = formElement.readOnly || schemaElement.readOnly || formElement.readonly || schemaElement.readonly; // Compute the ID of the input field if (!formElement.id) { formElement.id = escapeSelector(this.formDesc.prefix) + '-elt-' + slugify(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 schema type is an array containing only a type and "null", // remove null and make the element non-required if (_.isArray(schemaElement.type)) { if (_.contains(schemaElement.type, "null")) { schemaElement.type = _.without(schemaElement.type, "null"); schemaElement.required = false; } if (schemaElement.type.length > 1) { throw new Error("Cannot process schema element with multiple types."); } schemaElement.type = _.first(schemaElement.type); } if ((schemaElement.type === 'string') && (schemaElement.format === 'color')) { formElement.type = 'color'; } else if ((schemaElement.type === 'number' || schemaElement.type === 'integer') && !schemaElement['enum']) { formElement.type = 'number'; } else if ((schemaElement.type === 'string' || schemaElement.type === 'any') && !schemaElement['enum']) { formElement.type = 'text'; } else if (schemaElement.type === 'boolean') { formElement.type = 'checkbox'; } else if (schemaElement.type === 'object') { if (schemaElement.properties) { formElement.type = 'fieldset'; } else { formElement.type = 'textarea'; } } 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: hasOwnProperty(formElement.titleMap, value) ? 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) { formElement.type = 'none'; } 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(); // If the schema defines required fields, flag the form with the // "jsonform-hasrequired" class for styling purpose // (typically so that users may display a legend) if (this.hasRequiredField()) { $(domRoot).addClass('jsonform-hasrequired'); } }; /** * 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; var errorSelectors = []; 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]+)(?=\.|$)/g, '[$1]'); var errormarkerclass = ".jsonform-error-" + escapeSelector(key.replace(/\./g,"---")); errorSelectors.push(errormarkerclass); var errorType = errors[i].type || "error"; $(errormarkerclass, this).addClass(errorType); $(errormarkerclass + " .jsonform-errortext", this).html(errors[i].message).show(); } // Look for the first error in the DOM and ensure the element // is visible so that the user understands that something went wrong errorSelectors = errorSelectors.join(','); var firstError = $(errorSelectors).get(0); if (firstError && firstError.scrollIntoView) { firstError.scrollIntoView(true, { behavior: 'smooth' }); } }; /** * 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
    element in the document. * * The function handles the following properties for the JSON Form object it * receives as parameter: * - schema (required): The JSON Schema that describes the form to render * - form: The options form layout description, overrides default layout * - prefix: String to use to prefix computed IDs. Default is an empty string. * Use this option if JSON Form is used multiple times in an application with * schemas that have overlapping parameter names to avoid running into multiple * IDs issues. Default value is "jsonform-[counter]". * - transloadit: Transloadit parameters when transloadit is used * - validate: Validates form against schema upon submission. Uses the value * of the "validate" property as validator if it is an object. * - displayErrors: Function to call with errors upon form submission. * Default is to render the errors next to the input fields. * - submitEvent: Name of the form submission event to bind to. * Default is "submit". Set this option to false to avoid event binding. * - onSubmit: Callback function to call when form is submitted * - onSubmitValid: Callback function to call when form is submitted without * errors. * * @function * @param {Object} options The JSON Form object to use as basis for the form */ $.fn.jsonForm = function(options) { var formElt = this; options = _.defaults({}, options, {submitEvent: 'submit'}); var form = new formTree(); form.initialize(options); form.render(formElt.get(0)); // TODO: move that to formTree.render if (options.transloadit) { formElt.append(''); } // Keep a direct pointer to the JSON schema for form submission purpose formElt.data("jsonform-tree", form); if (options.submitEvent) { formElt.unbind((options.submitEvent)+'.jsonform'); formElt.bind((options.submitEvent)+'.jsonform', function(evt) { form.submit(evt); }); } // Initialize tabs sections, if any initializeTabs(formElt); // Initialize expandable sections, if any $('.expandable > div, .expandable > fieldset', formElt).hide(); formElt.on('click', '.expandable > legend', function () { var parent = $(this).parent(); parent.toggleClass('expanded'); $('> div', parent).slideToggle(100); }); return form; }; /** * Retrieves the structured values object generated from the values * entered by the user and the data schema that gave birth to the form. * * Defined as a jQuery function that typically needs to be applied to * a element whose content has previously been generated by a * call to "jsonForm". * * Unless explicitly disabled, the values are automatically validated * against the constraints expressed in the schema. * * @function * @return {Object} Structured values object that matches the user inputs * and the data schema. */ $.fn.jsonFormValue = function() { return jsonform.getFormValue(this); }; // Expose the getFormValue method to the global object // (other methods exposed as jQuery functions) global.JSONForm = global.JSONForm || {util:{}}; global.JSONForm.getFormValue = jsonform.getFormValue; global.JSONForm.fieldTemplate = jsonform.fieldTemplate; global.JSONForm.fieldTypes = jsonform.elementTypes; global.JSONForm.getInitialValue = getInitialValue; global.JSONForm.util.getObjKey = jsonform.util.getObjKey; global.JSONForm.util.setObjKey = jsonform.util.setObjKey; })((typeof exports !== 'undefined'), ((typeof exports !== 'undefined') ? exports : window), ((typeof jQuery !== 'undefined') ? jQuery : { fn: {} }), ((typeof _ !== 'undefined') ? _ : null), JSON);