diff --git a/_pages/claims/css.html b/_pages/claims/css.html new file mode 100644 index 0000000..ba9cf5b --- /dev/null +++ b/_pages/claims/css.html @@ -0,0 +1,480 @@ +--- +excerpt: none +--- + +{% include head.html %} + + Skip to main content + {% include site-header.html %} +
+
+
+

File a claim

+
+

Claim information

+
+ Which of the following best describes the reason for your claim? (select one) +
+ +
+
+ +
+
+ +
+
+
+ +
+
+

Person claiming benefits

+
+ Name +
+ +
+
+ +
+
+ +
+
+
+ Mailing address +
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ Is any other person ready, willing, and able to provide care for the same period this person is claiming PFL benefits? +
+ +
+
+ +
+
+
+
+

Employment information

+

Benefit eligibility is primarily determined by the claimant’s employment history. Please provide information about the claimant’s current employment.

+
+ Employer +
+ +
+
+ Mailing address +
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ Do you plan on returning to work during your family leave period? +
+ +
+
+ +
+
+
+ Have you claimed or do you plan to claim workers’ compensation benefits for any portion of the period covered by this claim? +
+ +
+
+ +
+
+
+ May we disclose benefit payment information to your employer? +
+ +
+
+ +
+
+
+
+

Dependent

+

This information is only required for claimants who are either bonding with a child or caring for someone other than themselves.

+
+ Name +
+ +
+
+ +
+
+ +
+
+
+ Residence (if different from claimant) +
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ {% include form-handler.html %} + {% include site-footer.html %} + diff --git a/_pages/claims/new.html b/_pages/claims/new.html index ba9cf5b..500a9b5 100644 --- a/_pages/claims/new.html +++ b/_pages/claims/new.html @@ -3,478 +3,66 @@ --- {% include head.html %} + Skip to main content {% include site-header.html %} -
+
-
-

File a claim

-
-

Claim information

-
- Which of the following best describes the reason for your claim? (select one) -
- -
-
- -
-
- -
-
-
- -
-
-

Person claiming benefits

-
- Name -
- -
-
- -
-
- -
-
-
- Mailing address -
- -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
- -
-
- Is any other person ready, willing, and able to provide care for the same period this person is claiming PFL benefits? -
- -
-
- -
-
-
-
-

Employment information

-

Benefit eligibility is primarily determined by the claimant’s employment history. Please provide information about the claimant’s current employment.

-
- Employer -
- -
-
- Mailing address -
- -
-
-
- -
-
- -
-
- -
-
-
-
-
- -
-
- Do you plan on returning to work during your family leave period? -
- -
-
- -
-
-
- Have you claimed or do you plan to claim workers’ compensation benefits for any portion of the period covered by this claim? -
- -
-
- -
-
-
- May we disclose benefit payment information to your employer? -
- -
-
- -
-
-
-
-

Dependent

-

This information is only required for claimants who are either bonding with a child or caring for someone other than themselves.

-
- Name -
- -
-
- -
-
- -
-
-
- Residence (if different from claimant) -
- -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
- -
-
-
- -
-
- +

File a claim

+
+
{% include form-handler.html %} {% include site-footer.html %} + + + + diff --git a/_pages/claims/new/step-2.html b/_pages/claims/new/step-2.html deleted file mode 100644 index 3e0577a..0000000 --- a/_pages/claims/new/step-2.html +++ /dev/null @@ -1,726 +0,0 @@ ---- -excerpt: none ---- - -{% include head.html %} - - Skip to main content - {% include site-header.html %} -
-
-
-

Claim for Paid Family Leave (PFL) Benefits

-
- Claimant -
- Name -
- -
-
- -
-
- -
-
-
- Mailing address -
- -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
- -
-
- -
-
- -
-
- -
-
-
- Child -
- Name -
- -
-
- -
-
- -
-
-
- -
-
- -
-
- Gender -
- -
-
- -
-
- -
-
-
- -
-
- -
-
- Residence address (if different from claimant) -
- -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
-
- Physician -

Do not complete this part if you are bonding with a child.

