diff --git a/gulpfile.coffee b/gulpfile.coffee index bc9b90f..89ec283 100644 --- a/gulpfile.coffee +++ b/gulpfile.coffee @@ -12,19 +12,21 @@ gulp.task 'lint', -> .pipe(coffeelint('./coffeelint.json')) .pipe(coffeelint.reporter()) -gulp.task 'compile', ['lint'], -> +gulp.task 'compile', gulp.series('lint', -> gulp.src(src) .pipe( coffee({bare:true}) .on 'error', console.log ) .pipe gulp.dest('lib') +) # runs all coffee tests in the test directory. # alternatively, specify --file to run a single file or alternate file pattern. -gulp.task 'test', ['compile'], -> +gulp.task 'test', gulp.series('compile', -> src = argv.file or 'test/**/*.coffee' gulp.src src .pipe mocha() - -gulp.task 'default', ['test'] \ No newline at end of file +) + +gulp.task 'default', gulp.series('test') \ No newline at end of file diff --git a/lib/building.js b/lib/building.js new file mode 100644 index 0000000..456b71a --- /dev/null +++ b/lib/building.js @@ -0,0 +1,246 @@ +var CoffeeScript, ModelGroup, Mustache, _, globals, jiff, throttledAlert, vm; + +CoffeeScript = require('coffee-script').register(); + +Mustache = require('mustache'); + +_ = require('underscore'); + +vm = require('vm'); + +jiff = require('jiff'); + +globals = require('./globals'); + +ModelGroup = require('./modelGroup'); + +globals = require('./globals'); + +if (typeof alert !== "undefined" && alert !== null) { + throttledAlert = _.throttle(alert, 500); +} + +/* + An array of functions that can test a built model. + Model code may add tests to this array during build. The tests themselves will not be run at the time, but are + made avaiable via this export so processes can run the tests when appropriate. + Tests may modify the model state, so the model should be rebuilt prior to running each test. +*/ +exports.modelTests = []; + +// Creates a Model object from JS code. The executed code will execute in a +// root ModelGroup +// code - model code +// data - initialization data (optional). Object or stringified object +// element - jquery element for firing validation events (optional) +// imports - object mapping {varname : model object}. May be referenced in form code +exports.fromCode = function(code, data, element, imports, isImport) { + var assert, emit, newRoot, test; + data = (function() { + switch (typeof data) { + case 'object': + return jiff.clone(data); //copy it + case 'string': + return JSON.parse(data); // 'undefined', 'null', and other unsupported types + default: + return {}; + } + })(); + globals.runtime = false; + exports.modelTests = []; + test = function(func) { + return exports.modelTests.push(func); + }; + assert = function(bool, message = "A model test has failed") { + if (!bool) { + return globals.handleError(message); + } + }; + emit = function(name, context) { + if (element && $) { + return element.trigger($.Event(name, context)); + } + }; + newRoot = new ModelGroup(); + //dont recalculate until model is done creating + newRoot.recalculating = false; + newRoot.recalculateCycle = function() {}; + (function(root) { //new scope for root variable name + var field, group, sandbox, validate; + field = newRoot.field.bind(newRoot); + group = newRoot.group.bind(newRoot); + root = newRoot.root; + validate = newRoot.validate; + //running in a vm is safer, but slower. Let the browser do plain eval, but not server. + if (typeof window === "undefined" || window === null) { + sandbox = { //hooks available in form code + field: field, + group: group, + root: root, + validate: validate, + data: data, + imports: imports, + test: test, + assert: assert, + Mustache: Mustache, + emit: emit, + _: _, + console: { //console functions don't break, but don't do anything + log: function() {}, + error: function() {} + }, + print: function() {} + }; + return vm.runInNewContext('"use strict";' + code, sandbox); + } else { + return eval('"use strict";' + code); + } + })(null); + newRoot.postBuild(); + globals.runtime = true; + newRoot.applyData(data); + newRoot.getChanges = exports.getChanges.bind(null, newRoot); + newRoot.setDirty(newRoot.id, 'multiple'); + newRoot.recalculateCycle = function() { + var results; + results = []; + while (!this.recalculating && this.dirty) { + this.recalculating = true; + this.recalculateRelativeProperties(); + results.push(this.recalculating = false); + } + return results; + }; + newRoot.recalculateCycle(); + newRoot.on('change:isValid', function() { + if (!isImport) { + return emit('validate', { + isValid: newRoot.isValid + }); + } + }); + newRoot.on('recalculate', function() { + if (!isImport) { + return emit('change'); + } + }); + newRoot.trigger('change:isValid'); + newRoot.trigger('recalculate'); + newRoot.styles = false; //don't render with well, etc. + return newRoot; +}; + +// CoffeeScript counterpart to fromCode. Compiles the given code to JS +// and passes it to fromCode. +exports.fromCoffee = function(code, data, element, imports, isImport) { + return exports.fromCode(CoffeeScript.compile(code), data, element, imports, isImport); +}; + +// Build a model from a package object, consisting of +// - formid (int or string) +// - forms (array of object). Each object contains +// - - formid (int) +// - - model (string) coffeescript model code +// - - imports (array of object). Each object contains +// - - - importformid (int) +// - - - namespace (string) +// - data (object, optional) +// data may also be supplied as the second parameter to the function. Data in this parameter +// will override any matching keys provided in the package data +// element to which to bind validation and change messages, also optional +exports.fromPackage = function(pkg, data, element) { + var buildModelWithRecursiveImports; + buildModelWithRecursiveImports = function(p, el, isImport) { + var buildImport, builtImports, f, form; + form = ((function() { + var i, len, ref, results; + ref = p.forms; + results = []; + for (i = 0, len = ref.length; i < len; i++) { + f = ref[i]; + if (f.formid === p.formid) { + results.push(f); + } + } + return results; + })())[0]; + if (form == null) { + return; + } + builtImports = {}; + buildImport = function(impObj) { + return builtImports[impObj.namespace] = buildModelWithRecursiveImports({ + formid: impObj.importformid, + data: data, + forms: p.forms + }, element, true); + }; + if (form.imports) { //in case imports left off the package + form.imports.forEach(buildImport); + } + return exports.fromCoffee(form.model, data, el, builtImports, isImport); + }; + if (typeof pkg.formid === 'string') { + pkg.formid = parseInt(pkg.formid); + } + //data could be in the package and/or as a separate parameter. Extend them together. + data = _.extend(pkg.data || {}, data || {}); + return buildModelWithRecursiveImports(pkg, element, false); +}; + +exports.getChanges = function(modelAfter, beforeData) { + var after, before, changedPath, changedPaths, changedPathsUniqObject, changedPathsUnique, changes, i, internalPatch, j, key, len, len1, modelBefore, outputPatch, p, path, val; + modelBefore = modelAfter.cloneModel(); + modelBefore.applyData(beforeData, true); + // This patch is parsed and used to generate the changes + internalPatch = jiff.diff(modelBefore.buildOutputData(void 0, true), modelAfter.buildOutputData(void 0, true), { + invertible: false + }); + // This is the actual patch + outputPatch = jiff.diff(modelBefore.buildOutputData(), modelAfter.buildOutputData(), { + invertible: false + }); + //array paths end in an index #. We only want the field, not the index of the value + changedPaths = (function() { + var i, len, results; + results = []; + for (i = 0, len = internalPatch.length; i < len; i++) { + p = internalPatch[i]; + results.push(p.path.replace(/\/[0-9]+$/, '')); + } + return results; + })(); + //get distinct field names. Arrays for example might appear multiple times + changedPathsUniqObject = {}; + for (i = 0, len = changedPaths.length; i < len; i++) { + val = changedPaths[i]; + changedPathsUniqObject[val] = val; + } + changedPathsUnique = (function() { + var results; + results = []; + for (key in changedPathsUniqObject) { + results.push(key); + } + return results; + })(); + changes = []; + for (j = 0, len1 = changedPathsUnique.length; j < len1; j++) { + changedPath = changedPathsUnique[j]; + path = changedPath.slice(1); + before = modelBefore.child(path); + after = modelAfter.child(path); + if (!_.isEqual(before != null ? before.value : void 0, after != null ? after.value : void 0)) { //deep equality for non-primitives + changes.push({ + name: changedPath, + title: after.title, + before: before.buildOutputData(void 0, true), + after: after.buildOutputData(void 0, true) + }); + } + } + return { + changes: changes, + patch: outputPatch + }; +}; diff --git a/lib/formbuilder.js b/lib/formbuilder.js new file mode 100644 index 0000000..915d8b8 --- /dev/null +++ b/lib/formbuilder.js @@ -0,0 +1,54 @@ +var Backbone, ModelBase, building, globals; + +if (typeof window !== "undefined" && window !== null) { + window.formbuilder = exports; +} + +Backbone = require('backbone'); + +ModelBase = require('./modelBase'); + +building = require('./building'); + +globals = require('./globals'); + +exports.fromCode = building.fromCode; + +exports.fromCoffee = building.fromCoffee; + +exports.fromPackage = building.fromPackage; + +exports.getChanges = building.getChanges; + +exports.mergeData = globals.mergeData; + +// Apply initialization data to the model. +exports.applyData = function(modelObject, inData, clear, purgeDefaults) { + return modelObject.applyData(inData, clear, purgeDefaults); +}; + +//Call this method before output data is needed. +exports.buildOutputData = function(model) { + return model.buildOutputData(); +}; + +Object.defineProperty(exports, 'modelTests', { + get: function() { + return building.modelTests; + } +}); + + +// We want users to be able to set a new handleError function. Rather than setting this +// module's handleError function to the current value in global.handleError, we make the +// setter overwrite the function reference in globals rather than the function reference +// in this file. +Object.defineProperty(exports, 'handleError', { + get: function() { + return globals.handleError; + }, + set: function(f) { + return globals.handleError = f; + }, + enumerable: true +}); diff --git a/lib/globals.js b/lib/globals.js new file mode 100644 index 0000000..3e87f92 --- /dev/null +++ b/lib/globals.js @@ -0,0 +1,40 @@ +//Things that are shared among many components, but we don't necessarily want to include in the base class. +module.exports = { + runtime: false, + // Determine what to do in the case of any error, including during compile, build and dynamic function calls. + // Any client may overwrite this method to handle errors differently, for example displaying them to the user + handleError: function(err) { + if (!(err instanceof Error)) { + err = new Error(err); + } + throw err; + }, + makeErrorMessage: function(model, propName, err) { + var nameStack, node, stack; + stack = []; + node = model; + while (node.name != null) { + stack.push(node.name); + node = node.parent; + } + stack.reverse(); + nameStack = stack.join('.'); + return `The '${propName}' function belonging to the field named '${nameStack}' threw an error with the message '${err.message}'`; + }, + // Merge data objects together. + // Modifies and returns the first parameter + mergeData: function(a, b) { + var key, value; + if ((b != null ? b.constructor : void 0) === Object) { + for (key in b) { + value = b[key]; + if ((a[key] != null) && a[key].constructor === Object && (value != null ? value.constructor : void 0) === Object) { + module.exports.mergeData(a[key], value); + } else { + a[key] = value; + } + } + } + return a; + } +}; diff --git a/lib/modelBase.js b/lib/modelBase.js new file mode 100644 index 0000000..fdd0730 --- /dev/null +++ b/lib/modelBase.js @@ -0,0 +1,385 @@ + /* + * Attributes common to groups and fields. + */ + /* Some properties may be booleans or functions that return booleans + Use this function to determine final boolean value. + prop - the property to evaluate, which may be something primitive or a function + deflt - the value to return if the property is undefined + */ +var Backbone, ModelBase, Mustache, _, getBoolOrFunctionResult, globals, moment, newid, + indexOf = [].indexOf; + +Backbone = require('backbone'); + +_ = require('underscore'); + +globals = require('./globals'); + +moment = require('moment'); + +Mustache = require('mustache'); + +// generate a new, unqiue identifier. Mostly good for label. +newid = (function() { + var incId; + incId = 0; + return function() { + incId++; + return `fbid_${incId}`; + }; +})(); + +getBoolOrFunctionResult = function(prop, deflt = true) { + if (typeof prop === 'function') { + return !!prop(); + } + if (prop === void 0) { + return deflt; + } + return !!prop; +}; + +module.exports = ModelBase = (function() { + class ModelBase extends Backbone.Model { + initialize() { + var key, ref, val; + this.setDefault('visible', true); + this.set('isVisible', true); + this.setDefault('disabled', false); + this.set('isDisabled', false); + this.setDefault('onChangePropertiesHandlers', []); + this.set('id', newid()); + this.setDefault('parent', void 0); + this.setDefault('root', void 0); + this.setDefault('name', this.get('title')); + this.setDefault('title', this.get('name')); + ref = this.attributes; + //add accessors for each name access instead of get/set + for (key in ref) { + val = ref[key]; + ((key) => { + return Object.defineProperty(this, key, { + get: function() { + return this.get(key); + }, + set: function(newValue) { + if ((this.get(key)) !== newValue) { //save an onChange event if value isnt different + return this.set(key, newValue); + } + } + }); + })(key); + } + this.bindPropFunctions('visible'); + this.bindPropFunctions('disabled'); + this.makePropArray('onChangePropertiesHandlers'); + this.bindPropFunctions('onChangePropertiesHandlers'); + // Other fields may need to update visibility, validity, etc when this field changes. + // Fire an event on change, and catch those events fired by others. + return this.on('change', function() { + var ch, changeFunc, i, len, ref1; + if (!globals.runtime) { + return; + } + ref1 = this.onChangePropertiesHandlers; + // model onChangePropertiesHandlers functions + for (i = 0, len = ref1.length; i < len; i++) { + changeFunc = ref1[i]; + changeFunc(); + } + ch = this.changedAttributes(); + if (ch === false) { //no changes, manual trigger meant to fire everything + ch = 'multiple'; + } + this.root.setDirty(this.id, ch); + return this.root.recalculateCycle(); + }); + } + + postBuild() {} + + setDefault(field, val) { + if (this.get(field) == null) { + return this.set(field, val); + } + } + + text(message) { + return this.field(message, { + type: 'info' + }); + } + + //note: doesn't set the variable locally, just creates a bound version of it + bindPropFunction(propName, func) { + var model; + model = this; + return function() { + var err, message; + try { + if (this instanceof ModelBase) { + model = this; + } + return func.apply(model, arguments); + } catch (error) { + err = error; + message = globals.makeErrorMessage(model, propName, err); + return globals.handleError(message); + } + }; + } + + // bind properties that are functions to this object's context. Single functions or arrays of functions + bindPropFunctions(propName) { + var i, index, ref, results; + if (Array.isArray(this[propName])) { + results = []; + for (index = i = 0, ref = this[propName].length; (0 <= ref ? i < ref : i > ref); index = 0 <= ref ? ++i : --i) { + results.push(this[propName][index] = this.bindPropFunction(propName, this[propName][index])); + } + return results; + } else if (typeof this[propName] === 'function') { + return this.set(propName, this.bindPropFunction(propName, this[propName]), { + silent: true + }); + } + } + + // ensure a property is array type, for when a single value is supplied where an array is needed. + makePropArray(propName) { + if (!Array.isArray(this.get(propName))) { + return this.set(propName, [this.get(propName)]); + } + } + + // convert list of params, either object(s) or positional strings (or both), into an object + // and add a few common properties + // assumes always called by creator of child objects, and thus sets parent to this + buildParamObject(params, paramPositions) { + var i, key, len, param, paramIndex, paramObject, ref, val; + paramObject = {}; + paramIndex = 0; + for (i = 0, len = params.length; i < len; i++) { + param = params[i]; + if (((ref = typeof param) === 'string' || ref === 'number' || ref === 'boolean') || Array.isArray(param)) { + paramObject[paramPositions[paramIndex++]] = param; + } else if (Object.prototype.toString.call(param) === '[object Object]') { + for (key in param) { + val = param[key]; + paramObject[key] = val; + } + } + } + paramObject.parent = this; //not a param, but common to everything that uses this method + paramObject.root = this.root; + return paramObject; + } + + // set the dirty flag according to an object with all current changes + // or, whatChanged could be a string to set as the dirty value + setDirty(id, whatChanged) { + var ch, drt, keys; + ch = typeof whatChanged === 'string' ? whatChanged : (keys = Object.keys(whatChanged), keys.length === 1 ? `${id}:${keys[0]}` : 'multiple'); + drt = this.dirty === ch || this.dirty === '' ? ch : "multiple"; + return this.dirty = drt; + } + + setClean() { + return this.dirty = ''; + } + + shouldCallTriggerFunctionFor(dirty, attrName) { + return dirty && dirty !== `${this.id}:${attrName}`; + } + + // Any local properties that may need to recalculate if a foreign field changes. + recalculateRelativeProperties() { + var dirty; + dirty = this.dirty; + this.setClean(); + // visibility + if (this.shouldCallTriggerFunctionFor(dirty, 'isVisible')) { + this.isVisible = getBoolOrFunctionResult(this.visible); + } + + // disabled status + if (this.shouldCallTriggerFunctionFor(dirty, 'isDisabled')) { + this.isDisabled = getBoolOrFunctionResult(this.disabled, false); + } + return this.trigger('recalculate'); + } + + // Add a new change properties handler to this object. + // This change itself will trigger on change properties functions to run, including the just-added one! + // If this trigger is not desired, set the second property to false + onChangeProperties(f, trigger = true) { + this.onChangePropertiesHandlers.push(this.bindPropFunction('onChangeProperties', f)); + if (trigger) { + this.trigger('change'); + } + return this; + } + + //Deep copy this backbone model by creating a new one with the same attributes. + //Overwrite each root attribute with the new root in the cloning form. + cloneModel(newRoot = this.root, constructor = this.constructor, excludeAttributes = []) { + var childClone, filteredAttributes, i, key, len, modelObj, myClone, newVal, ref, ref1, val; + // first filter out undesired attributes from the clone + filteredAttributes = {}; + ref = this.attributes; + for (key in ref) { + val = ref[key]; + if (indexOf.call(excludeAttributes, key) < 0) { + filteredAttributes[key] = val; + } + } + + // now call the constructor with the desired attributes + myClone = new constructor(filteredAttributes); + ref1 = myClone.attributes; + //some attributes need to be deep copied + for (key in ref1) { + val = ref1[key]; + //attributes that are form model objects need to themselves be cloned + if (key === 'root') { + myClone.set(key, newRoot); + } else if (val instanceof ModelBase && (key !== 'root' && key !== 'parent')) { + myClone.set(key, val.cloneModel(newRoot)); + } else if (Array.isArray(val)) { + newVal = []; + //array of form model objects, each needs to be cloned. Don't clone value objects + if (val[0] instanceof ModelBase && key !== 'value') { + for (i = 0, len = val.length; i < len; i++) { + modelObj = val[i]; + childClone = modelObj.cloneModel(newRoot); + //and if children/options are cloned, update their parent to this new object + if (childClone.parent === this) { + childClone.parent = myClone; + if (key === 'options' && childClone.selected) { + myClone.addOptionValue(childClone.value); + } + } + newVal.push(childClone); + } + } else { + newVal = _.clone(val); + } + myClone.set(key, newVal); + } + } + return myClone; + } + + }; + + ModelBase.prototype.modelClassName = 'ModelBase'; + + ModelBase.prototype.dirty = ''; //do as a local string not attribute so it is not included in @changed + + // Built-in functions for checking validity. + ModelBase.prototype.validate = { + required: function(value = this.value || '') { + if (((function() { + switch (typeof value) { + case 'number': + case 'boolean': + return false; //these types cannot be empty + case 'string': + return value.length === 0; + case 'object': + return Object.keys(value).length === 0; + default: + return true; //null, undefined + } + })())) { + return "This field is required"; + } + }, + minLength: function(n) { + return function(value = this.value || '') { + if (value.length < n) { + return `Must be at least ${n} characters long`; + } + }; + }, + maxLength: function(n) { + return function(value = this.value || '') { + if (value.length > n) { + return `Can be at most ${n} characters long`; + } + }; + }, + number: function(value = this.value || '') { + if (isNaN(+value)) { + return "Must be an integer or decimal number. (ex. 42 or 1.618)"; + } + }, + date: function(value = this.value || '', format = this.format) { + if (value === '') { + return; + } + if (!moment(value, format, true).isValid()) { + return `Not a valid date or does not match the format ${format}`; + } + }, + email: function(value = this.value || '') { + if (!value.match(/^[a-z0-9!\#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!\#$%&'*+\/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/)) { + return "Must be a valid email"; + } + }, + url: function(value = this.value || '') { + if (!value.match(/^(([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)\#?(?:[\w]*))?$/)) { + return "Must be a URL"; + } + }, + dollars: function(value = this.value || '') { + if (!value.match(/^\$(\d+\.\d\d|\d+)$/)) { + return "Must be a dollar amount (ex. $3.99)"; + } + }, + minSelections: function(n) { + return function(value = this.value || '') { + if (value.length < n) { + return `Please select at least ${n} options`; + } + }; + }, + maxSelections: function(n) { + return function(value = this.value || '') { + if (value.length > n) { + return `Please select at most ${n} options`; + } + }; + }, + selectedIsVisible: function(field = this) { + var i, len, opt, ref; + ref = field.options; + for (i = 0, len = ref.length; i < len; i++) { + opt = ref[i]; + if (opt.selected && !opt.isVisible) { + return "A selected option is not currently available. Please make a new choice from available options."; + } + } + }, + template: function() { //ensure the template field contains valid mustache + var e, template; + if (!this.template) { + return; + } + if (typeof this.template === 'object') { + template = this.template.value; + } else { + template = this.parent.child(this.template).value; + } + try { + Mustache.render(template, this.root.data); + } catch (error) { + e = error; + return "Template field does not contain valid Mustache"; + } + } + }; + + return ModelBase; + +}).call(this); diff --git a/lib/modelField.js b/lib/modelField.js new file mode 100644 index 0000000..4814bd7 --- /dev/null +++ b/lib/modelField.js @@ -0,0 +1,535 @@ +var ModelBase, ModelField, ModelOption, Mustache, globals, jiff; + +ModelBase = require('./modelBase'); + +ModelOption = require('./modelOption'); + +globals = require('./globals'); + +Mustache = require('mustache'); + +jiff = require('jiff'); + +/* + A ModelField represents a model object that render as a DOM field + NOTE: The following field types are subclasses: image, tree, date +*/ +module.exports = ModelField = (function() { + class ModelField extends ModelBase { + initialize() { + var ref2, ref3; + this.setDefault('type', 'text'); + this.setDefault('options', []); + this.setDefault('value', (function() { + switch (this.get('type')) { + case 'multiselect': + return []; + case 'bool': + return false; + case 'info': + case 'button': + return void 0; + default: + return (this.get('defaultValue')) || ''; + } + }).call(this)); + this.setDefault('defaultValue', this.get('value')); //used for control type and clear() + this.set('isValid', true); + this.setDefault('validators', []); + this.setDefault('onChangeHandlers', []); + this.setDefault('dynamicValue', null); + this.setDefault('template', null); + this.setDefault('autocomplete', null); + this.setDefault('beforeInput', function(val) { + return val; + }); + this.setDefault('beforeOutput', function(val) { + return val; + }); + super.initialize({ + objectMode: true + }); + //difficult to catch bad types at render time. error here instead + if ((ref2 = this.type) !== 'info' && ref2 !== 'text' && ref2 !== 'url' && ref2 !== 'email' && ref2 !== 'tel' && ref2 !== 'time' && ref2 !== 'date' && ref2 !== 'textarea' && ref2 !== 'bool' && ref2 !== 'tree' && ref2 !== 'color' && ref2 !== 'select' && ref2 !== 'multiselect' && ref2 !== 'image' && ref2 !== 'button' && ref2 !== 'number') { + return globals.handleError(`Bad field type: ${this.type}`); + } + this.bindPropFunctions('dynamicValue'); + // multiselects are arrays, others are strings. If typeof value doesn't match, convert it. + while ((Array.isArray(this.value)) && (this.type !== 'multiselect') && (this.type !== 'tree') && (this.type !== 'button')) { + this.value = this.value[0]; + } + if (typeof this.value === 'string' && (this.type === 'multiselect')) { + this.value = [this.value]; + } + //bools are special too. + if (this.type === 'bool' && typeof this.value !== 'bool') { + this.value = !!this.value; //convert to bool + } + this.makePropArray('validators'); + this.bindPropFunctions('validators'); + //onChangeHandlers functions for field value changes only. For any property change, use onChangePropertiesHandlers + this.makePropArray('onChangeHandlers'); + this.bindPropFunctions('onChangeHandlers'); + if (this.optionsFrom != null) { + this.ensureSelectType(); + if ((this.optionsFrom.url == null) || (this.optionsFrom.parseResults == null)) { + return globals.handleError('When fetching options remotely, both url and parseResults properties are required'); + } + if (typeof ((ref3 = this.optionsFrom) != null ? ref3.url : void 0) === 'function') { + this.optionsFrom.url = this.bindPropFunction('optionsFrom.url', this.optionsFrom.url); + } + if (typeof this.optionsFrom.parseResults !== 'function') { + return globals.handleError('optionsFrom.parseResults must be a function'); + } + this.optionsFrom.parseResults = this.bindPropFunction('optionsFrom.parseResults', this.optionsFrom.parseResults); + } + this.updateOptionsSelected(); + this.on('change:value', function() { + var changeFunc, j, len1, ref4; + ref4 = this.onChangeHandlers; + for (j = 0, len1 = ref4.length; j < len1; j++) { + changeFunc = ref4[j]; + changeFunc(); + } + return this.updateOptionsSelected(); + }); + // if type changes, need to update value + return this.on('change:type', function() { + if (this.type === 'multiselect') { + this.value = this.value.length > 0 ? [this.value] : []; + } else if (this.previousAttributes().type === 'multiselect') { + this.value = this.value.length > 0 ? this.value[0] : ''; + } + // must be *select if options present + if (this.options.length > 0 && !this.isSelectType()) { + return this.type = 'select'; + } + }); + } + + getOptionsFrom() { + var ref2, url; + if (this.optionsFrom == null) { + return; + } + url = typeof this.optionsFrom.url === 'function' ? this.optionsFrom.url() : this.optionsFrom.url; + if (this.prevUrl === url) { + return; + } + this.prevUrl = url; + return typeof window !== "undefined" && window !== null ? (ref2 = window.formbuilderproxy) != null ? ref2.getFromProxy({ + url: url, + method: this.optionsFrom.method || 'get', + headerKey: this.optionsFrom.headerKey + }, (error, data) => { + var j, len1, mappedResults, opt, results1; + if (error) { + return globals.handleError(globals.makeErrorMessage(this, 'optionsFrom', error)); + } + mappedResults = this.optionsFrom.parseResults(data); + if (!Array.isArray(mappedResults)) { + return globals.handleError('results of parseResults must be an array of option parameters'); + } + this.options = []; + results1 = []; + for (j = 0, len1 = mappedResults.length; j < len1; j++) { + opt = mappedResults[j]; + results1.push(this.option(opt)); + } + return results1; + }) : void 0 : void 0; + } + + field(...obj) { + return this.parent.field(...obj); + } + + group(...obj) { + return this.parent.group(...obj); + } + + option(...optionParams) { + var newOption, nextOpts, opt, optionObject; + optionObject = this.buildParamObject(optionParams, ['title', 'value', 'selected', 'bidAdj', 'bidAdjFlag']); + // when adding an option to a field, make sure it is a *select type + this.ensureSelectType(); + // If this option already exists, replace. Otherwise append + nextOpts = (function() { + var j, len1, ref2, results1; + ref2 = this.options; + results1 = []; + for (j = 0, len1 = ref2.length; j < len1; j++) { + opt = ref2[j]; + if (opt.title !== optionObject.title) { + results1.push(opt); + } + } + return results1; + }).call(this); + newOption = new ModelOption(optionObject); + nextOpts.push(newOption); + this.options = nextOpts; + //if new option has selected:true, set this field's value to that + //don't remove from parent value if not selected. Might be supplied by field value during creation. + if (newOption.selected) { + this.addOptionValue(newOption.value); + } + return this; + } + + postBuild() { + // options may have changed the starting value, so update the defaultValue to that + this.defaultValue = this.value; //todo: NO! need to clone this in case value isnt primitive + //update each option's selected status to match the field value + return this.updateOptionsSelected(); + } + + updateOptionsSelected() { + var bid, i, len, opt, ref, ref1, results; + ref = this.options; + results = []; + i = 0; + len = ref.length; + while (i < len) { + opt = ref[i]; + if ((ref1 = this.type) === 'multiselect' || ref1 === 'tree') { + bid = this.hasValue(opt.value); + if (bid.bidValue) { + opt.bidAdj = bid.bidValue.lastIndexOf('/') !== -1 ? bid.bidValue.split("/").pop() : this.bidAdj; + } + results.push(opt.selected = bid.selectStatus); + } else { + results.push(opt.selected = this.hasValue(opt.value)); + } + i++; + } + return results; + } + + // returns true if this type is one where a value is selected. Otherwise false + isSelectType() { + var ref2; + return (ref2 = this.type) === 'select' || ref2 === 'multiselect' || ref2 === 'image' || ref2 === 'tree'; + } + + // certain operations require one of the select types. If its not already, change field type to select + ensureSelectType() { + if (!this.isSelectType()) { + return this.type = 'select'; + } + } + + // find an option by value. Uses the same child method as groups and fields to find constituent objects + child(value) { + var j, len1, o, ref2; + if (Array.isArray(value)) { + value = value.shift(); + } + ref2 = this.options; + for (j = 0, len1 = ref2.length; j < len1; j++) { + o = ref2[j]; + if (o.value === value) { + return o; + } + } + } + + // add a new validator function + validator(func) { + this.validators.push(this.bindPropFunction('validator', func)); + this.trigger('change'); + return this; + } + + // add a new onChangeHandler function that triggers when the field's value changes + onChange(f) { + this.onChangeHandlers.push(this.bindPropFunction('onChange', f)); + this.trigger('change'); + return this; + } + + setDirty(id, whatChanged) { + var j, len1, opt, ref2; + ref2 = this.options; + for (j = 0, len1 = ref2.length; j < len1; j++) { + opt = ref2[j]; + opt.setDirty(id, whatChanged); + } + return super.setDirty(id, whatChanged); + } + + setClean(all) { + var j, len1, opt, ref2, results1; + super.setClean({ + objectMode: true + }); + if (all) { + ref2 = this.options; + results1 = []; + for (j = 0, len1 = ref2.length; j < len1; j++) { + opt = ref2[j]; + results1.push(opt.setClean(all)); + } + return results1; + } + } + + recalculateRelativeProperties() { + var dirty, j, k, len1, len2, opt, ref2, ref3, results1, validator, validityMessage, value; + dirty = this.dirty; + super.recalculateRelativeProperties({ + objectMode: true + }); + // validity + // only fire if isValid changes. If isValid stays false but message changes, don't need to re-fire. + if (this.shouldCallTriggerFunctionFor(dirty, 'isValid')) { + validityMessage = void 0; + //certain validators are automatic on fields with certain properties + if (this.template) { + validityMessage || (validityMessage = this.validate.template.call(this)); + } + if (this.type === 'number') { + validityMessage || (validityMessage = this.validate.number.call(this)); + } + //if no problems yet, try all the user-defined validators + if (!validityMessage) { + ref2 = this.validators; + for (j = 0, len1 = ref2.length; j < len1; j++) { + validator = ref2[j]; + if (typeof validator === 'function') { + validityMessage = validator.call(this); + } + if (typeof validityMessage === 'function') { + return globals.handleError(`A validator on field '${this.name}' returned a function`); + } + if (validityMessage) { + break; + } + } + } + this.validityMessage = validityMessage; + this.set({ + isValid: validityMessage == null + }); + } + // Fields with a template property can't also have a dynamicValue property. + if (this.template && this.shouldCallTriggerFunctionFor(dirty, 'value')) { + this.renderTemplate(); + } else { + //dynamic value + if (typeof this.dynamicValue === 'function' && this.shouldCallTriggerFunctionFor(dirty, 'value')) { + value = this.dynamicValue(); + if (typeof value === 'function') { + return globals.handleError(`dynamicValue on field '${this.name}' returned a function`); + } + this.set('value', value); + } + } + if (this.shouldCallTriggerFunctionFor(dirty, 'options')) { + this.getOptionsFrom(); + } + ref3 = this.options; + results1 = []; + for (k = 0, len2 = ref3.length; k < len2; k++) { + opt = ref3[k]; + results1.push(opt.recalculateRelativeProperties()); + } + return results1; + } + + addOptionValue(val, bidAdj) { + var findMatch, ref; + findMatch = void 0; + ref = void 0; + if ((ref = this.type) === 'multiselect' || ref === 'tree') { + if (!Array.isArray(this.value)) { + this.value = [this.value]; + } + findMatch = this.value.findIndex(function(e) { + if (typeof e === 'string') { + return e.search(val) !== -1; + } else { + return e === val; + } + }); + if (findMatch !== -1) { + if (bidAdj) { + return this.value[findMatch] = val + '/' + bidAdj; + } + } else { + if (bidAdj) { + return this.value.push(val + '/' + bidAdj); + } else { + return this.value.push(val); + } + } + } else { + return this.value = val; + } + } + + removeOptionValue(val) { + var ref; + ref = void 0; + if ((ref = this.type) === 'multiselect' || ref === 'tree') { + return this.value = this.value.filter(function(e) { + if (typeof e === 'string') { + return e.search(val) === -1; + } else { + return e !== val; + } + }); + } else if (this.value === val) { + return this.value = ''; + } + } + + hasValue(val) { + var findMatch, ref; + findMatch = void 0; + ref = void 0; + if ((ref = this.type) === 'multiselect' || ref === 'tree') { + findMatch = this.value.findIndex(function(e) { + if (typeof e === 'string') { + return e.search(val) !== -1; + } else { + return e === val; + } + }); + if (findMatch !== -1) { + return { + 'bidValue': this.value[findMatch], + 'selectStatus': true + }; + } else { + return { + 'selectStatus': false + }; + } + } else { + return val === this.value; + } + } + + buildOutputData(_, skipBeforeOutput) { + var out, value; + value = (function() { + switch (this.type) { + case 'number': + out = +this.value; + if (isNaN(out)) { + return null; + } else { + return out; + } + break; + case 'info': + case 'button': + return void 0; + case 'bool': + return !!this.value; + default: + return this.value; + } + }).call(this); + if (skipBeforeOutput) { + return value; + } else { + return this.beforeOutput(value); + } + } + + clear(purgeDefaults = false) { + if (purgeDefaults) { + return this.value = (function() { + switch (this.type) { + case 'multiselect': + return []; + case 'bool': + return false; + default: + return ''; + } + }).call(this); + } else { + return this.value = this.defaultValue; + } + } + + ensureValueInOptions() { + var existingOption, j, k, l, len1, len2, len3, o, ref2, ref3, ref4, results1, v; + if (!this.isSelectType()) { + return; + } + if (typeof this.value === 'string') { + ref2 = this.options; + for (j = 0, len1 = ref2.length; j < len1; j++) { + o = ref2[j]; + if (o.value === this.value) { + existingOption = o; + } + } + if (!existingOption) { + return this.option(this.value, { + selected: true + }); + } + } else if (Array.isArray(this.value)) { + ref3 = this.value; + results1 = []; + for (k = 0, len2 = ref3.length; k < len2; k++) { + v = ref3[k]; + existingOption = null; + ref4 = this.options; + for (l = 0, len3 = ref4.length; l < len3; l++) { + o = ref4[l]; + if (o.value === v) { + existingOption = o; + } + } + if (!existingOption) { + results1.push(this.option(v, { + selected: true + })); + } else { + results1.push(void 0); + } + } + return results1; + } + } + + applyData(inData, clear = false, purgeDefaults = false) { + if (clear) { + this.clear(purgeDefaults); + } + if (inData != null) { + return this.value = this.beforeInput(jiff.clone(inData)); + } + } + + //HUB-2766 this is no longer necessary as we now have biding changing option + //@ensureValueInOptions() + renderTemplate() { + var template; + if (typeof this.template === 'object') { + template = this.template.value; + } else { + template = this.parent.child(this.template).value; + } + try { + return this.value = Mustache.render(template, this.root.data); + } catch (error1) { + + } + } + + }; + + ModelField.prototype.modelClassName = 'ModelField'; + + ModelField.prototype.validityMessage = void 0; + + return ModelField; + +}).call(this); diff --git a/lib/modelFieldDate.js b/lib/modelFieldDate.js new file mode 100644 index 0000000..bf68e8a --- /dev/null +++ b/lib/modelFieldDate.js @@ -0,0 +1,26 @@ +var ModelField, ModelFieldDate, moment; + +ModelField = require('./modelField'); + +moment = require('moment'); + +module.exports = ModelFieldDate = class ModelFieldDate extends ModelField { + initialize() { + this.setDefault('format', 'M/D/YYYY'); + super.initialize({ + objectMode: true + }); + return this.validator(this.validate.date); + } + + // Convert date to string according to this format + dateToString(date = this.value, format = this.format) { + return moment(date).format(format); + } + + // Convert string in this format to a date. Could be an invalid date. + stringToDate(str = this.value, format = this.format) { + return moment(str, format, true).toDate(); + } + +}; diff --git a/lib/modelFieldImage.js b/lib/modelFieldImage.js new file mode 100644 index 0000000..ec56c05 --- /dev/null +++ b/lib/modelFieldImage.js @@ -0,0 +1,93 @@ +var ModelField, ModelFieldImage; + +ModelField = require('./modelField'); + +// An image field is different enough from other fields to warrant its own subclass +module.exports = ModelFieldImage = class ModelFieldImage extends ModelField { + initialize() { + this.setDefault('value', {}); + this.setDefault('allowUpload', false); + this.setDefault('imagesPerPage', 4); + this.setDefault('minWidth', 0); + this.setDefault('maxWidth', 0); + this.setDefault('minHeight', 0); + this.setDefault('maxHeight', 0); + this.setDefault('minSize', 0); + this.setDefault('maxSize', 0); + this.set('optionsChanged', false); //React needs to know if the number of options changed, + // as this requires a full reinit of the plugin at render time that is not necessary for other changes. + return super.initialize({ + objectMode: true + }); + } + + // Override behaviors different from other fields. + option(...optionParams) { + var optionObject; + optionObject = this.buildParamObject(optionParams, ['fileID', 'fileUrl', 'thumbnailUrl']); + if (optionObject.fileID == null) { + optionObject.fileID = optionObject.fileUrl; + } + if (optionObject.thumbnailUrl == null) { + optionObject.thumbnailUrl = optionObject.fileUrl; + } + optionObject.value = { + fileID: optionObject.fileID, + fileUrl: optionObject.fileUrl, + thumbnailUrl: optionObject.thumbnailUrl + }; + // use fileID as the key because this is how they are selected when rendered. + if (optionObject.title == null) { + optionObject.title = optionObject.fileID; + } + this.optionsChanged = true; //required to reinit the carousel in the ui + return super.option(optionObject); + } + + // image values are objects, so lookup children by fileid instead + child(fileID) { + var i, len, o, ref; + if (Array.isArray(fileID)) { + fileID = fileID.shift(); + } + if (typeof fileID === 'object') { //if lookup by full object value + fileID = fileID.fileID; + } + ref = this.options; + for (i = 0, len = ref.length; i < len; i++) { + o = ref[i]; + if (o.fileID === fileID) { + return o; + } + } + } + + removeOptionValue(val) { + if (this.value.fileID === val.fileID) { + return this.value = {}; + } + } + + hasValue(val) { + return val.fileID === this.value.fileID && val.thumbnailUrl === this.value.thumbnailUrl && val.fileUrl === this.value.fileUrl; + } + + clear(purgeDefaults = false) { + return this.value = purgeDefaults ? {} : this.defaultValue; + } + + ensureValueInOptions() { + var existingOption, i, len, o, ref; + ref = this.options; + for (i = 0, len = ref.length; i < len; i++) { + o = ref[i]; + if (o.attributes.fileID === this.value.fileID) { + existingOption = o; + } + } + if (!existingOption) { + return this.option(this.value); + } + } + +}; diff --git a/lib/modelFieldTree.js b/lib/modelFieldTree.js new file mode 100644 index 0000000..ca15c1f --- /dev/null +++ b/lib/modelFieldTree.js @@ -0,0 +1,30 @@ +var ModelField, ModelFieldTree; + +ModelField = require('./modelField'); + +module.exports = ModelFieldTree = class ModelFieldTree extends ModelField { + initialize() { + this.setDefault('value', []); + return super.initialize({ + objectMode: true + }); + } + + option(...optionParams) { + var optionObject; + optionObject = this.buildParamObject(optionParams, ['path', 'value', 'selected']); + if (optionObject.value == null) { + optionObject.value = optionObject.id; + } + if (optionObject.value == null) { + optionObject.value = optionObject.path.join(' > '); + } + optionObject.title = optionObject.path.join('>'); //use path as the key since that is what is rendered. + return super.option(optionObject); + } + + clear(purgeDefaults = false) { + return this.value = purgeDefaults ? [] : this.defaultValue; + } + +}; diff --git a/lib/modelGroup.js b/lib/modelGroup.js new file mode 100644 index 0000000..acce5dc --- /dev/null +++ b/lib/modelGroup.js @@ -0,0 +1,393 @@ + +/* + Encapsulates a group of form objects that can be added or removed to the form together multiple times +*/ +var ModelBase, ModelField, ModelFieldDate, ModelFieldImage, ModelFieldTree, ModelGroup, RepeatingModelGroup, globals, jiff; + +ModelBase = require('./modelBase'); + +ModelFieldImage = require('./modelFieldImage'); + +ModelFieldTree = require('./modelFieldTree'); + +ModelFieldDate = require('./modelFieldDate'); + +ModelField = require('./modelField'); + +globals = require('./globals'); + +jiff = require('jiff'); + +/* + A ModelGroup is a model object that can contain any number of other groups and fields +*/ +module.exports = ModelGroup = (function() { + class ModelGroup extends ModelBase { + initialize() { + this.setDefault('children', []); + this.setDefault('root', this); + this.set('isValid', true); + this.set('data', null); + this.setDefault('beforeInput', function(val) { + return val; + }); + this.setDefault('beforeOutput', function(val) { + return val; + }); + return super.initialize({ + objectMode: true + }); + } + + postBuild() { + var child, i, len, ref, results; + ref = this.children; + results = []; + for (i = 0, len = ref.length; i < len; i++) { + child = ref[i]; + results.push(child.postBuild()); + } + return results; + } + + field(...fieldParams) { + var fieldObject, fld; + fieldObject = this.buildParamObject(fieldParams, ['title', 'name', 'type', 'value']); + if (fieldObject.disabled == null) { + fieldObject.disabled = this.disabled; + } + //Could move this to a factory, but fields should only be created here so probably not necessary. + fld = (function() { + switch (fieldObject.type) { + case 'image': + return new ModelFieldImage(fieldObject); + case 'tree': + return new ModelFieldTree(fieldObject); + case 'date': + return new ModelFieldDate(fieldObject); + default: + return new ModelField(fieldObject); + } + })(); + this.children.push(fld); + this.trigger('change'); + return fld; + } + + group(...groupParams) { + var groupObject, grp, key, ref, val; + grp = {}; + if (((ref = groupParams[0].constructor) != null ? ref.name : void 0) === 'ModelGroup') { + grp = groupParams[0].cloneModel(this.root); + //set any other supplied params on the clone + groupParams.shift(); //remove the cloned object + groupObject = this.buildParamObject(groupParams, ['title', 'name', 'description']); + if (groupObject.name == null) { + groupObject.name = groupObject.title; + } + if (groupObject.title == null) { + groupObject.title = groupObject.name; + } + for (key in groupObject) { + val = groupObject[key]; + grp.set(key, val); + } + } else { + groupObject = this.buildParamObject(groupParams, ['title', 'name', 'description']); + if (groupObject.disabled == null) { + groupObject.disabled = this.disabled; + } + if (groupObject.repeating) { + grp = new RepeatingModelGroup(groupObject); + } else { + grp = new ModelGroup(groupObject); + } + } + this.children.push(grp); + this.trigger('change'); + return grp; + } + + // find a child by name. + // can also find descendants multiple levels down by supplying one of + // * an array of child names (in order) eg: group.child(['childname','grandchildname','greatgrandchildname']) + // * a dot or slash delimited string. eg: group.child('childname.grandchildname.greatgrandchildname') + child(path) { + var c, child, i, len, name, ref; + if (!(Array.isArray(path))) { + path = path.split(/[.\/]/); + } + name = path.shift(); + ref = this.children; + for (i = 0, len = ref.length; i < len; i++) { + c = ref[i]; + if (c.name === name) { + child = c; + } + } + if (path.length === 0) { + return child; + } else { + return child.child(path); + } + } + + setDirty(id, whatChanged) { + var child, i, len, ref; + ref = this.children; + for (i = 0, len = ref.length; i < len; i++) { + child = ref[i]; + child.setDirty(id, whatChanged); + } + return super.setDirty(id, whatChanged); + } + + setClean(all) { + var child, i, len, ref, results; + super.setClean({ + objectMode: true + }); + if (all) { + ref = this.children; + results = []; + for (i = 0, len = ref.length; i < len; i++) { + child = ref[i]; + results.push(child.setClean(all)); + } + return results; + } + } + + recalculateRelativeProperties(collection = this.children) { + var child, dirty, i, len, newValid; + dirty = this.dirty; + super.recalculateRelativeProperties({ + objectMode: true + }); + //group is valid if all children are valid + //might not need to check validy, but always need to recalculate all children anyway. + newValid = true; + for (i = 0, len = collection.length; i < len; i++) { + child = collection[i]; + child.recalculateRelativeProperties(); + newValid && (newValid = child.isValid); + } + return this.isValid = newValid; + } + + buildOutputData(group = this, skipBeforeOutput) { + var obj; + obj = {}; + console.log("buildOutputData======"); + group.children.forEach(function(child) { + var childData; + childData = child.buildOutputData(void 0, skipBeforeOutput); + if (childData !== void 0) { // undefined values do not appear in output, but nulls do + return obj[child.name] = childData; + } + }); + if (skipBeforeOutput) { + return obj; + } else { + return group.beforeOutput(obj); + } + } + + buildOutputDataString() { + return JSON.stringify(this.buildOutputData()); + } + + clear(purgeDefaults = false) { + var child, i, j, key, len, len1, ref, ref1, results; + //reset the 'data' object in-place, so model code will see an empty object too. + if (this.data) { + ref = Object.keys(this.data); + for (i = 0, len = ref.length; i < len; i++) { + key = ref[i]; + delete this.data[key]; + } + } + ref1 = this.children; + results = []; + for (j = 0, len1 = ref1.length; j < len1; j++) { + child = ref1[j]; + results.push(child.clear(purgeDefaults)); + } + return results; + } + + applyData(inData, clear = false, purgeDefaults = false) { + var finalInData, key, ref, results, value; + if (clear) { + this.clear(purgeDefaults); + } + finalInData = this.beforeInput(jiff.clone(inData)); + /* + This section preserves a link to the initially applied data object and merges subsequent applies on top + of it in-place. This is necessary for two reasons. + First, the scope of the running model code also references the applied data through the 'data' variable. + Every applied data must be available even though the runtime is not re-evaluated each time. + Second, templated fields use this data as the input to their Mustache evaluation. See @renderTemplate() + */ + if (this.data) { + globals.mergeData(this.data, inData); + this.trigger('change'); + } else { + this.data = inData; + } + results = []; + for (key in finalInData) { + value = finalInData[key]; + results.push((ref = this.child(key)) != null ? ref.applyData(value) : void 0); + } + return results; + } + + }; + + ModelGroup.prototype.modelClassName = 'ModelGroup'; + + return ModelGroup; + +}).call(this); + +RepeatingModelGroup = (function() { + class RepeatingModelGroup extends ModelGroup { + initialize() { + this.setDefault('defaultValue', this.get('value') || []); + this.set('value', []); + return super.initialize({ + objectMode: true + }); + } + + postBuild() { + var c, i, len, ref; + ref = this.children; + for (i = 0, len = ref.length; i < len; i++) { + c = ref[i]; + c.postBuild(); + } + return this.clear(); // Apply the defaultValue for the repeating model group after it has been built + } + + setDirty(id, whatChanged) { + var i, len, ref, val; + ref = this.value; + for (i = 0, len = ref.length; i < len; i++) { + val = ref[i]; + val.setDirty(id, whatChanged); + } + return super.setDirty(id, whatChanged); + } + + setClean(all) { + var i, len, ref, results, val; + super.setClean({ + objectMode: true + }); + if (all) { + ref = this.value; + results = []; + for (i = 0, len = ref.length; i < len; i++) { + val = ref[i]; + results.push(val.setClean(all)); + } + return results; + } + } + + recalculateRelativeProperties() { + //ignore validity/visibility of children, only value instances + return super.recalculateRelativeProperties(this.value); + } + + buildOutputData(_, skipBeforeOutput) { + var tempOut; + tempOut = this.value.map(function(instance) { + return ModelGroup.prototype.buildOutputData.apply(instance); + }); + //super.buildOutputData instance #build output data of each value as a group, not repeating group + if (skipBeforeOutput) { + return tempOut; + } else { + return this.beforeOutput(tempOut); + } + } + + clear(purgeDefaults = false) { + this.value = []; + if (!purgeDefaults) { + if (this.defaultValue) { + // we do NOT want to run beforeInput when resetting to the default, so just convert each to a ModelGroup + return this.addEachSimpleObject(this.defaultValue); + } + } + } + + // applyData performs and clearing and transformations, then adds each simple object and a value ModelGroup + applyData(inData, clear = false, purgeDefaults = false) { + var finalInData; + finalInData = this.beforeInput(jiff.clone(inData)); + // always clear out and replace the model value when data is supplied + if (finalInData) { + this.value = []; + } else { + if (clear) { + this.clear(purgeDefaults); + } + } + return this.addEachSimpleObject(finalInData, clear, purgeDefaults); + } + + addEachSimpleObject(o, clear = false, purgeDefaults = false) { + var added, i, key, len, obj, results, value; +//each value in the repeating group needs to be a repeating group object, not just the anonymous object in data +//add a new repeating group to value for each in data, and apply data like with a model group + results = []; + for (i = 0, len = o.length; i < len; i++) { + obj = o[i]; + added = this.add(); + results.push((function() { + var ref, results1; + results1 = []; + for (key in obj) { + value = obj[key]; + results1.push((ref = added.child(key)) != null ? ref.applyData(value, clear, purgeDefaults) : void 0); + } + return results1; + })()); + } + return results; + } + + cloneModel(root, constructor) { + var clone, excludeAttributes; + // When cloning to a ModelGroup exclude items not intended for subordinate clones + excludeAttributes = (constructor != null ? constructor.name : void 0) === 'ModelGroup' ? ['value', 'beforeInput', 'beforeOutput', 'description'] : []; + clone = super.cloneModel(root, constructor, excludeAttributes); + // need name but not title. Can't exclude in above clone because default to each other. + clone.title = ''; + return clone; + } + + add() { + var clone; + clone = this.cloneModel(this.root, ModelGroup); + this.value.push(clone); + this.trigger('change'); + return clone; + } + + delete(index) { + this.value.splice(index, 1); + return this.trigger('change'); + } + + }; + + RepeatingModelGroup.prototype.modelClassName = 'RepeatingModelGroup'; + + return RepeatingModelGroup; + +}).call(this); diff --git a/lib/modelOption.js b/lib/modelOption.js new file mode 100644 index 0000000..f1f0e9f --- /dev/null +++ b/lib/modelOption.js @@ -0,0 +1,29 @@ +var ModelBase, ModelOption; + +ModelBase = require('./modelBase'); + +module.exports = ModelOption = class ModelOption extends ModelBase { + initialize() { + this.setDefault('value', this.get('title')); + // No two options on a field should have the same title. This would be confusing during render. + // Even if not rendered, title can be used as primary key to determine when duplicate options should be avoided. + this.setDefault('title', this.get('value')); + // selected is used to set default value and also to store current value. + this.setDefault('selected', false); + // set default bid adjustment + this.setDefault('path', []); //for tree. Might should move to subclass + super.initialize({ + objectMode: true + }); + // if selected is changed, make sure parent matches + // this change likely comes from parent value changing, so be careful not to infinitely recurse. + return this.on('change:selected', function() { + if (this.selected) { + return this.parent.addOptionValue(this.value, this.bidAdj); // not selected + } else { + return this.parent.removeOptionValue(this.value); + } + }); + } + +}; diff --git a/package.json b/package.json index df294ba..cb9cd9b 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,28 @@ { "name": "balihoo-form-builder-model", - "version": "2.2.1", + "version": "2.2.3", "description": "Standalone code for building form builder models", "author": "Balihoo", "main": "./formbuilder.js", "dependencies": { - "backbone": "1.2.1", - "coffee-script": "1.9.3", - "jiff": "0.7.2", + "backbone": "1.4.0", + "coffeescript": "2.5.1", + "jiff": "0.7.3", "moment": "^2.14.1", - "mustache": "balihoo-anewman/mustache.js", - "underscore": "1.8.3" + "mustache": "4.1.0", + "underscore": "1.13.1" }, "devDependencies": { - "coffeelint": "1.11.1", - "gulp": "^3.8.10", - "gulp-coffee": "^2.2.0", - "gulp-coffeelint": "^0.5.0", - "gulp-istanbul": "^0.5.0", - "gulp-mocha": "^2.0.0", - "istanbul": "^0.3.5", - "mocha": "^2.0.1", - "sinon": "^1.17.7", - "yargs": "^3.15.0" + "coffeelint": "2.1.0", + "gulp": "4.0.2", + "gulp-coffee": "3.0.3", + "gulp-coffeelint": "0.6.0", + "gulp-istanbul": "1.1.3", + "gulp-mocha": "7.0.2", + "istanbul": "0.4.5", + "mocha": "8.2.1", + "sinon": "1.17.7", + "yargs": "16.1.0" }, "repository": { "type": "git", diff --git a/src/building.coffee b/src/building.coffee index a438953..c30be3c 100644 --- a/src/building.coffee +++ b/src/building.coffee @@ -1,6 +1,6 @@ -CoffeeScript = require 'coffee-script' +CoffeeScript = require('coffee-script').register() Mustache = require 'mustache' _ = require 'underscore' vm = require 'vm' diff --git a/src/modelField.coffee b/src/modelField.coffee index 059853b..d93ccfb 100644 --- a/src/modelField.coffee +++ b/src/modelField.coffee @@ -30,10 +30,10 @@ module.exports = class ModelField extends ModelBase @setDefault 'beforeOutput', (val) -> val super - + objectMode: true #difficult to catch bad types at render time. error here instead - if @type not in ['info', 'text', 'url', 'email', 'tel', 'time', 'date', 'textarea', - 'bool', 'tree', 'color', 'select', 'multiselect', 'image', 'button', 'number'] + if @type not in ['info', 'text', 'url', 'email', 'tel', 'time', 'date', 'textarea','bool', 'tree', + 'color', 'select', 'multiselect','image', 'button', 'number'] return globals.handleError "Bad field type: #{@type}" @bindPropFunctions 'dynamicValue' @@ -116,7 +116,7 @@ module.exports = class ModelField extends ModelBase @parent.group obj... option: (optionParams...) -> - optionObject = @buildParamObject optionParams, ['title', 'value', 'selected'] + optionObject = @buildParamObject optionParams, ['title', 'value', 'selected', 'bidAdj', 'bidAdjFlag'] # when adding an option to a field, make sure it is a *select type @ensureSelectType() @@ -140,8 +140,22 @@ module.exports = class ModelField extends ModelBase @updateOptionsSelected() updateOptionsSelected: -> - for opt in @options - opt.selected = @hasValue opt.value + ref = @options + results = [] + i = 0 + len = ref.length + + while i < len + opt = ref[i] + if (ref1 = @type) == 'multiselect' or ref1 == 'tree' + bid = @hasValue(opt.value) + if bid.bidValue + opt.bidAdj = if bid.bidValue.lastIndexOf('/') != -1 then bid.bidValue.split("/").pop() else @bidAdj + results.push opt.selected = bid.selectStatus + else + results.push opt.selected = @hasValue(opt.value) + i++ + results # returns true if this type is one where a value is selected. Otherwise false isSelectType: -> @@ -176,13 +190,14 @@ module.exports = class ModelField extends ModelBase setClean: (all) -> super + objectMode: true if all opt.setClean all for opt in @options recalculateRelativeProperties: -> dirty = @dirty super - + objectMode: true # validity # only fire if isValid changes. If isValid stays false but message changes, don't need to re-fire. if @shouldCallTriggerFunctionFor dirty, 'isValid' @@ -222,28 +237,61 @@ module.exports = class ModelField extends ModelBase for opt in @options opt.recalculateRelativeProperties() - addOptionValue: (val) -> - if @type in ['multiselect','tree'] - unless Array.isArray @value - @value = [@value] - if not (val in @value) - @value.push val - else #single-select - @value = val + addOptionValue: (val, bidAdj) -> + findMatch = undefined + ref = undefined + if (ref = @type) == 'multiselect' or ref == 'tree' + if !Array.isArray(@value) + @value = [ @value ] + findMatch = @value.findIndex((e) -> + if typeof e == 'string' + e.search(val) != -1 + else + e == val + ) + if findMatch != -1 + if bidAdj + return @value[findMatch] = val + '/' + bidAdj + else + if bidAdj + return @value.push(val + '/' + bidAdj) + else + return @value.push(val) + else + return @value = val + return removeOptionValue: (val) -> - if @type in ['multiselect','tree'] - if val in @value - @value = @value.filter (v) -> v isnt val - else if @value is val #single-select - @value = '' - - #determine if the value is or contains the provided value. + ref = undefined + if (ref = @type) == 'multiselect' or ref == 'tree' + return @value = @value.filter((e) -> + if typeof e == 'string' + e.search(val) == -1 + else + e != val + ) + else if @value == val + return @value = '' + return hasValue: (val) -> - if @type in ['multiselect','tree'] - val in @value + findMatch = undefined + ref = undefined + if (ref = @type) == 'multiselect' or ref == 'tree' + findMatch = @value.findIndex((e) -> + if typeof e == 'string' + e.search(val) != -1 + else + e == val + ) + if findMatch != -1 + { + 'bidValue': @value[findMatch] + 'selectStatus': true + } + else + { 'selectStatus': false } else - val is @value + val == @value buildOutputData: (_, skipBeforeOutput) -> value = switch @type @@ -273,7 +321,7 @@ module.exports = class ModelField extends ModelBase @option @value, selected:true else if Array.isArray @value for v in @value - existingOption = null #required to clear out previously found values + existingOption = null existingOption = o for o in @options when o.value is v unless existingOption @option v, selected:true @@ -282,7 +330,8 @@ module.exports = class ModelField extends ModelBase @clear purgeDefaults if clear if inData? @value = @beforeInput jiff.clone inData - @ensureValueInOptions() + #HUB-2766 this is no longer necessary as we now have biding changing option + #@ensureValueInOptions() renderTemplate: () -> if typeof @template is 'object' @@ -291,5 +340,4 @@ module.exports = class ModelField extends ModelBase template = @parent.child(@template).value try @value = Mustache.render template, @root.data - catch #just don't crash. Validator will display error later. - + catch \ No newline at end of file diff --git a/src/modelFieldDate.coffee b/src/modelFieldDate.coffee index 280d6f0..d08d952 100644 --- a/src/modelFieldDate.coffee +++ b/src/modelFieldDate.coffee @@ -5,6 +5,7 @@ module.exports = class ModelFieldDate extends ModelField initialize: -> @setDefault 'format', 'M/D/YYYY' super + objectMode: true @validator @validate.date # Convert date to string according to this format diff --git a/src/modelFieldImage.coffee b/src/modelFieldImage.coffee index 23693a6..95290a5 100644 --- a/src/modelFieldImage.coffee +++ b/src/modelFieldImage.coffee @@ -15,7 +15,7 @@ module.exports = class ModelFieldImage extends ModelField @set 'optionsChanged', false #React needs to know if the number of options changed, # as this requires a full reinit of the plugin at render time that is not necessary for other changes. super - + objectMode: true # Override behaviors different from other fields. option: (optionParams...) -> diff --git a/src/modelFieldTree.coffee b/src/modelFieldTree.coffee index 2e33f15..adb554f 100644 --- a/src/modelFieldTree.coffee +++ b/src/modelFieldTree.coffee @@ -4,7 +4,7 @@ module.exports = class ModelFieldTree extends ModelField initialize: -> @setDefault 'value', [] super - + objectMode: true option: (optionParams...) -> optionObject = @buildParamObject optionParams, ['path', 'value', 'selected'] optionObject.value ?= optionObject.id diff --git a/src/modelGroup.coffee b/src/modelGroup.coffee index ab2d609..e96733e 100644 --- a/src/modelGroup.coffee +++ b/src/modelGroup.coffee @@ -20,7 +20,7 @@ module.exports = class ModelGroup extends ModelBase @setDefault 'beforeOutput', (val) -> val super - + objectMode: true postBuild: -> child.postBuild() for child in @children @@ -87,12 +87,14 @@ module.exports = class ModelGroup extends ModelBase setClean: (all) -> super + objectMode: true if all child.setClean all for child in @children recalculateRelativeProperties: (collection = @children) -> dirty = @dirty super + objectMode: true #group is valid if all children are valid #might not need to check validy, but always need to recalculate all children anyway. newValid = true @@ -103,6 +105,7 @@ module.exports = class ModelGroup extends ModelBase buildOutputData: (group = @, skipBeforeOutput) -> obj = {} + console.log "buildOutputData======" group.children.forEach (child) -> childData = child.buildOutputData(undefined, skipBeforeOutput) unless childData is undefined # undefined values do not appear in output, but nulls do @@ -150,7 +153,7 @@ class RepeatingModelGroup extends ModelGroup @set 'value', [] super - + objectMode: true postBuild: -> c.postBuild() for c in @children @clear() # Apply the defaultValue for the repeating model group after it has been built @@ -161,6 +164,7 @@ class RepeatingModelGroup extends ModelGroup setClean: (all) -> super + objectMode: true if all val.setClean all for val in @value @@ -170,7 +174,8 @@ class RepeatingModelGroup extends ModelGroup buildOutputData: (_, skipBeforeOutput) -> tempOut = @value.map (instance) -> - super instance #build output data of each value as a group, not repeating group + ModelGroup.prototype.buildOutputData.apply instance + #super.buildOutputData instance #build output data of each value as a group, not repeating group if skipBeforeOutput then tempOut else @beforeOutput tempOut diff --git a/src/modelOption.coffee b/src/modelOption.coffee index c713655..2e6afc4 100644 --- a/src/modelOption.coffee +++ b/src/modelOption.coffee @@ -8,13 +8,14 @@ module.exports = class ModelOption extends ModelBase @setDefault 'title', @get 'value' # selected is used to set default value and also to store current value. @setDefault 'selected', false + # set default bid adjustment @setDefault 'path', [] #for tree. Might should move to subclass super - + objectMode: true # if selected is changed, make sure parent matches # this change likely comes from parent value changing, so be careful not to infinitely recurse. @on 'change:selected', -> if @selected - @parent.addOptionValue @value + @parent.addOptionValue @value, @bidAdj else # not selected @parent.removeOptionValue @value \ No newline at end of file