diff --git a/js/legalform-calc.js b/js/legalform-calc.js index 00a0f05..71313b2 100644 --- a/js/legalform-calc.js +++ b/js/legalform-calc.js @@ -55,6 +55,8 @@ function LegalFormCalc($) { if (type === 'group' && field.multiple) { value = typeof(value) !== 'undefined' ? [value] : []; + } else if (type === 'money' && value) { + value = parseFloat(value); } addGroupedData(data, step.group, field.name, value); @@ -88,7 +90,7 @@ function LegalFormCalc($) { data[name + '-validation'] = expandCondition(field.validation, step.group || '', true); } - if (type === 'expression') { + if (type === 'expression' && !step.repeater) { setComputedForExpression(name, step, field, data); } else if (type === 'external_data' || field.external_source) { setComputedForExternalUrls(name, step, field, data); @@ -155,6 +157,15 @@ function LegalFormCalc($) { meta.yearly = !!(typeof field.yearly !== 'undefined' && field.yearly); } + if (type === 'expression' && step.repeater) { + var expression = {}; + var name = (step.group ? step.group + '.' : '') + field.name; + var key = name + '-expression'; + + setComputedForExpression(name, step, field, expression); + meta.expressionTmpl = expression[key]; + } + addGroupedData(data, step.group, field.name, meta); }); diff --git a/js/legalform-html.js b/js/legalform-html.js index cf63e56..0346ae5 100644 --- a/js/legalform-html.js +++ b/js/legalform-html.js @@ -22,16 +22,18 @@ function LegalFormHtml($) { }; this.model = null; - this.isTestEnv = false; + this.disableRequiredFields = false; /** * Build form html - * @param {array} definition Form definition - * @param {boolean} isTestEnv If we're building form for testing purposes - * @return {string} Form html + * @param {array} definition Form definition + * @param {object} builderOptions Additional options for buildong form html + * @return {string} Form html */ - this.build = function(definition, isTestEnv) { - self.isTestEnv = !!isTestEnv; + this.build = function(definition, builderOptions) { + if (typeof builderOptions === 'undefined') builderOptions = {}; + + self.disableRequiredFields = !!builderOptions.disableRequiredFields; self.model = (new FormModel(definition)).getModel(); var lines = []; @@ -165,7 +167,7 @@ function LegalFormHtml($) { var type = self.model.getFieldType(data); var excl = mode === 'build' ? 'data-mask;' : - (self.isTestEnv ? 'required;' : ''); + (self.disableRequiredFields ? 'required;' : ''); switch (type) { case 'number': @@ -335,7 +337,7 @@ function LegalFormHtml($) { var more = value === null ? {checked: data.value} : {name: data.value, value: value}; attrs = $.extend(attrs, more); - if (self.isTestEnv) { + if (self.disableRequiredFields) { excl += ';required;'; } } else { diff --git a/js/legalform-validation.js b/js/legalform-validation.js index 3d30acb..b749ff5 100644 --- a/js/legalform-validation.js +++ b/js/legalform-validation.js @@ -2,10 +2,13 @@ * Validation for LegalForm */ (function($) { - function LegalFormValidation() { + function LegalFormValidation(builderOptions) { + if (typeof builderOptions === 'undefined') builderOptions = {}; + this.ractive = null; this.el = null; this.elWizard = null; + this.disableRequiredFields = !!builderOptions.disableRequiredFields; //Fields for custom validation var textFields = 'input[type="text"], input[type="number"], input[type="email"], textarea'; @@ -228,25 +231,26 @@ } // Implement validation for group checkboxes - if (meta.type === 'group') { + if (meta.type === 'group' && $input.attr('multiple')) { const checkBoxId = $input.attr('data-id'); - const allCheckboxes = $("[data-id='" + checkBoxId + "']"); + const $allCheckboxes = $('[data-id="' + checkBoxId + '"]'); const isRequired = !$input.closest('.form-group').find('label > span').length ? false : $input.closest('.form-group').find('label > span')[0].className === 'required' ? true : false; - let checked = 0; - - for (var i = 0; i < allCheckboxes.length; i++) { - if (allCheckboxes[i].checked) { - checked++; - } else if (allCheckboxes[i].type !== 'radio') { - $(allCheckboxes[i]).prop('required', false); + if (isRequired && this.disableRequiredFields) { + $allCheckboxes.prop('required', false); + } else { + let checked = 0; + for (var i = 0; i < $allCheckboxes.length; i++) { + if ($allCheckboxes[i].checked) checked++; } - } - if (isRequired && checked === 0) { - $input.get(0).setCustomValidity(error); - return; + if (isRequired) $allCheckboxes.prop('required', !checked); + + if (isRequired && checked === 0) { + $input.get(0).setCustomValidity(error); + return; + } } } diff --git a/js/legalform.js b/js/legalform.js index a2671a8..6ffc495 100644 --- a/js/legalform.js +++ b/js/legalform.js @@ -12,13 +12,13 @@ function LegalForm($) { /** * Build form html - * @param {array} definition Form definition - * @param {boolean} isTestEnv If we're building form for testing purposes - * @return {string} Form html + * @param {array} definition Form definition + * @param {object} builderOptions Additional options for buildong form html + * @return {string} Form html */ - this.build = function(definition, isTestEnv) { + this.build = function(definition, builderOptions) { var handler = new LegalFormHtml($); - return handler.build(definition, isTestEnv); + return handler.build(definition, builderOptions); } /** diff --git a/js/lib/parse-number.js b/js/lib/parse-number.js index 4f45efe..1c8d145 100644 --- a/js/lib/parse-number.js +++ b/js/lib/parse-number.js @@ -3,7 +3,7 @@ if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = parseNumber; } -var numberRegexp = new RegExp('^(?:((?:\\d{1,3}(?:\\.\\d{3})+|\\d+)(?:,\\d{1,})?)|((?:\\d{1,3}(?:,\\d{3})+|\\d+)(?:\\.\\d{1,})?))$'); +var numberRegexp = new RegExp('^(?:((?:\\d{1,3}(?:\\.\\d{3})+|\\d+)(,\\d{1,})?)|((?:\\d{1,3}(?:,\\d{3})+|\\d+)(\\.\\d{1,})?))$'); var dotRegexp = /\./g; var commaRegexp = /,/g; @@ -23,7 +23,9 @@ function parseNumber(number) { var match = number.match(numberRegexp); if (!match) return null; - var isDecimalComma = typeof match[1] !== 'undefined'; + var isDecimalComma = + typeof match[2] !== 'undefined' || + (typeof match[3] !== 'undefined' && typeof match[4] === 'undefined'); number = isDecimalComma ? number.replace(dotRegexp, '').replace(',', '.') : diff --git a/js/lib/ractive-dynamic-computed.js b/js/lib/ractive-dynamic-computed.js new file mode 100644 index 0000000..2bae9d7 --- /dev/null +++ b/js/lib/ractive-dynamic-computed.js @@ -0,0 +1,71 @@ +/** + * Dynamic addition and removing of computed properties in current used version of Ractive (0.9.13) is not supported out of the box. + * So we use this object for that purpose. It uses code, extracted from ractive, and simplified to only cover our needs + * (that is only support computed properties, given as strings). + */ + +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = RactiveDynamicComputed; +} + +function RactiveDynamicComputed() { + var self = this; + + this.dotRegExp = /\./g; + this.computedVarRegExp = /\$\{([^\}]+)\}/g; + + /** + * Remove computed property from existing rative instance + * @param {object} ractive + * @param {string} key + */ + this.remove = function(ractive, key) { + var escapedKey = key.replace(dotRegExp, '\\.'); + + delete ractive.computed[key]; + delete ractive.viewmodel.computations[escapedKey]; + } + + /** + * Add computed expression to existing ractive instance + * @param {object} ractive + * @param {string} key + * @param {string} value + */ + this.add = function(ractive, key, value) { + var signature = getComputationSignature(ractive, key, value); + + ractive.computed[key] = value; + ractive.viewmodel.compute(key, signature); + } + + function getComputationSignature(ractive, key, signature) { + if (typeof signature !== 'string') { + throw 'Unable to dynamically add computed property with value of type ' + (typeof signature); + } + + var getter = createFunctionFromString(signature, ractive); + var getterString = signature; + + return { + getter: getter, + setter: undefined, + getterString: getterString, + setterString: undefined, + getterUseStack: undefined + }; + } + + function createFunctionFromString(str, bindTo) { + var hasThis; + + var functionBody = 'return (' + str.replace(self.computedVarRegExp, function (match, keypath) { + hasThis = true; + return ("__ractive.get(\"" + keypath + "\")"); + }) + ');'; + + if (hasThis) { functionBody = "var __ractive = this; " + functionBody; } + var fn = new Function( functionBody ); + return hasThis ? fn.bind( bindTo ) : fn; + } +} diff --git a/js/lib/repeated-step-expression.js b/js/lib/repeated-step-expression.js new file mode 100644 index 0000000..4242923 --- /dev/null +++ b/js/lib/repeated-step-expression.js @@ -0,0 +1,22 @@ + +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = tmplToExpression; +} + +var dotRegExp = /\./g; + +/** + * Insert repeated step index into expression + * @param {object} step + * @param {string} expression + * @return {string} + */ +function tmplToExpression(expressionTmpl, group, idx) { + var prefix = group + '.'; + prefix = prefix.replace(dotRegExp, '\\.'); + + var prefixRegExp = new RegExp('\\$\\{' + prefix, 'g'); + var replacement = '${' + group + '[' + idx + '].'; + + return expressionTmpl.replace(prefixRegExp, replacement); +} diff --git a/js/ractive-legalform.js b/js/ractive-legalform.js index 2c82958..0cefc5c 100644 --- a/js/ractive-legalform.js +++ b/js/ractive-legalform.js @@ -20,6 +20,11 @@ */ validation: null, + /** + * Expressions used in repeated steps + */ + repeatedStepExpressions: {}, + /** * Suffixes in keypath names that determine their special behaviour * @type {Object} @@ -132,6 +137,10 @@ if (isComputed) return; + + this.onChangeMoney(newValue, oldValue, keypath); + this.onChangeAmount(newValue, oldValue, keypath); + var isEmpty = newValue === null || newValue === undefined || (typeof(newValue) === 'string' && !newValue.trim().length); //consider evalueted expressions, that have only spaces, as empty @@ -207,6 +216,45 @@ }, 10); }, + /** + * Cast money to float + * @param {string} newValue + * @param {string} oldValue + * @param {string} keypath + */ + onChangeMoney: function(newValue, oldValue, keypath) { + var meta = this.get('meta.' + keypath); + var isMoney = typeof meta !== 'undefined' && + typeof meta.type !== 'undefined' && + meta.type === 'money'; + + if (isMoney && newValue) { + this.set(keypath, parseFloat(newValue)); + } + }, + + /** + * Handle change of amount options from singular to plural, and backwords. + * @param {mixed} newValue + * @param {mixed} oldValue + * @param {string} keypath + */ + onChangeAmount: function(newValue, oldValue, keypath) { + var key = keypath.replace(/\.amount$/, ''); + var meta = this.get('meta.' + key); + var isAmount = typeof meta !== 'undefined' && + typeof meta.plural !== 'undefined' && + typeof meta.singular !== 'undefined'; + + if (!isAmount) return; + + var oldOptions = meta[newValue == 1 ? 'plural' : 'singular']; + var newOptions = meta[newValue == 1 ? 'singular' : 'plural']; + var index = oldOptions ? oldOptions.indexOf(this.get(key + '.unit')) : -1; + + if (newOptions && index !== -1) this.set(key + '.unit', newOptions[index]); + }, + /** * We do not use computed for expression field itself, to avoid escaping dots in template, * because in computed properties dots are just parts of name, and do not represent nested objects. @@ -237,18 +285,25 @@ var ractive = this; var name = unescapeDots(keypath.replace(this.suffix.repeater, '')); var value = ractive.get(name); + var tmpl = typeof this.defaults[name] !== 'undefined' ? this.defaults[name][0] : {}; var repeater = newValue; var stepCount = value.length; - if (!repeater) value.length = 0; - else if (repeater < stepCount) value = value.slice(0, repeater); - else if (repeater > stepCount) { + if (!repeater && stepCount) { + this.removeRepeatedStepExpression(name, 0, stepCount); + value.length = 0; + } else if (repeater < stepCount) { + this.removeRepeatedStepExpression(name, repeater, stepCount); + value = value.slice(0, repeater); + } else if (repeater > stepCount) { var addLength = repeater - stepCount; for (var i = 0; i < addLength; i++) { - value.push({}); + value.push($.extend(true, {}, tmpl)); } } + this.addRepeatedStepExpression(name, 0, value.length); + ractive.set(name, value); var meta = ractive.get('meta'); @@ -259,6 +314,69 @@ ractive.set('meta', meta); }, + /** + * Save repeated step expression tmpl to cache on ractive init + * @param {string} keypath + * @param {string} expressionTmpl + */ + cacheExpressionTmpl: function(keypath, expressionTmpl) { + var parts = keypath.split('.0.'); + if (parts.length !== 2) return; // Step is not repeatable (shouldn't happen) or has nested arrays (can't be, just in case) + + var group = parts[0]; + var fieldName = parts[1]; + var cache = this.repeatedStepExpressions; + + if (typeof cache[group] === 'undefined') cache[group] = {}; + cache[group][fieldName] = expressionTmpl; + }, + + /** + * Create computed expression dynamically for repeated step + * @param {string} group + * @param {int} fromStepIdx + * @param {int} stepCount + */ + addRepeatedStepExpression: function(group, fromStepIdx, stepCount) { + var expressionTmpls = this.repeatedStepExpressions[group]; + if (typeof expressionTmpls === 'undefined' || !expressionTmpls) return; + + for (var idx = fromStepIdx; idx < stepCount; idx++) { + var prefix = group + '[' + idx + ']'; + + for (var key in expressionTmpls) { + var keypath = prefix + '.' + key + this.suffix.expression; + var value = this.get(keypath); + + if (typeof value !== 'undefined') continue; + + var tmpl = expressionTmpls[key]; + var expression = tmplToExpression(tmpl, group, idx); + this.ractiveDynamicComputed.add(this, keypath, expression); + } + } + }, + + /** + * Remove computed expressions for repeated steps + * @param {string} group + * @param {int} fromStepIdx + * @param {int} stepCount + */ + removeRepeatedStepExpression: function(group, fromStepIdx, stepCount) { + var expressionTmpls = this.repeatedStepExpressions[group]; + if (typeof expressionTmpls === 'undefined' || !expressionTmpls) return; + + for (var idx = fromStepIdx; idx < stepCount; idx++) { + var prefix = group + '[' + idx + ']'; + + for (var key in expressionTmpls) { + var keypath = prefix + '.' + key + this.suffix.expression; + this.ractiveDynamicComputed.remove(this, keypath); + } + } + }, + /** * Show / hide likert questions */ @@ -342,36 +460,13 @@ * Initialize special field types */ initField: function (key, meta) { - var amountFields = []; - if (meta.type === 'amount') { - amountFields.push(key + this.suffix.amount); this.initAmountField(key, meta); } else if (meta.type === 'external_data') { this.initExternalData($.extend({name: key}, meta)); } else if (meta.external_source) { this.initExternalSource($.extend({name: key}, meta)); } - - this.initAmountChange(amountFields); - }, - - /** - * Init change of amount options from singular to plural, and backwords. - * This can not be processed in base form change observer, as it needs fields names. - * @param {array} fields All amount fields' names - */ - initAmountChange: function(fields) { - if (!fields.length) return; - - this.observe(fields.join(' '), function(newValue, oldValue, keypath) { - var key = keypath.replace(/\.amount$/, ''); - var oldOptions = this.get('meta.' + key + '.' + (newValue == 1 ? 'plural' : 'singular')); - var newOptions = this.get('meta.' + key + '.' + (newValue == 1 ? 'singular' : 'plural')); - var index = oldOptions ? oldOptions.indexOf(this.get(key + '.unit')) : -1; - - if (newOptions && index !== -1) this.set(key + '.unit', newOptions[index]); - }, {defer: true}); }, /** @@ -857,6 +952,8 @@ setByKeyPath(ractive.defaults, key, today); } else if (meta.type === "date") { setByKeyPath(ractive.defaults, key, ""); + } else if (meta.type === 'expression' && typeof meta.expressionTmpl !== 'undefined') { + ractive.cacheExpressionTmpl(key, meta.expressionTmpl); } }); diff --git a/spec/legalform.build.spec.js b/spec/legalform.build.spec.js index 781c9d3..abc74e1 100644 --- a/spec/legalform.build.spec.js +++ b/spec/legalform.build.spec.js @@ -1484,7 +1484,7 @@ describe("building a LegalForm for legalform model", function() { } ]; - var form = new LegalForm(jQuery).build(definition, true); + var form = new LegalForm(jQuery).build(definition, {disableRequiredFields: true}); expect(form).toMatchHtml(`
diff --git a/spec/lib/repeated-step-expression.spec.js b/spec/lib/repeated-step-expression.spec.js new file mode 100644 index 0000000..2848ff1 --- /dev/null +++ b/spec/lib/repeated-step-expression.spec.js @@ -0,0 +1,42 @@ +'use strict'; + +describe("test inserting repeated step index into expression", function() { + var tmplToExpression = require('../../js/lib/repeated-step-expression'); + + function usePlaceholderProvider() { + return [ + { + note: 'insert indexes for "foo" step', + group: 'foo', + expression: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}', + expected: '${foo[2].bar} + ${baz.bar} + "test" + "foo." + ${foo[2].zoo} + ${test.foo.zoo}' + }, + { + note: 'insert indexes for "foo.bar.baz" step', + group: 'foo.bar.baz', + expression: '${foo.bar.baz.prop1} + ${baz.bar} + "test" + "foo.bar.baz." + ${foo.bar.baz.prop2} + ${test.foo.bar.baz.zoo}', + expected: '${foo.bar.baz[2].prop1} + ${baz.bar} + "test" + "foo.bar.baz." + ${foo.bar.baz[2].prop2} + ${test.foo.bar.baz.zoo}' + }, + { + note: 'return expression as it is if step group is not set', + group: '', + expression: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}', + expected: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}' + }, + { + note: 'return expression as it is if step group is not found in expression', + group: 'not_used', + expression: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}', + expected: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}' + }, + ]; + } + + usePlaceholderProvider().forEach(function(spec) { + it(spec.note, function() { + var result = tmplToExpression(spec.expression, spec.group, 2); + + expect(result).toBe(spec.expected); + }); + }); +});