-
- Name -
- -
-
- -
-
- -
-
-
- -
-
- Mailing address -
- -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
- -
-
- -
-
- -
-
- -
-
- Patient -
- Name -
- -
-
- -
-
- -
-
-
- -
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
- -
-
- -
-
- -
-
- Did you work or will you continue to work during your family leave period? -
- -
-
- -
-
-
- -
-
- Why did you or will you reduce your work hours? -
- -
-
- -
-
- -
-
-
- Is any other family member ready, willing, and able to provide care for the same period you are claiming PFL benefits? -
- -
-
- -
-
-
- Have you claimed or do you plan to claim workers’ compensation benefits for any portion of the period covered by this claim? -
- -
-
- -
-
-
- Do you have more than one employer? -
- -
-
- -
-
-
- If your employer(s) continued or will continue to pay you during your family leave, indicate type of pay: -
- -
-
- -
-
- -
-
-
- May we disclose benefit payment information to your employer? -
- -
-
- -
-
-
- At any time during PFL leave, were you in the custody of law enforcement authorities because you were convicted of violating a law or ordinance? -
- -
-
- -
-
- -
- -
- -
-
-
- {% include form-handler.html %} - {% include site-footer.html %} - diff --git a/javascripts/jsonform-defaults.js b/javascripts/jsonform-defaults.js new file mode 100755 index 0000000..fad62c1 --- /dev/null +++ b/javascripts/jsonform-defaults.js @@ -0,0 +1,331 @@ +/** + * @fileoverview The JSON Form "defaults" library exposes a setDefaultValues + * method that extends the object passed as argument so that it includes + * values for all required fields of the JSON schema it is to follow that + * define a default value. + * + * The library is called to complete the configuration settings of a template in + * the Factory and to complete datasource settings. + * + * The library is useless if the settings have already been validated against the + * schema using the JSON schema validator (typically, provided the validator is + * loaded, submitting the form created from the schema will raise an error when + * required properties are missing). + * + * Note the library does not validate the created object, it merely sets missing + * values to the default values specified in the schema. All other values may + * be invalid. + * + * Nota Bene: + * - in data-joshfire, the runtime/nodejs/lib/jsonform-defaults.js file is a + * symbolic link to the jsonform submodule in deps/jsonform + * - in platform-joshfire, the server/public/js/libs/jsonform-defaults.js file + * is a symbolic link to the jsonform submodule in deps/jsonform + */ + +(function () { + // Establish the root object: + // that's "window" in the browser, "global" in node.js + var root = this; + + /** + * Sets default values, ensuring that fields defined as "required" in the + * schema appear in the object. If missing, the hierarchy that leads to + * a required key is automatically created. + * + * @function + * @param {Object} obj The object to complete with default values according + * to the schema + * @param {Object} schema The JSON schema that the object follows + * @param {boolean} includeOptionalValues Include default values for fields + * that are not "required" + * @param {boolean} skipFieldsWithoutDefaultValue Set flag not to include a + * generated empty default value for fields marked as "required" in the + * schema but that do not define a default value. + * @return {Object} The completed object (same instance as obj) + */ + var setDefaultValues = function (obj, schema, includeOptionalValues, skipFieldsWithoutDefaultValue) { + if (!obj || !schema) return obj; + if (!schema.properties) { + schema = { properties: schema }; + } + + // Inner function that parses the schema recursively to build a flat + // list of defaults + var defaults = {}; + var extractDefaultValues = function (schemaItem, path) { + var properties = null; + var child = null; + + if (!schemaItem || (schemaItem !== Object(schemaItem))) return null; + + if (schemaItem.required) { + // Item is required + if (schemaItem['default']) { + // Item defines a default value, let's use it, + // no need to continue in that case, we have the default value + // for the whole subtree starting at schemaItem. + defaults[path] = schemaItem['default']; + return; + } + else if (skipFieldsWithoutDefaultValue) { + // Required but no default value and caller explicitly asked not + // include such fields in the returned object. + } + else if ((schemaItem.type === 'object') || schemaItem.properties) { + // Item is a required object + defaults[path] = {}; + } + else if ((schemaItem.type === 'array') || schemaItem.items) { + // Item is a required array + defaults[path] = []; + } + else if (schemaItem.type === 'string') { + defaults[path] = ''; + } + else if ((schemaItem.type === 'number') || (schemaItem.type === 'integer')) { + defaults[path] = 0; + } + else if (schemaItem.type === 'boolean') { + defaults[path] = false; + } + else { + // Unknown type, use an empty object by default + defaults[path] = {}; + } + } + else if (schemaItem['default'] && includeOptionalValues) { + // Item is not required but defines a default value and the + // include optional values flag is set, so let's use it. + // No need to continue in that case, we have the default value + // for the whole subtree starting at schemaItem. + defaults[path] = schemaItem['default']; + return; + } + + // Parse schema item's properties recursively + properties = schemaItem.properties; + if (properties) { + for (var key in properties) { + if (properties.hasOwnProperty(key)) { + extractDefaultValues(properties[key], path + '.' + key); + } + } + } + + // Parse schema item's children recursively + if (schemaItem.items) { + // Items may be a single item or an array composed of only one item + child = schemaItem.items; + if (_isArray(child)) { + child = child[0]; + } + + extractDefaultValues(child, path + '[]'); + } + }; + + // Build a flat list of default values + extractDefaultValues(schema, ''); + + // Ensure the object's default values are correctly set + for (var key in defaults) { + if (defaults.hasOwnProperty(key)) { + setObjKey(obj, key, defaults[key]); + } + } + }; + + + /** + * Retrieves the default value for the given key in the schema + * + * Levels in the path are separated by a dot. Array items are marked + * with []. For instance: + * foo.bar[].baz + * + * @function + * @param {Object} schema The schema to parse + * @param {String} key The path to the key whose default value we're + * looking for. each level is separated by a dot, and array items are + * flagged with [x]. + * @return {Object} The default value, null if not found. + */ + var getSchemaKeyDefaultValue = function(schema,key) { + var schemaKey = key + .replace(/\./g, '.properties.') + .replace(/\[.*\](\.|$)/g, '.items$1'); + var schemaDef = getObjKey(schema, schemaKey); + if (schemaDef) return schemaDef['default']; + return null; + }; + + /** + * 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 []. For instance: + * foo.bar[].baz + * + * @function + * @param {Object} obj The object to parse + * @param {String} key The path to the key whose default value we're + * looking for. each level is separated by a dot, and array items are + * flagged with [x]. + * @return {Object} The key definition, null if not found. + */ + var getObjKey = function (obj, key) { + var innerobj = obj; + var keyparts = key.split('.'); + var subkey = null; + var arrayMatch = null; + var reArraySingle = /\[([0-9]*)\](?:\.|$)/; + + for (var i = 0; i < keyparts.length; i++) { + if (typeof innerobj !== 'object') return null; + subkey = keyparts[i]; + arrayMatch = subkey.match(reArraySingle); + if (arrayMatch) { + // Subkey is part of an array + subkey = subkey.replace(reArraySingle, ''); + if (!_isArray(innerobj[subkey])) { + return null; + } + innerobj = innerobj[subkey][parseInt(arrayMatch[1], 10)]; + } + else { + innerobj = innerobj[subkey]; + } + } + + 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 []. For instance: + * foo.bar[].baz + * + * The hierarchy is automatically created if it does not exist yet. + * + * Default values are added to all array items. Array items are not + * automatically created if they do not exist (in particular, the + * minItems constraint is not enforced) + * + * @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. + */ + var setObjKey = function (obj, key, value) { + var keyparts = key.split('.'); + + // Recursive version of setObjKey + var recSetObjKey = function (obj, keyparts, value) { + var arrayMatch = null; + var reArray = /\[([0-9]*)\]$/; + var subkey = keyparts.shift(); + var idx = 0; + + if (keyparts.length > 0) { + // Not the end yet, build the hierarchy + arrayMatch = subkey.match(reArray); + if (arrayMatch) { + // Subkey is part of an array, check all existing array items + // TODO: review that! Only create the right item!!! + subkey = subkey.replace(reArray, ''); + if (!_isArray(obj[subkey])) { + obj[subkey] = []; + } + obj = obj[subkey]; + if (arrayMatch[1] !== '') { + idx = parseInt(arrayMatch[1], 10); + if (!obj[idx]) { + obj[idx] = {}; + } + recSetObjKey(obj[idx], keyparts, value); + } + else { + for (var k = 0; k < obj.length; k++) { + recSetObjKey(obj[k], keyparts, value); + } + } + return; + } + else { + // "Normal" subkey + if (typeof obj[subkey] !== 'object') { + obj[subkey] = {}; + } + obj = obj[subkey]; + recSetObjKey(obj, keyparts, value); + } + } + else { + // Last key, time to set the value, unless already defined + arrayMatch = subkey.match(reArray); + if (arrayMatch) { + subkey = subkey.replace(reArray, ''); + if (!_isArray(obj[subkey])) { + obj[subkey] = []; + } + idx = parseInt(arrayMatch[1], 10); + if (!obj[subkey][idx]) { + obj[subkey][idx] = value; + } + } + else if (!obj[subkey]) { + obj[subkey] = value; + } + } + }; + + // Skip first item if empty (key starts with a '.') + if (!keyparts[0]) { + keyparts.shift(); + } + recSetObjKey(obj, keyparts, value); + }; + + // Taken from Underscore.js (not included to save bytes) + var _isArray = Array.isArray || function (obj) { + return Object.prototype.toString.call(obj) == '[object Array]'; + }; + + + // Export the code as: + // 1. an AMD module (the "define" method exists in that case), or + // 2. a node.js module ("module.exports" is defined in that case), or + // 3. a global JSONForm object (using "root") + if (typeof define !== 'undefined') { + // AMD module + define([], function () { + return { + setDefaultValues: setDefaultValues, + setObjKey: setObjKey, + getSchemaKeyDefaultValue: getSchemaKeyDefaultValue + }; + }); + } + else if ((typeof module !== 'undefined') && module.exports) { + // Node.js module + module.exports = { + setDefaultValues: setDefaultValues, + setObjKey: setObjKey, + getSchemaKeyDefaultValue: getSchemaKeyDefaultValue + }; + } + else { + // Export the function to the global context, using a "string" for + // Google Closure Compiler "advanced" mode + // (not sure why it's needed, done by Underscore) + root['JSONForm'] = root['JSONForm'] || {}; + root['JSONForm'].setDefaultValues = setDefaultValues; + root['JSONForm'].setObjKey = setObjKey; + root['JSONForm'].getSchemaKeyDefaultValue = getSchemaKeyDefaultValue; + } +})(); \ No newline at end of file diff --git a/javascripts/jsonform-split.js b/javascripts/jsonform-split.js new file mode 100755 index 0000000..acc657a --- /dev/null +++ b/javascripts/jsonform-split.js @@ -0,0 +1,320 @@ +/** + * @fileoverview The JSON Form "split" library exposes a "split" method + * that can be used to divide a JSON Form object into two disjoint + * JSON Form objects: + * - the first one includes the schema and layout of the form that + * contains the list of keys given as parameters as well as keys that + * cannot be separated from them (typically because they belong to the + * same array in the layout) + * - the second one includes the schema and layout of a form that does not + * contain the list of keys given as parameters. + * + * The options parameter lets one be explicit about whether it wants to include + * fields that are tightly coupled with the provided list of keys or not. + */ +/*global exports, _*/ + +(function (serverside, global, _) { + if (serverside && !_) { + _ = require('underscore'); + } + + /** + * Splits a JSON Form object into two autonomous JSON Form objects, + * one that includes the provided list of schema keys as well as keys + * that are tightly coupled to these keys, and the other that does not + * include these keys. + * + * The function operates on the "schema", "form", and "value" properties + * of the initial JSON Form object. It copies over the other properties + * to the resulting JSON Form objects. + * + * Note that the split function does not support "*" form definitions. The + * "form" property must be set in the provided in the provided JSON Form + * object. + * + * @function + * @param {Object} jsonform JSON Form object with a "schema" and "form" + * @param {Array(String)} keys Schema keys used to split the form. Each + * key must reference a schema key at the first level in the schema + * (in other words, the keys cannot include "." or "[]") + * @param {Object} options Split options. Set the "excludeCoupledKeys" flag + * not to include keys that are tightly coupled with the ones provided in + * the included part of the JSON Form object. + * @return {Object} An object with an "included" property whose value is + * the JSON Form object that includes the keys and an "excluded" property + * whose value is the JSON Form object that does not contain any of the + * keys. These objects may be empty. + */ + var split = function (jsonform, keys, options) { + options = options || {}; + keys = keys || []; + if (!jsonform || !jsonform.form) { + return { + included: {}, + excluded: {} + }; + } + + if (_.isString(keys)) { + keys = [keys]; + } + + // Prepare the object that will be returned + var result = { + included: { + schema: { + properties: {} + }, + form: [] + }, + excluded: { + schema: { + properties: {} + }, + form: [] + } + }; + + // Copy over properties such as "value" or "tpldata" that do not need + // to be split (note both forms will reference the same structures) + _.each(jsonform, function (value, key) { + if ((key !== 'schema') && (key !== 'form')) { + result.included[key] = value; + result.excluded[key] = value; + } + }); + + + /** + * Helper function that parses the given field and returns true if + * it references one of the keys to include directly. Note the function + * does not parse the potential children of the field and will thus + * return false even if the field actually references a key to include + * indirectly. + * + * @function + * @param {Object} formField The form field to parse + * @return {boolean} true when the field references one of the keys to + * include, false when not + */ + var formFieldReferencesKey = function (formField) { + var referencedKey = _.isString(formField) ? + formField : + formField.key; + if (!referencedKey) { + return false; + } + return _.include(keys, referencedKey) || + !!_.find(keys, function (key) { + return (referencedKey.indexOf(key + '.') === 0) || + (referencedKey.indexOf(key + '[]') === 0); + }); + }; + + + /** + * Helper function that parses the given field and returns true if + * it references a key that is not in the list of keys to include. + * Note the function does not parse the potential children of the field + * and will thus return false even if the field actually references a key + * to include indirectly. + * + * @function + * @param {Object} formField The form field to parse + * @return {boolean} true when the field references one of the keys to + * include, false when not + */ + var formFieldReferencesOtherKey = function (formField) { + var referencedKey = _.isString(formField) ? + formField : + formField.key; + if (!referencedKey) { + return false; + } + return !_.include(keys, referencedKey) && + !_.find(keys, function (key) { + return (referencedKey.indexOf(key + '.') === 0) || + (referencedKey.indexOf(key + '[]') === 0); + }); + }; + + + /** + * Helper function that parses the given field and returns true if + * it references one of the keys to include somehow (either directly + * or through one of its descendants). + * + * @function + * @param {Object} formField The form field to parse + * @return {boolean} true when the field references one of the keys to + * include, false when not + */ + var includeFormField = function (formField) { + return formFieldReferencesKey(formField) || + formField.items && !!_.some(formField.items, function (item) { + return includeFormField(item); + }); + }; + + + /** + * Helper function that parses the given field and returns true if + * it references a key that is not one of the keys to include somehow + * (either directly or through one of its descendants). + * + * @function + * @param {Object} formField The form field to parse + * @return {boolean} true when the field references one of the keys to + * include, false when not + */ + var excludeFormField = function (formField) { + return formFieldReferencesOtherKey(formField) || + formField.items && !!_.some(formField.items, function (item) { + return excludeFormField(item); + }); + }; + + + /** + * Converts the provided form field for inclusion in the included/excluded + * portion of the result. The function returns null if the field should not + * appear in the relevant part. + * + * Note the function is recursive. + * + * @function + * @param {Object} formField The form field to convert + * @param {string} splitPart The targeted result part, one of "included", + * "excluded", or "all". The "all" string is used in recursions to force + * the inclusion of the field even if it does not reference one of the + * provided keys. + * @param {Object} parentField Pointer to the form field parent. This + * parameter is used in recursions to preserve direct children of a + * "selectfieldset". + * @return {Object} The converted field. + */ + var convertFormField = function (formField, splitPart, parentField) { + var convertedField = null; + + var keepField = formField.root || + (splitPart === 'all') || + (parentField && parentField.key && + (parentField.type === 'selectfieldset')) || + (formField.type && formField.type === 'help'); + if (!keepField) { + keepField = (splitPart === 'included') && includeFormField(formField); + } + if (!keepField) { + keepField = (splitPart === 'excluded') && excludeFormField(formField); + if (keepField && !options.excludeCoupledKeys) { + keepField = !includeFormField(formField); + } + } + if (!keepField) { + return null; + } + + var childPart = splitPart; + if ((childPart === 'included') && + !options.excludeCoupledKeys && + !formField.root) { + childPart = 'all'; + } + + // Make a shallow copy of the field since we will preserve all of its + // properties (save perhaps "items") + convertedField = _.clone(formField); + + // Recurse through the descendants of the field + if (convertedField.items) { + convertedField.items = _.map(convertedField.items, function (field) { + return convertFormField(field, childPart, convertedField); + }); + convertedField.items = _.compact(convertedField.items); + } + return convertedField; + }; + + + /** + * Helper function that checks the given schema key definition + * and returns true when the definition is referenced in the given + * form field definition + * + * @function + * @param {Object} formField The form field to check + * @param {string} schemaKey The key to search in the form field + * @return {boolean} true if the form field references the key somehow, + * false otherwise. + */ + var includeSchemaKey = function (formField, schemaKey) { + if (!formField) return false; + if (!schemaKey) return false; + + if (_.isString(formField)) { + // Direct reference to a key in the schema + return (formField === schemaKey) || + (formField.indexOf(schemaKey + '.') === 0) || + (formField.indexOf(schemaKey + '[]') === 0); + } + + if (formField.key) { + if ((formField.key === schemaKey) || + (formField.key.indexOf(schemaKey + '.') === 0) || + (formField.key.indexOf(schemaKey + '[]') === 0) + ) { + return true; + } + } + + return !!_.some(formField.items, function (item) { + return includeSchemaKey(item, schemaKey); + }); + }; + + + // Prepare the included/excluded forms + var converted = null; + converted = convertFormField({ + items: jsonform.form, + root: true + }, 'included'); + if (converted) { + result.included.form = converted.items; + } + converted = convertFormField({ + items: jsonform.form, + root: true + }, 'excluded'); + if (converted) { + result.excluded.form = converted.items; + } + + // Split the schema into two schemas. + // (note that the "excluded" JSON Form object may contain keys that + // are never referenced in the initial JSON Form layout. That's normal) + var schemaProperties = jsonform.schema; + if (schemaProperties.properties) { + schemaProperties = schemaProperties.properties; + } + _.each(schemaProperties, function (schemaDefinition, schemaKey) { + if (_.some(result.included.form, function (formField) { + return includeSchemaKey(formField, schemaKey); + })) { + result.included.schema.properties[schemaKey] = schemaDefinition; + } + else { + result.excluded.schema.properties[schemaKey] = schemaDefinition; + } + }); + + return result; + }; + + global.JSONForm = global.JSONForm || {}; + global.JSONForm.split = split; + +})((typeof exports !== 'undefined'), + ((typeof exports !== 'undefined') ? exports : window), + ((typeof _ !== 'undefined') ? _ : null)); diff --git a/javascripts/jsonform.js b/javascripts/jsonform.js new file mode 100755 index 0000000..e15e2b1 --- /dev/null +++ b/javascripts/jsonform.js @@ -0,0 +1,3591 @@ +/* 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)); + }; + + /** + * 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'); +}; + + +/** + * 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 '
' + + '<%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " jsonform-required" : "") %>' + + '<%= (node.readOnly ? " jsonform-readonly" : "") %>' + + '<%= (node.disabled ? " jsonform-disabled" : "") %>' + + '">' + + '<% if (node.title && !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.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": true, + "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...'); + } + }); + } + }, + 'radios':{ + 'template': '
<% _.each(node.options, function(key, val) { %> <% }); %>
', + 'fieldtemplate': true, + 'inputfield': true + }, + 'radiobuttons': { + 'template': '
' + + '<% _.each(node.options, function(key, val) { %>' + + ' ' + + '<% }); %>' + + '
', + 'fieldtempate': true, + 'inputfield': true, + 'onInsert': function (evt, node) { + var activeClass = 'active'; + var elt = node.formElement || {}; + if (elt.activeClass) { + activeClass += ' ' + elt.activeClass; + } + $(node.el).find('label').on('click', function () { + $(this).parent().find('label').removeClass(activeClass); + $(this).addClass(activeClass); + }); + } + }, + 'checkboxes':{ + 'template': '
<%= choiceshtml %>
', + 'fieldtemplate': true, + 'inputfield': true, + 'onBeforeRender': function (data, node) { + // Build up choices from the enumeration list + var choices = null; + var choiceshtml = null; + var template = ''; + if (!node || !node.schemaElement || !node.schemaElement.items) return; + choices = node.schemaElement.items['enum'] || + node.schemaElement.items[0]['enum']; + if (!choices) return; + + choiceshtml = ''; + _.each(choices, function (choice, idx) { + choiceshtml += _.template(template, { + name: node.key + '[' + idx + ']', + value: _.include(node.value, choice), + title: node.formElement.titleMap ? node.formElement.titleMap[choice] : choice, + node: node + }, fieldTemplateSettings); + }); + + data.choiceshtml = choiceshtml; + } + }, + 'array': { + 'template': '
' + + '' + + ' ' + + '' + + '' + + '
', + '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) { + 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) { %>
    <% } %>' + + '
    ' + }, + 'advancedfieldset': { + 'template': ' id="<%= id %>"<% } %>' + + ' class="expandable <%= elt.htmlClass?elt.htmlClass:"" %>">' + + 'Advanced options' + + '
    ' + + '<%= children %>' + + '
    ' + + '' + }, + 'authfieldset': { + 'template': ' id="<%= id %>"<% } %>' + + ' class="expandable <%= elt.htmlClass?elt.htmlClass:"" %>">' + + 'Authentication settings' + + '
    ' + + '<%= children %>' + + '
    ' + + '' + }, + '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 (node.title && !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]; + 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 %>' + + '
    ', + 'fieldtempate': true, + 'inputfield': true, + 'getElement': function (el) { + return $(el).parent().get(0); + }, + 'onInsert': function (evt, node) { + if (!node.children || (node.children.length === 0)) return; + _.each(node.children, function (child) { + $(child.el).hide(); + }); + $(node.children[0].el).show(); + } + }, + + /** + * A "question" field lets user choose a response among possible choices. + * The field is not associated with any schema key. A question should be + * part of a "questions" field that binds a series of questions to a + * schema key. + */ + 'question': { + 'template': '
    <% _.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 (!_.isArray(innerobj[prop])) return null; + innerobj = innerobj[prop][parseInt(arrayMatch[1], 10)]; + arrayMatch = reArray.exec(subkey); + if (!arrayMatch) break; + } + } + 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, tpldata, valueTemplateSettings); + } + } + + // Apply titleMap if needed + if (isSet(value) && formElement && + formElement.titleMap && + formElement.titleMap[value]) { + value = _.template(formElement.titleMap[value], + tpldata, valueTemplateSettings); + } + + // 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 = 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], formData, valueTemplateSettings); + } + } + else { + this[prop] = this.formElement[prop]; + } + }, this); + + // Apply templating to options created with "titleMap" as well + if (this.formElement.options) { + this.options = _.map(this.formElement.options, function (option) { + var title = null; + if (_.isObject(option) && option.title) { + // See a few lines above for more details about templating + // preparation here. + if (option.title.indexOf('{{values.') !== -1) { + title = option.title.replace( + /\{\{values\.([^\}]+)\}\}/g, + '{{getValue("$1")}}'); + } + else { + title = applyArrayPath(option.title, self.arrayPath); + } + return _.extend({}, option, { + value: (isSet(option.value) ? option.value : ''), + title: _.template(title, formData, valueTemplateSettings) + }); + } + else { + return option; + } + }); + } + } + + if (this.view && this.view.inputfield && this.schemaElement) { + // Case 1: simple input field + if (values) { + // Form has already been submitted, use former value if defined. + // Note we won't set the field to its default value otherwise + // (since the user has already rejected it) + if (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, formData, valueTemplateSettings); + } + } + this.defaultValue = true; + } + } + } + else if (this.view && this.view.array) { + // Case 2: array-like node + nbChildren = 0; + if (values) { + nbChildren = this.getPreviousNumberOfItems(values, this.arrayPath); + } + // TODO: use default values at the array level when form has not been + // submitted before. Note it's not that easy because each value may + // be a complex structure that needs to be pushed down the subtree. + // The easiest way is probably to generate a "values" object and + // compute initial values from that object + /* + else if (this.schemaElement['default']) { + nbChildren = this.schemaElement['default'].length; + } + */ + 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, formData, valueTemplateSettings); + break; + } + } + node = node.parentNode; + } + } +}; + + +/** + * Returns the number of items that the array node should have based on + * previously submitted values. + * + * The whole difficulty is that values may be hidden deep in the subtree + * of the node and may actually target different arrays in the JSON schema. + * + * @function + * @param {Object} values Previously submitted values + * @param {Array(Number)} arrayPath the array path we're interested in + * @return {Number} The number of items in the array + */ +formNode.prototype.getPreviousNumberOfItems = function (values, arrayPath) { + var key = null; + var arrayValue = null; + var childNumbers = null; + var idx = 0; + + if (!values) { + // No previously submitted values, no need to go any further + return 0; + } + + if (this.view.inputfield && this.schemaElement) { + // Case 1: node is a simple input field that links to a key in the schema. + // The schema key looks typically like: + // foo.bar[].baz.toto[].truc[].bidule + // The goal is to apply the array path and truncate the key to the last + // array we're interested in, e.g. with an arrayPath [4, 2]: + // foo.bar[4].baz.toto[2] + key = truncateToArrayDepth(this.formElement.key, arrayPath.length); + key = applyArrayPath(key, arrayPath); + arrayValue = jsonform.util.getObjKey(values, key); + if (!arrayValue) { + // No key? That means this field had been left empty + // in previous submit + return 0; + } + childNumbers = _.map(this.children, function (child) { + return child.getPreviousNumberOfItems(values, arrayPath); + }); + return _.max([_.max(childNumbers) || 0, arrayValue.length]); + } + else if (this.view.array) { + // Case 2: node is an array-like node, look for input fields + // in its child template + return this.childTemplate.getPreviousNumberOfItems(values, arrayPath); + } + else { + // Case 3: node is a leaf or a container, + // recurse through the list of children and return the maximum + // number of items found in each subtree + childNumbers = _.map(this.children, function (child) { + return child.getPreviousNumberOfItems(values, arrayPath); + }); + return _.max(childNumbers) || 0; + } +}; + + +/** + * Returns the structured object that corresponds to the form values entered + * by the user for the node's subtree. + * + * The returned object follows the structure of the JSON schema that gave + * birth to the form. + * + * Obviously, the node must have been rendered before that function may + * be called. + * + * @function + * @param {Array(Number)} updateArrayPath Array path to use to pretend that + * the entered values were actually entered for another item in an array + * (this is used to move values around when an item is inserted/removed/moved + * in an array) + * @return {Object} The object that follows the data schema and matches the + * values entered by the user. + */ +formNode.prototype.getFormValues = function (updateArrayPath) { + // The values object that will be returned + var values = {}; + + if (!this.el) { + throw new Error('formNode.getFormValues can only be called on nodes that are associated with a DOM element in the tree'); + } + + // Form fields values + var formArray = $(':input', this.el).serializeArray(); + + // 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, data, fieldTemplateSettings); + return html; +}; + + +/** + * Enhances the view with additional logic, binding event handlers + * in particular. + * + * The function also runs the "insert" event handler of the view and + * form element if they exist (starting with that of the view) + * + * @function + */ +formNode.prototype.enhance = function () { + var node = this; + var handlers = null; + var handler = null; + var formData = _.clone(this.ownerTree.formDesc.tpldata) || {}; + + if (this.formElement) { + // Check the view associated with the node as it may define an "onInsert" + // event handler to be run right away + if (this.view.onInsert) { + this.view.onInsert({ target: $(this.el) }, this); + } + + handlers = this.handlers || this.formElement.handlers; + + // Trigger the "insert" event handler + handler = this.onInsert || this.formElement.onInsert; + if (handler) { + handler({ target: $(this.el) }, this); + } + if (handlers) { + _.each(handlers, function (handler, onevent) { + if (onevent === 'insert') { + handler({ target: $(this.el) }, this); + } + }, this); + } + + // No way to register event handlers if the DOM element is unknown + // TODO: find some way to register event handlers even when this.el is not set. + if (this.el) { + + // Register specific event handlers + // TODO: Add support for other event handlers + if (this.onChange) + $(this.el).bind('change', function(evt) { node.onChange(evt, node); }); + if (this.view.onChange) + $(this.el).bind('change', function(evt) { node.view.onChange(evt, node); }); + if (this.formElement.onChange) + $(this.el).bind('change', function(evt) { node.formElement.onChange(evt, node); }); + + if (this.onClick) + $(this.el).bind('click', function(evt) { node.onClick(evt, node); }); + if (this.view.onClick) + $(this.el).bind('click', function(evt) { node.view.onClick(evt, node); }); + if (this.formElement.onClick) + $(this.el).bind('click', function(evt) { node.formElement.onClick(evt, node); }); + + if (this.onKeyUp) + $(this.el).bind('keyup', function(evt) { node.onKeyUp(evt, node); }); + if (this.view.onKeyUp) + $(this.el).bind('keyup', function(evt) { node.view.onKeyUp(evt, node); }); + if (this.formElement.onKeyUp) + $(this.el).bind('keyup', function(evt) { node.formElement.onKeyUp(evt, node); }); + + if (handlers) { + _.each(handlers, function (handler, onevent) { + if (onevent !== 'insert') { + $(this.el).bind(onevent, function(evt) { handler(evt, node); }); + } + }, this); + } + } + + // Auto-update legend based on the input field that's associated with it + if (this.legendChild && this.legendChild.formElement) { + $(this.legendChild.el).bind('keyup', function (evt) { + if (node.formElement && node.formElement.legend && node.parentNode) { + node.legend = applyArrayPath(node.formElement.legend, node.arrayPath); + formData.idx = (node.arrayPath.length > 0) ? + node.arrayPath[node.arrayPath.length-1] + 1 : + node.childPos + 1; + formData.value = $(evt.target).val(); + node.legend = _.template(node.legend, formData, valueTemplateSettings); + $(node.parentNode.el).trigger('legendUpdated'); + } + }); + } + } + + // Recurse down the tree to enhance children + _.each(this.children, function (child) { + child.enhance(); + }); +}; + + + +/** + * Inserts an item in the array at the requested position and renders the item. + * + * @function + * @param {Number} idx Insertion index + */ +formNode.prototype.insertArrayItem = function (idx, domElement) { + var i = 0; + + // Insert element at the end of the array if index is not given + if (idx === undefined) { + idx = this.children.length; + } + + // Create the additional array item at the end of the list, + // using the item template created when tree was initialized + // (the call to resetValues ensures that 'arrayPath' is correctly set) + var child = this.childTemplate.clone(); + this.appendChild(child); + child.resetValues(); + + // To create a blank array item at the requested position, + // shift values down starting at the requested position + // one to insert (note we start with the end of the array on purpose) + for (i = this.children.length-2; i >= idx; i--) { + this.children[i].moveValuesTo(this.children[i+1]); + } + + // Initialize the blank node we've created with default values + this.children[idx].resetValues(); + this.children[idx].computeInitialValues(); + + // Re-render all children that have changed + for (i = idx; i < this.children.length; i++) { + this.children[i].render(domElement); + } +}; + + +/** + * Remove an item from an array + * + * @function + * @param {Number} idx The index number of the item to remove + */ +formNode.prototype.deleteArrayItem = function (idx) { + var i = 0; + var child = null; + + // Delete last item if no index is given + if (idx === undefined) { + idx = this.children.length - 1; + } + + // Move values up in the array + for (i = idx; i < this.children.length-1; i++) { + this.children[i+1].moveValuesTo(this.children[i]); + this.children[i].render(); + } + + // Remove the last array item from the DOM tree and from the form tree + 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-' + formElement.key; + } + + // Should empty strings be included in the final value? + // TODO: it's rather unclean to pass it through the schema. + if (formElement.allowEmpty) { + schemaElement._jsonform_allowEmpty = true; + } + + // If the form element does not define its type, use the type of + // the schema element. + if (!formElement.type) { + if ((schemaElement.type === 'string') && + (schemaElement.format === 'color')) { + formElement.type = 'color'; + } else if ((schemaElement.type === 'number' || + schemaElement.type === 'integer' || + schemaElement.type === 'string' || + schemaElement.type === 'any') && + !schemaElement['enum']) { + formElement.type = 'text'; + } else if (schemaElement.type === 'boolean') { + formElement.type = 'checkbox'; + } else if (schemaElement.type === 'object') { + 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: 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(); + $('.expandable > legend', formElt).click(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); diff --git a/javascripts/schema.json b/javascripts/schema.json index 92b7f47..a1900ec 100644 --- a/javascripts/schema.json +++ b/javascripts/schema.json @@ -1,21 +1,296 @@ { - "type":"object", "title":"leave", + "type":"object", "$schema": "http://json-schema.org/draft-04/schema", - "properties":{ - "schema_version": { - "type":"string" + "properties": { + "information": { + "title": "Claim information", + "type": "object", + "properties": { + "reason": { + "title": "Which of the following best describes the reason for your claim? (select one)", + "type":"enum", + "enum":[ + "I need to bond with a newborn or newly adopted child.", + "I need to care for someone with a temporary disability or illness.", + "I need to manage my own recently diagnosed medical condition." + ], + "required":true + }, + "benefits_start": { + "title": "On what date will your claim start?", + "type":"date", + "required":true + } + } + }, + "claimant": { + "title": "Person claiming benefits", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "object", + "properties": { + "first": { + "title":"First", + "type":"string", + "required": true + }, + "middle": { + "title":"Middle", + "type":"string" + }, + "last": { + "title":"Last", + "type":"string", + "required": true + } + } + }, + "address": { + "title": "Mailing address", + "type": "object", + "properties": { + "street": { + "title":"Street address", + "type":"string", + "required": true + }, + "city": { + "title":"City", + "type":"string", + "required": true + }, + "state": { + "title":"State", + "type":"string", + "minLength": 2, + "maxLength": 2, + "pattern": "([A-Z]{2})", + "required": true + }, + "zip": { + "title":"Postal code", + "type":"string", + "minLength": 5, + "pattern": "[0-9]{5}(-([0-9]{4}))?", + "required": true + }, + "country": { + "title":"Country", + "type":"string", + "enum": [ + "United States", + "Other" + ], + "required": true + } + } + }, + "email": { + "title": "Email address", + "type": "string", + "format": "email", + "required": true + }, + "ssn": { + "title": "Social Security Number", + "type":"string", + "pattern":"(?!666|000|9\\d{2})([0-9]{3})([\\s-]?)(?!00)([0-9]{2})\\2(?!0{4})([0-9]{4})", + "required": true + }, + "birthdate": { + "title":"Date of birth", + "type":"date", + "required": true + }, + "language": { + "title":"Preferred language", + "type":"string", + "enum": [ + "English", + "Spanish", + "other" + ], + "default": "English", + "required": true + }, + "telephone": { + "title":"Telephone number", + "type":"string", + "required": true + }, + "alternative_caretaker": { + "title": "Is any other family member ready, willing, and able to provide care for the same period you are claiming benefits?", + "type":"string", + "enum":[ + "yes", + "no" + ], + "required":true + } + } }, - "conformsTo": { - "type":"string", - "format":"uri" + "employment": { + "title": "Employment information", + "type": "object", + "properties": { + "employer": { + "title": "Employer", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "address": { + "title": "Mailing address", + "type": "object", + "properties": { + "street": { + "title":"Street address", + "type":"string", + "required": true + }, + "city": { + "title":"City", + "type":"string", + "required": true + }, + "state": { + "title":"State", + "type":"string", + "minLength": 2, + "maxLength": 2, + "pattern": "([A-Z]{2})", + "required": true + }, + "zip": { + "title":"Postal code", + "type":"string", + "minLength": 5, + "pattern": "[0-9]{5}(-([0-9]{4}))?", + "required": true + } + } + } + } + }, + "benefits_end": { + "title": "On what date will you return to work?", + "type":"date", + "required":true + }, + "continue_work": { + "title": "Do you plan on returning to work during your family leave period?", + "type":"string", + "enum": [ + "yes", + "no" + ], + "required":true + }, + "workers-comp": { + "title": "Have you claimed or do you plan to claim workers’ compensation benefits for any portion of the period covered by this claim?", + "type":"string", + "enum":[ + "yes", + "no" + ], + "required":true + }, + "disclose": { + "title": "May we disclose benefit payment information to your employer?", + "type":"string", + "enum":[ + "Yes", + "No" + ] + } + } }, - "parties-requesting-agency-mailing-address-state": { - "type":"string", - "minLength": 2, - "maxLength": 2, - "pattern": "([A-Z]{2})" + "dependent": { + "title": "Dependent", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "object", + "properties": { + "first": { + "type":"string", + "title":"First", + "required": true + }, + "middle": { + "type":"string", + "title":"Middle" + }, + "last": { + "type":"string", + "title":"Last", + "required":true + } + } + }, + "address": { + "title": "Residence address (if different from claimant)", + "type": "object", + "properties": { + "street": { + "type":"string", + "required": true + }, + "city": { + "title":"City", + "type":"string", + "required": true + }, + "state": { + "title":"State", + "type":"string", + "minLength": 2, + "maxLength": 2, + "pattern": "([A-Z]{2})", + "required": true + }, + "zip": { + "title":"Postal code", + "type":"string", + "minLength": 5, + "pattern": "[0-9]{5}(-([0-9]{4}))?", + "required": true + }, + "country": { + "title":"Country", + "type":"string", + "enum": [ + "United States", + "Other" + ], + "required": true + } + } + }, + "relationship": { + "title": "Relationship", + "type":"string", + "enum": [ + "Family member (not child)", + "Friend", + "Child", + "Foster child", + "Other" + ], + "required": true + }, + "relationship_documentation": { + "title": "Documentation of Relationship", + "description": "Birth certificate, certificate of adoption, marriage license, or other documentary material.

    Alternately, take a photo of the document with your phone and text it from [[claimant.telephone]] to 55512.", + "type": "string" + } + } } - }, - "required":["state"] + } } diff --git a/javascripts/underscore.js b/javascripts/underscore.js new file mode 100755 index 0000000..f6f7e2f --- /dev/null +++ b/javascripts/underscore.js @@ -0,0 +1,1059 @@ +// Underscore.js 1.3.3 +// (c) 2009-2012 Jeremy Ashkenas, DocumentCloud Inc. +// Underscore is freely distributable under the MIT license. +// Portions of Underscore are inspired or borrowed from Prototype, +// Oliver Steele's Functional, and John Resig's Micro-Templating. +// For all details and documentation: +// http://documentcloud.github.com/underscore + +(function() { + + // Baseline setup + // -------------- + + // Establish the root object, `window` in the browser, or `global` on the server. + var root = this; + + // Save the previous value of the `_` variable. + var previousUnderscore = root._; + + // Establish the object that gets returned to break out of a loop iteration. + var breaker = {}; + + // Save bytes in the minified (but not gzipped) version: + var ArrayProto = Array.prototype, ObjProto = Object.prototype, FuncProto = Function.prototype; + + // Create quick reference variables for speed access to core prototypes. + var slice = ArrayProto.slice, + unshift = ArrayProto.unshift, + toString = ObjProto.toString, + hasOwnProperty = ObjProto.hasOwnProperty; + + // All **ECMAScript 5** native function implementations that we hope to use + // are declared here. + var + nativeForEach = ArrayProto.forEach, + nativeMap = ArrayProto.map, + nativeReduce = ArrayProto.reduce, + nativeReduceRight = ArrayProto.reduceRight, + nativeFilter = ArrayProto.filter, + nativeEvery = ArrayProto.every, + nativeSome = ArrayProto.some, + nativeIndexOf = ArrayProto.indexOf, + nativeLastIndexOf = ArrayProto.lastIndexOf, + nativeIsArray = Array.isArray, + nativeKeys = Object.keys, + nativeBind = FuncProto.bind; + + // Create a safe reference to the Underscore object for use below. + var _ = function(obj) { return new wrapper(obj); }; + + // Export the Underscore object for **Node.js**, with + // backwards-compatibility for the old `require()` API. If we're in + // the browser, add `_` as a global object via a string identifier, + // for Closure Compiler "advanced" mode. + if (typeof exports !== 'undefined') { + if (typeof module !== 'undefined' && module.exports) { + exports = module.exports = _; + } + exports._ = _; + } else { + root['_'] = _; + } + + // Current version. + _.VERSION = '1.3.3'; + + // Collection Functions + // -------------------- + + // The cornerstone, an `each` implementation, aka `forEach`. + // Handles objects with the built-in `forEach`, arrays, and raw objects. + // Delegates to **ECMAScript 5**'s native `forEach` if available. + var each = _.each = _.forEach = function(obj, iterator, context) { + if (obj == null) return; + if (nativeForEach && obj.forEach === nativeForEach) { + obj.forEach(iterator, context); + } else if (obj.length === +obj.length) { + for (var i = 0, l = obj.length; i < l; i++) { + if (i in obj && iterator.call(context, obj[i], i, obj) === breaker) return; + } + } else { + for (var key in obj) { + if (_.has(obj, key)) { + if (iterator.call(context, obj[key], key, obj) === breaker) return; + } + } + } + }; + + // Return the results of applying the iterator to each element. + // Delegates to **ECMAScript 5**'s native `map` if available. + _.map = _.collect = function(obj, iterator, context) { + var results = []; + if (obj == null) return results; + if (nativeMap && obj.map === nativeMap) return obj.map(iterator, context); + each(obj, function(value, index, list) { + results[results.length] = iterator.call(context, value, index, list); + }); + if (obj.length === +obj.length) results.length = obj.length; + return results; + }; + + // **Reduce** builds up a single result from a list of values, aka `inject`, + // or `foldl`. Delegates to **ECMAScript 5**'s native `reduce` if available. + _.reduce = _.foldl = _.inject = function(obj, iterator, memo, context) { + var initial = arguments.length > 2; + if (obj == null) obj = []; + if (nativeReduce && obj.reduce === nativeReduce) { + if (context) iterator = _.bind(iterator, context); + return initial ? obj.reduce(iterator, memo) : obj.reduce(iterator); + } + each(obj, function(value, index, list) { + if (!initial) { + memo = value; + initial = true; + } else { + memo = iterator.call(context, memo, value, index, list); + } + }); + if (!initial) throw new TypeError('Reduce of empty array with no initial value'); + return memo; + }; + + // The right-associative version of reduce, also known as `foldr`. + // Delegates to **ECMAScript 5**'s native `reduceRight` if available. + _.reduceRight = _.foldr = function(obj, iterator, memo, context) { + var initial = arguments.length > 2; + if (obj == null) obj = []; + if (nativeReduceRight && obj.reduceRight === nativeReduceRight) { + if (context) iterator = _.bind(iterator, context); + return initial ? obj.reduceRight(iterator, memo) : obj.reduceRight(iterator); + } + var reversed = _.toArray(obj).reverse(); + if (context && !initial) iterator = _.bind(iterator, context); + return initial ? _.reduce(reversed, iterator, memo, context) : _.reduce(reversed, iterator); + }; + + // Return the first value which passes a truth test. Aliased as `detect`. + _.find = _.detect = function(obj, iterator, context) { + var result; + any(obj, function(value, index, list) { + if (iterator.call(context, value, index, list)) { + result = value; + return true; + } + }); + return result; + }; + + // Return all the elements that pass a truth test. + // Delegates to **ECMAScript 5**'s native `filter` if available. + // Aliased as `select`. + _.filter = _.select = function(obj, iterator, context) { + var results = []; + if (obj == null) return results; + if (nativeFilter && obj.filter === nativeFilter) return obj.filter(iterator, context); + each(obj, function(value, index, list) { + if (iterator.call(context, value, index, list)) results[results.length] = value; + }); + return results; + }; + + // Return all the elements for which a truth test fails. + _.reject = function(obj, iterator, context) { + var results = []; + if (obj == null) return results; + each(obj, function(value, index, list) { + if (!iterator.call(context, value, index, list)) results[results.length] = value; + }); + return results; + }; + + // Determine whether all of the elements match a truth test. + // Delegates to **ECMAScript 5**'s native `every` if available. + // Aliased as `all`. + _.every = _.all = function(obj, iterator, context) { + var result = true; + if (obj == null) return result; + if (nativeEvery && obj.every === nativeEvery) return obj.every(iterator, context); + each(obj, function(value, index, list) { + if (!(result = result && iterator.call(context, value, index, list))) return breaker; + }); + return !!result; + }; + + // Determine if at least one element in the object matches a truth test. + // Delegates to **ECMAScript 5**'s native `some` if available. + // Aliased as `any`. + var any = _.some = _.any = function(obj, iterator, context) { + iterator || (iterator = _.identity); + var result = false; + if (obj == null) return result; + if (nativeSome && obj.some === nativeSome) return obj.some(iterator, context); + each(obj, function(value, index, list) { + if (result || (result = iterator.call(context, value, index, list))) return breaker; + }); + return !!result; + }; + + // Determine if a given value is included in the array or object using `===`. + // Aliased as `contains`. + _.include = _.contains = function(obj, target) { + var found = false; + if (obj == null) return found; + if (nativeIndexOf && obj.indexOf === nativeIndexOf) return obj.indexOf(target) != -1; + found = any(obj, function(value) { + return value === target; + }); + return found; + }; + + // Invoke a method (with arguments) on every item in a collection. + _.invoke = function(obj, method) { + var args = slice.call(arguments, 2); + return _.map(obj, function(value) { + return (_.isFunction(method) ? method || value : value[method]).apply(value, args); + }); + }; + + // Convenience version of a common use case of `map`: fetching a property. + _.pluck = function(obj, key) { + return _.map(obj, function(value){ return value[key]; }); + }; + + // Return the maximum element or (element-based computation). + _.max = function(obj, iterator, context) { + if (!iterator && _.isArray(obj) && obj[0] === +obj[0]) return Math.max.apply(Math, obj); + if (!iterator && _.isEmpty(obj)) return -Infinity; + var result = {computed : -Infinity}; + each(obj, function(value, index, list) { + var computed = iterator ? iterator.call(context, value, index, list) : value; + computed >= result.computed && (result = {value : value, computed : computed}); + }); + return result.value; + }; + + // Return the minimum element (or element-based computation). + _.min = function(obj, iterator, context) { + if (!iterator && _.isArray(obj) && obj[0] === +obj[0]) return Math.min.apply(Math, obj); + if (!iterator && _.isEmpty(obj)) return Infinity; + var result = {computed : Infinity}; + each(obj, function(value, index, list) { + var computed = iterator ? iterator.call(context, value, index, list) : value; + computed < result.computed && (result = {value : value, computed : computed}); + }); + return result.value; + }; + + // Shuffle an array. + _.shuffle = function(obj) { + var shuffled = [], rand; + each(obj, function(value, index, list) { + rand = Math.floor(Math.random() * (index + 1)); + shuffled[index] = shuffled[rand]; + shuffled[rand] = value; + }); + return shuffled; + }; + + // Sort the object's values by a criterion produced by an iterator. + _.sortBy = function(obj, val, context) { + var iterator = _.isFunction(val) ? val : function(obj) { return obj[val]; }; + return _.pluck(_.map(obj, function(value, index, list) { + return { + value : value, + criteria : iterator.call(context, value, index, list) + }; + }).sort(function(left, right) { + var a = left.criteria, b = right.criteria; + if (a === void 0) return 1; + if (b === void 0) return -1; + return a < b ? -1 : a > b ? 1 : 0; + }), 'value'); + }; + + // Groups the object's values by a criterion. Pass either a string attribute + // to group by, or a function that returns the criterion. + _.groupBy = function(obj, val) { + var result = {}; + var iterator = _.isFunction(val) ? val : function(obj) { return obj[val]; }; + each(obj, function(value, index) { + var key = iterator(value, index); + (result[key] || (result[key] = [])).push(value); + }); + return result; + }; + + // Use a comparator function to figure out at what index an object should + // be inserted so as to maintain order. Uses binary search. + _.sortedIndex = function(array, obj, iterator) { + iterator || (iterator = _.identity); + var low = 0, high = array.length; + while (low < high) { + var mid = (low + high) >> 1; + iterator(array[mid]) < iterator(obj) ? low = mid + 1 : high = mid; + } + return low; + }; + + // Safely convert anything iterable into a real, live array. + _.toArray = function(obj) { + if (!obj) return []; + if (_.isArray(obj)) return slice.call(obj); + if (_.isArguments(obj)) return slice.call(obj); + if (obj.toArray && _.isFunction(obj.toArray)) return obj.toArray(); + return _.values(obj); + }; + + // Return the number of elements in an object. + _.size = function(obj) { + return _.isArray(obj) ? obj.length : _.keys(obj).length; + }; + + // Array Functions + // --------------- + + // Get the first element of an array. Passing **n** will return the first N + // values in the array. Aliased as `head` and `take`. The **guard** check + // allows it to work with `_.map`. + _.first = _.head = _.take = function(array, n, guard) { + return (n != null) && !guard ? slice.call(array, 0, n) : array[0]; + }; + + // Returns everything but the last entry of the array. Especcialy useful on + // the arguments object. Passing **n** will return all the values in + // the array, excluding the last N. The **guard** check allows it to work with + // `_.map`. + _.initial = function(array, n, guard) { + return slice.call(array, 0, array.length - ((n == null) || guard ? 1 : n)); + }; + + // Get the last element of an array. Passing **n** will return the last N + // values in the array. The **guard** check allows it to work with `_.map`. + _.last = function(array, n, guard) { + if ((n != null) && !guard) { + return slice.call(array, Math.max(array.length - n, 0)); + } else { + return array[array.length - 1]; + } + }; + + // Returns everything but the first entry of the array. Aliased as `tail`. + // Especially useful on the arguments object. Passing an **index** will return + // the rest of the values in the array from that index onward. The **guard** + // check allows it to work with `_.map`. + _.rest = _.tail = function(array, index, guard) { + return slice.call(array, (index == null) || guard ? 1 : index); + }; + + // Trim out all falsy values from an array. + _.compact = function(array) { + return _.filter(array, function(value){ return !!value; }); + }; + + // Return a completely flattened version of an array. + _.flatten = function(array, shallow) { + return _.reduce(array, function(memo, value) { + if (_.isArray(value)) return memo.concat(shallow ? value : _.flatten(value)); + memo[memo.length] = value; + return memo; + }, []); + }; + + // Return a version of the array that does not contain the specified value(s). + _.without = function(array) { + return _.difference(array, slice.call(arguments, 1)); + }; + + // Produce a duplicate-free version of the array. If the array has already + // been sorted, you have the option of using a faster algorithm. + // Aliased as `unique`. + _.uniq = _.unique = function(array, isSorted, iterator) { + var initial = iterator ? _.map(array, iterator) : array; + var results = []; + // The `isSorted` flag is irrelevant if the array only contains two elements. + if (array.length < 3) isSorted = true; + _.reduce(initial, function (memo, value, index) { + if (isSorted ? _.last(memo) !== value || !memo.length : !_.include(memo, value)) { + memo.push(value); + results.push(array[index]); + } + return memo; + }, []); + return results; + }; + + // Produce an array that contains the union: each distinct element from all of + // the passed-in arrays. + _.union = function() { + return _.uniq(_.flatten(arguments, true)); + }; + + // Produce an array that contains every item shared between all the + // passed-in arrays. (Aliased as "intersect" for back-compat.) + _.intersection = _.intersect = function(array) { + var rest = slice.call(arguments, 1); + return _.filter(_.uniq(array), function(item) { + return _.every(rest, function(other) { + return _.indexOf(other, item) >= 0; + }); + }); + }; + + // Take the difference between one array and a number of other arrays. + // Only the elements present in just the first array will remain. + _.difference = function(array) { + var rest = _.flatten(slice.call(arguments, 1), true); + return _.filter(array, function(value){ return !_.include(rest, value); }); + }; + + // Zip together multiple lists into a single array -- elements that share + // an index go together. + _.zip = function() { + var args = slice.call(arguments); + var length = _.max(_.pluck(args, 'length')); + var results = new Array(length); + for (var i = 0; i < length; i++) results[i] = _.pluck(args, "" + i); + return results; + }; + + // If the browser doesn't supply us with indexOf (I'm looking at you, **MSIE**), + // we need this function. Return the position of the first occurrence of an + // item in an array, or -1 if the item is not included in the array. + // Delegates to **ECMAScript 5**'s native `indexOf` if available. + // If the array is large and already in sort order, pass `true` + // for **isSorted** to use binary search. + _.indexOf = function(array, item, isSorted) { + if (array == null) return -1; + var i, l; + if (isSorted) { + i = _.sortedIndex(array, item); + return array[i] === item ? i : -1; + } + if (nativeIndexOf && array.indexOf === nativeIndexOf) return array.indexOf(item); + for (i = 0, l = array.length; i < l; i++) if (i in array && array[i] === item) return i; + return -1; + }; + + // Delegates to **ECMAScript 5**'s native `lastIndexOf` if available. + _.lastIndexOf = function(array, item) { + if (array == null) return -1; + if (nativeLastIndexOf && array.lastIndexOf === nativeLastIndexOf) return array.lastIndexOf(item); + var i = array.length; + while (i--) if (i in array && array[i] === item) return i; + return -1; + }; + + // Generate an integer Array containing an arithmetic progression. A port of + // the native Python `range()` function. See + // [the Python documentation](http://docs.python.org/library/functions.html#range). + _.range = function(start, stop, step) { + if (arguments.length <= 1) { + stop = start || 0; + start = 0; + } + step = arguments[2] || 1; + + var len = Math.max(Math.ceil((stop - start) / step), 0); + var idx = 0; + var range = new Array(len); + + while(idx < len) { + range[idx++] = start; + start += step; + } + + return range; + }; + + // Function (ahem) Functions + // ------------------ + + // Reusable constructor function for prototype setting. + var ctor = function(){}; + + // Create a function bound to a given object (assigning `this`, and arguments, + // optionally). Binding with arguments is also known as `curry`. + // Delegates to **ECMAScript 5**'s native `Function.bind` if available. + // We check for `func.bind` first, to fail fast when `func` is undefined. + _.bind = function bind(func, context) { + var bound, args; + if (func.bind === nativeBind && nativeBind) return nativeBind.apply(func, slice.call(arguments, 1)); + if (!_.isFunction(func)) throw new TypeError; + args = slice.call(arguments, 2); + return bound = function() { + if (!(this instanceof bound)) return func.apply(context, args.concat(slice.call(arguments))); + ctor.prototype = func.prototype; + var self = new ctor; + var result = func.apply(self, args.concat(slice.call(arguments))); + if (Object(result) === result) return result; + return self; + }; + }; + + // Bind all of an object's methods to that object. Useful for ensuring that + // all callbacks defined on an object belong to it. + _.bindAll = function(obj) { + var funcs = slice.call(arguments, 1); + if (funcs.length == 0) funcs = _.functions(obj); + each(funcs, function(f) { obj[f] = _.bind(obj[f], obj); }); + return obj; + }; + + // Memoize an expensive function by storing its results. + _.memoize = function(func, hasher) { + var memo = {}; + hasher || (hasher = _.identity); + return function() { + var key = hasher.apply(this, arguments); + return _.has(memo, key) ? memo[key] : (memo[key] = func.apply(this, arguments)); + }; + }; + + // Delays a function for the given number of milliseconds, and then calls + // it with the arguments supplied. + _.delay = function(func, wait) { + var args = slice.call(arguments, 2); + return setTimeout(function(){ return func.apply(null, args); }, wait); + }; + + // Defers a function, scheduling it to run after the current call stack has + // cleared. + _.defer = function(func) { + return _.delay.apply(_, [func, 1].concat(slice.call(arguments, 1))); + }; + + // Returns a function, that, when invoked, will only be triggered at most once + // during a given window of time. + _.throttle = function(func, wait) { + var context, args, timeout, throttling, more, result; + var whenDone = _.debounce(function(){ more = throttling = false; }, wait); + return function() { + context = this; args = arguments; + var later = function() { + timeout = null; + if (more) func.apply(context, args); + whenDone(); + }; + if (!timeout) timeout = setTimeout(later, wait); + if (throttling) { + more = true; + } else { + result = func.apply(context, args); + } + whenDone(); + throttling = true; + return result; + }; + }; + + // Returns a function, that, as long as it continues to be invoked, will not + // be triggered. The function will be called after it stops being called for + // N milliseconds. If `immediate` is passed, trigger the function on the + // leading edge, instead of the trailing. + _.debounce = function(func, wait, immediate) { + var timeout; + return function() { + var context = this, args = arguments; + var later = function() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + if (immediate && !timeout) func.apply(context, args); + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + }; + + // Returns a function that will be executed at most one time, no matter how + // often you call it. Useful for lazy initialization. + _.once = function(func) { + var ran = false, memo; + return function() { + if (ran) return memo; + ran = true; + return memo = func.apply(this, arguments); + }; + }; + + // Returns the first function passed as an argument to the second, + // allowing you to adjust arguments, run code before and after, and + // conditionally execute the original function. + _.wrap = function(func, wrapper) { + return function() { + var args = [func].concat(slice.call(arguments, 0)); + return wrapper.apply(this, args); + }; + }; + + // Returns a function that is the composition of a list of functions, each + // consuming the return value of the function that follows. + _.compose = function() { + var funcs = arguments; + return function() { + var args = arguments; + for (var i = funcs.length - 1; i >= 0; i--) { + args = [funcs[i].apply(this, args)]; + } + return args[0]; + }; + }; + + // Returns a function that will only be executed after being called N times. + _.after = function(times, func) { + if (times <= 0) return func(); + return function() { + if (--times < 1) { return func.apply(this, arguments); } + }; + }; + + // Object Functions + // ---------------- + + // Retrieve the names of an object's properties. + // Delegates to **ECMAScript 5**'s native `Object.keys` + _.keys = nativeKeys || function(obj) { + if (obj !== Object(obj)) throw new TypeError('Invalid object'); + var keys = []; + for (var key in obj) if (_.has(obj, key)) keys[keys.length] = key; + return keys; + }; + + // Retrieve the values of an object's properties. + _.values = function(obj) { + return _.map(obj, _.identity); + }; + + // Return a sorted list of the function names available on the object. + // Aliased as `methods` + _.functions = _.methods = function(obj) { + var names = []; + for (var key in obj) { + if (_.isFunction(obj[key])) names.push(key); + } + return names.sort(); + }; + + // Extend a given object with all the properties in passed-in object(s). + _.extend = function(obj) { + each(slice.call(arguments, 1), function(source) { + for (var prop in source) { + obj[prop] = source[prop]; + } + }); + return obj; + }; + + // Return a copy of the object only containing the whitelisted properties. + _.pick = function(obj) { + var result = {}; + each(_.flatten(slice.call(arguments, 1)), function(key) { + if (key in obj) result[key] = obj[key]; + }); + return result; + }; + + // Fill in a given object with default properties. + _.defaults = function(obj) { + each(slice.call(arguments, 1), function(source) { + for (var prop in source) { + if (obj[prop] == null) obj[prop] = source[prop]; + } + }); + return obj; + }; + + // Create a (shallow-cloned) duplicate of an object. + _.clone = function(obj) { + if (!_.isObject(obj)) return obj; + return _.isArray(obj) ? obj.slice() : _.extend({}, obj); + }; + + // Invokes interceptor with the obj, and then returns obj. + // The primary purpose of this method is to "tap into" a method chain, in + // order to perform operations on intermediate results within the chain. + _.tap = function(obj, interceptor) { + interceptor(obj); + return obj; + }; + + // Internal recursive comparison function. + function eq(a, b, stack) { + // Identical objects are equal. `0 === -0`, but they aren't identical. + // See the Harmony `egal` proposal: http://wiki.ecmascript.org/doku.php?id=harmony:egal. + if (a === b) return a !== 0 || 1 / a == 1 / b; + // A strict comparison is necessary because `null == undefined`. + if (a == null || b == null) return a === b; + // Unwrap any wrapped objects. + if (a._chain) a = a._wrapped; + if (b._chain) b = b._wrapped; + // Invoke a custom `isEqual` method if one is provided. + if (a.isEqual && _.isFunction(a.isEqual)) return a.isEqual(b); + if (b.isEqual && _.isFunction(b.isEqual)) return b.isEqual(a); + // Compare `[[Class]]` names. + var className = toString.call(a); + if (className != toString.call(b)) return false; + switch (className) { + // Strings, numbers, dates, and booleans are compared by value. + case '[object String]': + // Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is + // equivalent to `new String("5")`. + return a == String(b); + case '[object Number]': + // `NaN`s are equivalent, but non-reflexive. An `egal` comparison is performed for + // other numeric values. + return a != +a ? b != +b : (a == 0 ? 1 / a == 1 / b : a == +b); + case '[object Date]': + case '[object Boolean]': + // Coerce dates and booleans to numeric primitive values. Dates are compared by their + // millisecond representations. Note that invalid dates with millisecond representations + // of `NaN` are not equivalent. + return +a == +b; + // RegExps are compared by their source patterns and flags. + case '[object RegExp]': + return a.source == b.source && + a.global == b.global && + a.multiline == b.multiline && + a.ignoreCase == b.ignoreCase; + } + if (typeof a != 'object' || typeof b != 'object') return false; + // Assume equality for cyclic structures. The algorithm for detecting cyclic + // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`. + var length = stack.length; + while (length--) { + // Linear search. Performance is inversely proportional to the number of + // unique nested structures. + if (stack[length] == a) return true; + } + // Add the first object to the stack of traversed objects. + stack.push(a); + var size = 0, result = true; + // Recursively compare objects and arrays. + if (className == '[object Array]') { + // Compare array lengths to determine if a deep comparison is necessary. + size = a.length; + result = size == b.length; + if (result) { + // Deep compare the contents, ignoring non-numeric properties. + while (size--) { + // Ensure commutative equality for sparse arrays. + if (!(result = size in a == size in b && eq(a[size], b[size], stack))) break; + } + } + } else { + // Objects with different constructors are not equivalent. + if ('constructor' in a != 'constructor' in b || a.constructor != b.constructor) return false; + // Deep compare objects. + for (var key in a) { + if (_.has(a, key)) { + // Count the expected number of properties. + size++; + // Deep compare each member. + if (!(result = _.has(b, key) && eq(a[key], b[key], stack))) break; + } + } + // Ensure that both objects contain the same number of properties. + if (result) { + for (key in b) { + if (_.has(b, key) && !(size--)) break; + } + result = !size; + } + } + // Remove the first object from the stack of traversed objects. + stack.pop(); + return result; + } + + // Perform a deep comparison to check if two objects are equal. + _.isEqual = function(a, b) { + return eq(a, b, []); + }; + + // Is a given array, string, or object empty? + // An "empty" object has no enumerable own-properties. + _.isEmpty = function(obj) { + if (obj == null) return true; + if (_.isArray(obj) || _.isString(obj)) return obj.length === 0; + for (var key in obj) if (_.has(obj, key)) return false; + return true; + }; + + // Is a given value a DOM element? + _.isElement = function(obj) { + return !!(obj && obj.nodeType == 1); + }; + + // Is a given value an array? + // Delegates to ECMA5's native Array.isArray + _.isArray = nativeIsArray || function(obj) { + return toString.call(obj) == '[object Array]'; + }; + + // Is a given variable an object? + _.isObject = function(obj) { + return obj === Object(obj); + }; + + // Is a given variable an arguments object? + _.isArguments = function(obj) { + return toString.call(obj) == '[object Arguments]'; + }; + if (!_.isArguments(arguments)) { + _.isArguments = function(obj) { + return !!(obj && _.has(obj, 'callee')); + }; + } + + // Is a given value a function? + _.isFunction = function(obj) { + return toString.call(obj) == '[object Function]'; + }; + + // Is a given value a string? + _.isString = function(obj) { + return toString.call(obj) == '[object String]'; + }; + + // Is a given value a number? + _.isNumber = function(obj) { + return toString.call(obj) == '[object Number]'; + }; + + // Is a given object a finite number? + _.isFinite = function(obj) { + return _.isNumber(obj) && isFinite(obj); + }; + + // Is the given value `NaN`? + _.isNaN = function(obj) { + // `NaN` is the only value for which `===` is not reflexive. + return obj !== obj; + }; + + // Is a given value a boolean? + _.isBoolean = function(obj) { + return obj === true || obj === false || toString.call(obj) == '[object Boolean]'; + }; + + // Is a given value a date? + _.isDate = function(obj) { + return toString.call(obj) == '[object Date]'; + }; + + // Is the given value a regular expression? + _.isRegExp = function(obj) { + return toString.call(obj) == '[object RegExp]'; + }; + + // Is a given value equal to null? + _.isNull = function(obj) { + return obj === null; + }; + + // Is a given variable undefined? + _.isUndefined = function(obj) { + return obj === void 0; + }; + + // Has own property? + _.has = function(obj, key) { + return hasOwnProperty.call(obj, key); + }; + + // Utility Functions + // ----------------- + + // Run Underscore.js in *noConflict* mode, returning the `_` variable to its + // previous owner. Returns a reference to the Underscore object. + _.noConflict = function() { + root._ = previousUnderscore; + return this; + }; + + // Keep the identity function around for default iterators. + _.identity = function(value) { + return value; + }; + + // Run a function **n** times. + _.times = function (n, iterator, context) { + for (var i = 0; i < n; i++) iterator.call(context, i); + }; + + // Escape a string for HTML interpolation. + _.escape = function(string) { + return (''+string).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\//g,'/'); + }; + + // If the value of the named property is a function then invoke it; + // otherwise, return it. + _.result = function(object, property) { + if (object == null) return null; + var value = object[property]; + return _.isFunction(value) ? value.call(object) : value; + }; + + // Add your own custom functions to the Underscore object, ensuring that + // they're correctly added to the OOP wrapper as well. + _.mixin = function(obj) { + each(_.functions(obj), function(name){ + addToWrapper(name, _[name] = obj[name]); + }); + }; + + // Generate a unique integer id (unique within the entire client session). + // Useful for temporary DOM ids. + var idCounter = 0; + _.uniqueId = function(prefix) { + var id = idCounter++; + return prefix ? prefix + id : id; + }; + + // By default, Underscore uses ERB-style template delimiters, change the + // following template settings to use alternative delimiters. + _.templateSettings = { + evaluate : /<%([\s\S]+?)%>/g, + interpolate : /<%=([\s\S]+?)%>/g, + escape : /<%-([\s\S]+?)%>/g + }; + + // When customizing `templateSettings`, if you don't want to define an + // interpolation, evaluation or escaping regex, we need one that is + // guaranteed not to match. + var noMatch = /.^/; + + // Certain characters need to be escaped so that they can be put into a + // string literal. + var escapes = { + '\\': '\\', + "'": "'", + 'r': '\r', + 'n': '\n', + 't': '\t', + 'u2028': '\u2028', + 'u2029': '\u2029' + }; + + for (var p in escapes) escapes[escapes[p]] = p; + var escaper = /\\|'|\r|\n|\t|\u2028|\u2029/g; + var unescaper = /\\(\\|'|r|n|t|u2028|u2029)/g; + + // Within an interpolation, evaluation, or escaping, remove HTML escaping + // that had been previously added. + var unescape = function(code) { + return code.replace(unescaper, function(match, escape) { + return escapes[escape]; + }); + }; + + // JavaScript micro-templating, similar to John Resig's implementation. + // Underscore templating handles arbitrary delimiters, preserves whitespace, + // and correctly escapes quotes within interpolated code. + _.template = function(text, data, settings) { + settings = _.defaults(settings || {}, _.templateSettings); + + // Compile the template source, taking care to escape characters that + // cannot be included in a string literal and then unescape them in code + // blocks. + var source = "__p+='" + text + .replace(escaper, function(match) { + return '\\' + escapes[match]; + }) + .replace(settings.escape || noMatch, function(match, code) { + return "'+\n_.escape(" + unescape(code) + ")+\n'"; + }) + .replace(settings.interpolate || noMatch, function(match, code) { + return "'+\n(" + unescape(code) + ")+\n'"; + }) + .replace(settings.evaluate || noMatch, function(match, code) { + return "';\n" + unescape(code) + "\n;__p+='"; + }) + "';\n"; + + // If a variable is not specified, place data values in local scope. + if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n'; + + source = "var __p='';" + + "var print=function(){__p+=Array.prototype.join.call(arguments, '')};\n" + + source + "return __p;\n"; + + var render = new Function(settings.variable || 'obj', '_', source); + if (data) return render(data, _); + var template = function(data) { + return render.call(this, data, _); + }; + + // Provide the compiled function source as a convenience for build time + // precompilation. + template.source = 'function(' + (settings.variable || 'obj') + '){\n' + + source + '}'; + + return template; + }; + + // Add a "chain" function, which will delegate to the wrapper. + _.chain = function(obj) { + return _(obj).chain(); + }; + + // The OOP Wrapper + // --------------- + + // If Underscore is called as a function, it returns a wrapped object that + // can be used OO-style. This wrapper holds altered versions of all the + // underscore functions. Wrapped objects may be chained. + var wrapper = function(obj) { this._wrapped = obj; }; + + // Expose `wrapper.prototype` as `_.prototype` + _.prototype = wrapper.prototype; + + // Helper function to continue chaining intermediate results. + var result = function(obj, chain) { + return chain ? _(obj).chain() : obj; + }; + + // A method to easily add functions to the OOP wrapper. + var addToWrapper = function(name, func) { + wrapper.prototype[name] = function() { + var args = slice.call(arguments); + unshift.call(args, this._wrapped); + return result(func.apply(_, args), this._chain); + }; + }; + + // Add all of the Underscore functions to the wrapper object. + _.mixin(_); + + // Add all mutator Array functions to the wrapper. + each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) { + var method = ArrayProto[name]; + wrapper.prototype[name] = function() { + var wrapped = this._wrapped; + method.apply(wrapped, arguments); + var length = wrapped.length; + if ((name == 'shift' || name == 'splice') && length === 0) delete wrapped[0]; + return result(wrapped, this._chain); + }; + }); + + // Add all accessor Array functions to the wrapper. + each(['concat', 'join', 'slice'], function(name) { + var method = ArrayProto[name]; + wrapper.prototype[name] = function() { + return result(method.apply(this._wrapped, arguments), this._chain); + }; + }); + + // Start chaining a wrapped Underscore object. + wrapper.prototype.chain = function() { + this._chain = true; + return this; + }; + + // Extracts the result from a wrapped and chained object. + wrapper.prototype.value = function() { + return this._wrapped; + }; + +}).call(this); diff --git a/stylesheets/form.css b/stylesheets/form.css new file mode 100644 index 0000000..db70176 --- /dev/null +++ b/stylesheets/form.css @@ -0,0 +1,505 @@ +/* @group widths */ + +input[name="claimant.ssn"], +input[type=date], +input[name="claimant.telephone"], +input[name="claimant.language"] label{ + max-width: 12em; +} + +input[type=submit] { + +} + +.field-select select{ + min-width: 0; + background: none; + padding-right: 24px; + overflow: hidden; + text-overflow: ellipsis; + display: block; + line-height: 1.2; + width: 100%; + +} + +.field-select{ + position: relative; + z-index: 1; +} + +.field-select::after{ + content: ""; + border: 6px solid black; + position: absolute; + right: 8px; + bottom: 0; + margin-bottom: 9px; + border-color: #aaa transparent transparent transparent; + z-index: -1; +} + +.field-select:hover select{ border-color: #aaa; } +.field-select:hover::after{ border-top-color: #888; } + +textarea{ + display: block; + width: 100%; + max-width: 40em; + min-height: 6em; + line-height: 1.25; + color: #333; +} + +button, +.button { + background: none; + border: 1px solid; + color: #2e8540; + font: inherit; + padding: 10px 12px; + margin: 1em 0 0; + line-height: 16px; + cursor: pointer; + text-decoration: none; + border-radius: 3px; + display: inline-block; + vertical-align: middle; + max-width: 128px; +} + +button:hover, +.button:hover { + background: #2e8540; + border-color: #2e8540; + color: white; + box-shadow: 0 1px 0 #f3f3f3; + text-decoration: none !important; +} + +button:active, +.button:active { + color: #7aa79d; + background: #1d5529; + border-color: #006e52; + box-shadow: inset 0 1px 0 rgba(0,0,0,0.1); +} + +.button-reject{ + border-color: #981b1e !important; + color: #981b1e; +} + +.button-reject:hover{ + background: #981b1e; + color: white; +} + +form a.a-return + h1{ + margin-top: 1em !important; +} + + +fieldset > p{ + font-size: 14px; + line-height: 1.4; + color: #444; + margin-top: 0; + margin-bottom: 1em; +} + + + +.jsonform-error-claimant---address---country .controls, +.jsonform-error-claimant---address---state .controls, +.jsonform-error-employment---employer---address---state .controls { + position: relative; + z-index: 1; +} + + div[class*="---address---country"] > .controls::after { + content: ""; + border: 6px solid black; + position: absolute; + right: 8px; + bottom: 0; + margin-bottom: 9px; + border-color: #aaa transparent transparent transparent; + z-index: 1; + } + + div[class*="---address---country"] > .controls > select { + min-width: 0; + background: white; + padding-right: 24px; + overflow: hidden; + text-overflow: ellipsis; + display: block; + line-height: 1.2; + width: 100%; + + } + + div[class*="---address---country"] > .controls:hover > select { + border-color: #aaa; + } + + div[class*="---address---country"] > .controls:hover::after { + border-top-color: #888; + } + +.description-text, +.p-description-text{ + margin-top: 8px; + font-size: 12px; + line-height: 1.3; + max-width: 28em; + display: block; + color: #333; +} + +.description-text a{ color: inherit; } +.description-text a:hover{ text-decoration: none; } + +div.field, +fieldset{ + margin: 0 auto 28px; + max-width: 640px; +} + +.field .help-text, +.p-customize-timeline{ + font-size: 14px; + line-height: 1.33em; + display: block; + margin-top: 8px; +} + +div { margin-bottom: 18px; } + +#jsonform-0-elt-claimant, +#jsonform-0-elt-child, +#jsonform-0-elt-employer, +#jsonform-0-elt-physician, +#jsonform-0-elt-physician fieldset.fieldset-patient{ + border: 1px solid #ccc; + padding: 16px; + padding-top: 0; + padding-bottom: 19px; + position: relative; + margin-top: 36px; + margin-bottom: 32px; + border-radius: 4px; +} + +:not(fieldset) > fieldset { + border-top: 3px solid; + counter-increment: claim-section; + position: relative; + margin-bottom: 2em; + background: #f8f8f8; + padding: 16px 16px 16px; + border-radius: 0 0 4px 4px; +} + + :not(fieldset) > fieldset > legend { + font-weight: 600; + font-size: 24px; + letter-spacing: normal; + padding-top: 8px; + margin-bottom: 24px; + padding-left: 16px; + } + + :not(fieldset) > fieldset > legend::before{ + content: counter(claim-section); + font-family: Merriweather; + font-style: italic; + font-size: 22px; + border: 3px solid; + width: 1.67em; + float: left; + margin-left: -1.67em; + margin-top: -3px; + margin-right: 0.5em; + border-radius: 4px 0 0 4px; + text-align: center; + line-height: 1.67em; + background: black; + border-color: black; + color: white; + border-top-right-radius: 0; + } + +.fieldset-sign-in label:first-child input{ + margin-bottom: 24px; +} + +fieldset div.field:last-of-type+fieldset{ + margin-top: 24px; + margin-bottom: 4px; +} + +fieldset > legend { + display: block; + max-width: 40em; + margin: 0 0 12px; + padding: 0; + position: static; + font: inherit; + line-height: 24px; + font-weight: 600; +} + +.field.is-disabled span.label-text { + color: #888; + cursor: not-allowed; +} + +.field.field-radio{ + display: inline-block; + vertical-align: middle; + margin-bottom: 6px; + margin-right: 0.4em; +} + +.field.field-radio label{ + display: inline-block; + padding: 8px 12px 8px 12px; + min-height: 32px; + line-height: 1; + vertical-align: middle; + border: 1px solid #ccc; + border-radius: 3px; +} + +.field.field-radio input, +.field.field-checkbox input{ margin-right: 0.4em; } + +.field input:focus, +.field select:focus, +.field textarea:focus, +.field.field-checkbox.is-focused label, +.field.field-radio.is-focused label{ + border-color: #1188ff; + box-shadow: 0 0 0 1px rgba(17,136,255,0.15); + outline: none; +} + +main div.field-select.is-focused::after{ + border-top-color: #1188ff; + +} + +div.field.field-checkbox span.label-text, +div.field.field-radio span.label-text, +div.field.field-radio input{ + display: inline; + vertical-align: middle; + font-weight: normal; +} + +div.field.field-checkbox{ + position: relative; + padding-left: 24px; + margin-bottom: 8px; +} + +div.field.field-checkbox input{ + font-size: 1em; + line-height: 1; + position: absolute; + left: 0; + top: 0; +} + + +div.field.field-checkbox+.is-branch{ + margin-top: -4px !important; + margin-left: 3px; + margin-bottom: 16px; +} + +div.field.is-error .label-text{ + color: #a73856; +} + +div.field.is-error input, +div.field.is-error textarea{ + border-color: #ce6246; +} + +div.field.is-error .error-text, +fieldset.fieldset-radio.is-error .error-text{ + color: #990017; + display: block; + padding-top: 12px; + font-style: italic; + font-size: 14px; +} + +div.field.is-error .error-text, +fieldset.fieldset-radio..is-error .error-text{ + color: #af1830; + font-size: 14px; + padding-top: 6px; + display: block; + font-style: italic; +} + +@media screen and (min-width: 533.33px) { + + fieldset.fieldset-date-range::after{ + content: ""; + display: table; + clear: both; + } + + fieldset.fieldset-date-range div.field{ + padding-bottom: 1.4em; + position: relative; + width: 50%; + float: left; + margin-bottom: 0; + } + + fieldset.fieldset-date-range span.label-text{ + position: absolute; + bottom: 0; + left: 0; + margin: 0; + font-size: 12px; + } + +} + +#jsonform-0-elt-claimant\.address fieldset, +#jsonform-0-elt-dependent\.address fieldset, +#jsonform-0-elt-employment\.employer\.address fieldset { + padding-bottom: 1.4em; + position: relative; +} + +#jsonform-0-elt-claimant\.address label { + font-size: 14px; +} + +#jsonform-0-elt-claimant\.address .jsonform-error-claimant---address---city, +#jsonform-0-elt-claimant\.address .jsonform-error-claimant---address---state, +#jsonform-0-elt-claimant\.address .jsonform-error-claimant---address---zip, +#jsonform-0-elt-dependent\.address .jsonform-error-dependent---address---city, +#jsonform-0-elt-dependent\.address .jsonform-error-dependent---address---state, +#jsonform-0-elt-dependent\.address .jsonform-error-dependent---address---zip, +#jsonform-0-elt-employment\.employer\.address .jsonform-error-employment---employer---address---city, +#jsonform-0-elt-employment\.employer\.address .jsonform-error-employment---employer---address---state, +#jsonform-0-elt-employment\.employer\.address .jsonform-error-employment---employer---address---zip { + float: left; + width: 25%; + padding-right: 0.67em; + margin-bottom: 0; +} + +#jsonform-0-elt-claimant\.address .jsonform-error-claimant---address---city, +#jsonform-0-elt-dependent\.address .jsonform-error-dependent---address---city, +#jsonform-0-elt-employment\.employer\.address .jsonform-error-employment---employer---address---city { + width: 50%; +} + +#jsonform-0-elt-claimant\.address #claimant\.address\.city, +#jsonform-0-elt-claimant\.address #claimant\.address\.state, +#jsonform-0-elt-claimant\.address #claimant\.address\.zip, +#jsonform-0-elt-dependent\.address #dependent\.address\.city, +#jsonform-0-elt-dependent\.address #dependent\.address\.state, +#jsonform-0-elt-dependent\.address #dependent\.address\.zip, +#jsonform-0-elt-employment\.employer\.address #employment\.employer\.address\.city, +#jsonform-0-elt-employment\.employer\.address #employment\.employer\.address\.state, +#jsonform-0-elt-employment\.employer\.address #employment\.employer\.address\.zip { + min-width: 0; +} + +#jsonform-0-elt-claimant\.address div.field.field-select::after, +#jsonform-0-elt-dependent\.address div.field.field-select::after, +#jsonform-0-elt-employment\.employer\.address div.field.field-select::after { + bottom: auto; + top: 16px; + right: 8px; +} + +#jsonform-0-elt-claimant\.address .jsonform-error-claimant---address---state::after, +#jsonform-0-elt-dependent\.address .jsonform-error-dependent---address---state::after, +#jsonform-0-elt-employment\.employer\.address .jsonform-error-employment---employer---address---state::after { + right: 20px; +} + +#jsonform-0-elt-claimant\.address .jsonform-error-claimant---address---country, +#jsonform-0-elt-dependent\.address .jsonform-error-dependent---address---country { + padding-top: 28px; + margin-bottom: 0; +} + +#jsonform-0-elt-claimant\.address .jsonform-error-claimant---address---country::after, +#jsonform-0-elt-dependent\.address .jsonform-error-dependent---address---country::after { + top: 44px; +} + +.field.field-diagnosis{ + margin-bottom: 0; +} + +/* @group layout-claims */ + +.layout-claims main{ + padding-top: 1em; +} + +.layout-claims main button{ + margin: 2em auto 1em; + display: block; + width: 640px; +} + +.layout-claims main button { + margin: 0; + font-size: 24px; + font-weight: 600; + padding: 12px 20px; + border-width: 2px; + margin-bottom: 2em; +} + +@media screen and (min-width: 711.111px){ + #jsonform-0-elt-claimant\.name, #jsonform-0-elt-dependent\.name { + margin-bottom: 1em; + } + } + #jsonform-0-elt-claimant\.name .control-group, + #jsonform-0-elt-dependent\.name .control-group { + width: 31.67%; + float: left; + margin-bottom: 0; + padding-bottom: 1.33em; + position: relative; + } + #jsonform-0-elt-claimant\.name .controls, + #jsonform-0-elt-dependent\.name .controls, + .fieldset-name .controls { + margin-right: 5%; + } + + #jsonform-0-elt-claimant\.name label, + #jsonform-0-elt-dependent\.name label { + font-size: 14px; + } + + .col-sign-in{ + width: 45%; + float: left; + } + .col-sign-up{ + width: 45%; + float: right; + } + +} + +/* @end */