diff --git a/public/javascripts/control.js b/public/javascripts/control.js index 9cd330c4..11cb829e 100644 --- a/public/javascripts/control.js +++ b/public/javascripts/control.js @@ -10,6 +10,7 @@ ;(function($) { var validationNS = odkmaker.namespace.load('odkmaker.validation'); + var controlNS = odkmaker.namespace.load('odkmaker.control'); // Globally active singleton facilities: var $propertyList = $('.propertyList'); @@ -500,7 +501,7 @@ value: {}, summary: false } }, inputNumeric: { - range: { name: 'Range', + range: { name: 'Valid Range', type: 'numericRange', description: 'Valid numeric range for the user input of this control.', tips: [ @@ -514,14 +515,50 @@ tips: [ 'It is also displayed if a custom constraint check is failed.' ], value: {}, summary: false }, + appearance: { name: 'Style', + type: 'enum', + description: 'Style of collection interface to present.', + tips: [ 'A Picker, also known as a Spinner, is a little textbox with plus and minus buttons to change the number.' ], + options: [ 'Textbox', + 'Slider', + 'Vertical Slider', + 'Picker' ], + value: 'Textbox', + summary: true }, kind: { name: 'Kind', type: 'enum', + bindDisplayIf: { appearance: 'Textbox' }, description: 'Type of number accepted.', tips: [ 'In some data collection tools, this will affect the type of keypad shown.' ], options: [ 'Integer', 'Decimal' ], value: 'Integer', - summary: true } }, + summary: true }, + selectRange:{ name: 'Selectable Range', + type: 'numericRange', + validation: [ 'rangeRequired' ], + bindDisplayIf: { appearance: [ 'Slider', 'Vertical Slider', 'Picker' ] }, + optional: false, + inclusivity: false, + description: 'The lowest and highest selectable values, inclusive.', + tips: [ 'Integers and decimals are both valid.' ], + value: { min: "1", max: "10" }, + summary: false }, + selectStep: { name: 'Selectable Range: Step Between Choices', + type: 'text', + validation: [ 'required', 'numeric', 'stepDivision' ], + bindDisplayIf: { appearance: [ 'Slider', 'Vertical Slider', 'Picker' ] }, + description: 'The gap between adjacent selectable values in the range.', + tips: [ 'The step must cleanly end at the low and high ends of the range; in other words, it must divide the selectable range perfectly.' ], + value: '1', + summary: false }, + sliderTicks:{ name: 'Slider Ticks', + type: 'bool', + bindDisplayIf: { appearance: [ 'Slider', 'Vertical Slider' ] }, + description: 'The lowest and highest selectable values, inclusive.', + tips: [ 'Integers and decimals are both valid.' ], + value: true, + summary: false } }, inputDate: { range: { name: 'Range', type: 'dateRange', @@ -775,4 +812,19 @@ } }; + + controlNS.upgrade = { + 2: function(form) { + var processControl = function(control) + { + if (_.isArray(control.children)) + _.each(control.children, processControl); + + if ((control.type === 'inputNumeric') && (control.appearance == null)) + control.appearance = 'Textbox'; + }; + _.each(form.controls, processControl); + } + }; + })(jQuery); diff --git a/public/javascripts/core.validation.js b/public/javascripts/core.validation.js index 55ea892c..3699e10a 100644 --- a/public/javascripts/core.validation.js +++ b/public/javascripts/core.validation.js @@ -291,9 +291,48 @@ apply(); }); }); + + // if the property has a bindDisplayIf we'll automagically create subscriptions + // to track that binding and not bother validating if the property is hidden. + var displayed = true; + if (property.bindDisplayIf != null) + { + // TODO: duplicated from property-editor.js. if this logic continues to + // evolve we should consolidate. but js has no tuple type so it's not obvious + // how to do so. + var bdiKey, bdiValues; + if (_.isString(property.bindDisplayIf)) + { + bdiKey = property.bindDisplayIf; + bdiValues = [ true ]; + } + else + { + bdiKey = _.keys(property.bindDisplayIf)[0]; + bdiValues = property.bindDisplayIf[bdiKey]; + if (!_.isArray(bdiValues)) { bdiValues = [ bdiValues ]; } + } + + subscribe($control, 'self', 'property', bdiKey, function(value) + { + displayed = _.contains(bdiValues, value); + apply(); + }); + } + var lastHasError = false; var apply = function() { + if (displayed === false) + { + if (lastHasError === true) + { + lastHasError = result.hasError = false; + $control.trigger('odkControl-validationChanged', [ property, false ]); + } + return; + } + var passed; if ((validationObj.prereq != null) && (validationObj.prereq.apply(null, params) !== true)) passed = true; // bail out if we fail the precondition. diff --git a/public/javascripts/data.js b/public/javascripts/data.js index e03d29d2..314b13b5 100644 --- a/public/javascripts/data.js +++ b/public/javascripts/data.js @@ -88,7 +88,7 @@ var dataNS = odkmaker.namespace.load('odkmaker.data'); // the current will be upgraded. to define an upgrade, add an upgrade object to any module // whose keys are the number of the version to be upgraded to and values are the functions // that take the form data and update it to conform with that version. - odkmaker.data.currentVersion = 1; + odkmaker.data.currentVersion = 2; odkmaker.data.load = function(formObj) { var version = formObj.metadata.version || 0; @@ -134,7 +134,9 @@ var dataNS = odkmaker.namespace.load('odkmaker.data'); 'Manual (No GPS)': 'placement-map', 'Minimal (spinner)': 'minimal', 'Table': 'label', - 'Horizontal Layout': 'horizontal' + 'Horizontal Layout': 'horizontal', + 'Vertical Slider': 'vertical', + 'Picker': 'picker' }; var addTranslation = function(obj, itextPath, translations) { @@ -392,10 +394,26 @@ var dataNS = odkmaker.namespace.load('odkmaker.data'); binding.attrs.type = 'string'; else if (control.type == 'inputNumeric') { - if (control.kind == 'Integer') - binding.attrs.type = 'int'; - else if (control.kind == 'Decimal') - binding.attrs.type = 'decimal'; + if (control.appearance == 'Textbox') + { + if (control.kind == 'Integer') + binding.attrs.type = 'int'; + else if (control.kind == 'Decimal') + binding.attrs.type = 'decimal'; + } + else + { + // overrides extant input tag with a range tag. + bodyTag.name = 'range'; + if (_.isObject(control.selectRange)) + { + bodyTag.attrs.start = control.selectRange.min; + bodyTag.attrs.end = control.selectRange.max; + } + bodyTag.attrs.step = control.selectStep; + var step = parseFloat(control.selectStep); + binding.attrs.type = (Math.floor(step) === step) ? 'int' : 'decimal'; + } } else if (control.type == 'inputDate') { @@ -513,6 +531,8 @@ var dataNS = odkmaker.namespace.load('odkmaker.data'); } if ((control.type === 'inputDate') && ((control.kind === 'Year and Month') || (control.kind === 'Year'))) bodyTag.attrs.appearance = (control.kind === 'Year') ? 'year' : 'month-year'; + if (control.sliderTicks === false) + bodyTag.attrs.appearance = ((bodyTag.attrs.appearance || '') + ' no-ticks').trim(); // options if (control.options !== undefined) diff --git a/public/javascripts/impl.validation.js b/public/javascripts/impl.validation.js index c79aa52d..ba54ce72 100644 --- a/public/javascripts/impl.validation.js +++ b/public/javascripts/impl.validation.js @@ -79,26 +79,33 @@ var hasOptions = function(val) { return _.isArray(val) && (val.length > 0); }; var xmlLegalChars = function(val) { return /^[0-9a-z_.-]+$/i.test(val); }; var alphaStart = function(val) { return /^[a-z]/i.test(val); }; + var isNumeric = function(val) { return /^[+-]?\d*(\.\d*)?$/.test(val) && /\d/.test(val); }; // the actual definitions: validationNS.validations = { required: { given: [ 'self' ], - check: function(self) { return hasString(self); }, + check: hasString, message: 'This property is required.' }, xmlLegalChars: { given: [ 'self' ], prereq: hasString, - check: function(self) { return xmlLegalChars(self); }, + check: xmlLegalChars, message: 'Only letters, numbers, -, _, and . are allowed.' }, alphaStart: { given: [ 'self' ], prereq: hasString, - check: function(self) { return alphaStart(self); }, + check: alphaStart, message: 'The first character must be a letter.' }, + numeric: { + given: [ 'self' ], + prereq: hasString, + check: isNumeric, + message: 'Please enter a number.' + }, unique: { given: [ 'self', { scope: 'all', property: 'self' } ], prereq: hasString, @@ -147,6 +154,32 @@ }, message: 'One or more Underlying Value is longer than the allowed maximum of 32 characters.' }, + // checks both presence and numericness. i couldn't think of a case you wouldn't want both. + rangeRequired: { + given: [ 'self' ], + check: function(range) + { + return _.isObject(range) && isNumeric(range.min) && isNumeric(range.max); + }, + message: 'Please enter two valid numbers.' + }, + stepDivision: { + given: [ 'self', { scope: 'self', property: 'selectRange' } ], + prereq: function(step, range) + { + return _.isObject(range) && _.all([ step, range.min, range.max ], isNumeric); + }, + check: function(step, range) + { + // can't use % because js float nastiness. + var step = parseFloat(step); + if (step <= 0) return false; + + var quotient = (parseFloat(range.max) - parseFloat(range.min)) / step; + return Math.floor(quotient) === quotient; + }, + message: 'Step must divide the selectable range perfectly into evenly-sized increments.', + }, hasOptions: { given: [ 'self' ], prereq: _.isArray, diff --git a/public/javascripts/property-editor.js b/public/javascripts/property-editor.js index e32eb7b0..2d8a2cc4 100644 --- a/public/javascripts/property-editor.js +++ b/public/javascripts/property-editor.js @@ -49,12 +49,25 @@ if (property.bindDisplayIf != null) { - var parentProperty = $parent.data('odkControl-properties')[property.bindDisplayIf]; - var showHide = function() { $this.toggle(parentProperty.value !== false); } - $parent.on('odkControl-propertiesUpdated', function(_, propId) + // normalize two formats: 'key' just checks bool on that key. + // { 'key': [ 'value1', 'value2' ] } checks a single property against values. + // someday if required we can check multiple properties. + var key, values; + if (_.isString(property.bindDisplayIf)) { - if (propId === property.bindDisplayIf) showHide(); - }); + key = property.bindDisplayIf; + values = [ true ]; + } + else + { + key = _.keys(property.bindDisplayIf)[0]; + values = property.bindDisplayIf[key]; + if (!_.isArray(values)) { values = [ values ]; } + } + + var parentProperty = $parent.data('odkControl-properties')[key]; + var showHide = function() { $this.toggle(_.contains(values, parentProperty.value)); } + $parent.on('odkControl-propertiesUpdated', function(_, propId) { if (propId === key) showHide(); }); showHide(); } }); @@ -119,6 +132,11 @@ }; }; + if (property.optional === false) + $editor.addClass('nonOptional'); + if (property.inclusivity === false) + $editor.addClass('hideInclusivity'); + if ((property.value === false) || (property.value == null)) $inputs.attr('disabled', true); else diff --git a/public/stylesheets/styles.css b/public/stylesheets/styles.css index e16ed97a..408c4707 100644 --- a/public/stylesheets/styles.css +++ b/public/stylesheets/styles.css @@ -1137,7 +1137,10 @@ body > .control.last .controlFlowArrow, content: '\e900'; } -.property-length .inclusiveCtrl, .property-count .inclusiveCtrl { +.nonOptional .editorEnabled { + display: none; +} +.hideInclusivity .inclusiveCtrl, .property-length .inclusiveCtrl, .property-count .inclusiveCtrl { display: none; }