diff --git a/.travis.yml b/.travis.yml index aae912b..c1df0a5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ before_install: - curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash - && sudo apt-get install -y nodejs before_script: - - cd lib/assets && npm install && npm run bundle && cd - + - cd lib/assets && npm install && npm run bundle -- -p && cd - - cp config/database_example.yml config/database.yml - cp config/secrets_example.yml config/secrets.yml - cp config/branding_example.yml config/branding.yml diff --git a/app/views/layouts/_es5_scripts.html.erb b/app/views/layouts/_es5_scripts.html.erb index 8bfc6b8..3d4c9b6 100644 --- a/app/views/layouts/_es5_scripts.html.erb +++ b/app/views/layouts/_es5_scripts.html.erb @@ -14,13 +14,13 @@ - <%= javascript_include_tag 'utils/define.js' %> - <%= javascript_include_tag 'utils/debounce.js' %> - <%= javascript_include_tag 'utils/tinymce.js' %> - <%= javascript_include_tag 'utils/validate.js' %> - <%= javascript_include_tag 'utils/ariatiseForm.js' %> - <%= javascript_include_tag 'utils/filteriseTable.js' %> - <%= javascript_include_tag 'utils/collateTable.js' %> + <%= javascript_include_tag 'utils_es5/define.js' %> + <%= javascript_include_tag 'utils_es5/debounce.js' %> + <%= javascript_include_tag 'utils_es5/tinymce.js' %> + <%= javascript_include_tag 'utils_es5/validate.js' %> + <%= javascript_include_tag 'utils_es5/ariatiseForm.js' %> + <%= javascript_include_tag 'utils_es5/filteriseTable.js' %> + <%= javascript_include_tag 'utils_es5/collateTable.js' %> diff --git a/lib/assets/.eslintrc.json b/lib/assets/.eslintrc.json index 6f67564..6ebd320 100644 --- a/lib/assets/.eslintrc.json +++ b/lib/assets/.eslintrc.json @@ -1,3 +1,6 @@ { - "extends": "airbnb-base" + "extends": "airbnb-base", + "env": { + "jasmine": true + } } \ No newline at end of file diff --git a/lib/assets/README.md b/lib/assets/README.md index 820a2dc..7156ebf 100644 --- a/lib/assets/README.md +++ b/lib/assets/README.md @@ -1,10 +1,31 @@ -## Bundle for development +## Set up +We use WebPack to pre-compile all our assets. Before executing any bundling, please make sure that all the dependencies are installed first by typing: + ``` - npm run bundle +npm install +``` +within lib/assets + +## Assets Bundling + +We have set up two environments, one for development which watching for changes at any .js or .css file to pre-compile on the fly and another for production. For a development environment, i.e. if the developer is making changes, please type: + +``` +npm run bundle ``` -## Bundle for production: +and for production, please type: + ``` - npm run bundle -- -p +npm run bundle -- -p ``` -Remember this will generated output files with a new hash associated in order to prevent browser to use a previous cached version. You will need to stop and start the rails server. \ No newline at end of file + +Note, the above commands have to run within lib/assets directory. + +## Testing + +We use jasmine to write unit tests together with karma for testing in real browser our functionality. Please type the following command to execute every test for JavaScript modules. + +``` +npm test +``` diff --git a/lib/assets/javascripts/constants.js b/lib/assets/javascripts/constants.js new file mode 100644 index 0000000..407b385 --- /dev/null +++ b/lib/assets/javascripts/constants.js @@ -0,0 +1,2 @@ +export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 128; diff --git a/lib/assets/javascripts/utils/ariatiseForm.js b/lib/assets/javascripts/utils/ariatiseForm.js deleted file mode 100644 index 6762df8..0000000 --- a/lib/assets/javascripts/utils/ariatiseForm.js +++ /dev/null @@ -1,122 +0,0 @@ -/* - dmproadmap.utils.ariatiseForm augmentates a HTML form by: - - Associating help text with form controls - - Adding validation state to each form group - - Adding specific attributes for user with assistive technologies. - - For example the following form : -
-
- - -
-
- - -
-
- - -
- -
- will be augmentated as follows: -
-
- - - -
-
- - - -
-
- - -
- -
- and any time the buttton is clicked the validation according to each type (e.g. text, email) will be triggered. An invalid result for a form-control will: - 1. Add has-error class to its form-group parent and aria-invalid="true" to the form-control - 2. Show its help-block following sibling - 3. Prevent form to be submitted -*/ -(function(ctx){ - var requiredFields=(function(selector){ - return $(selector).find('.form-control').filter('[aria-required="true"]'); - }); - var blockHelp=(function (id,type){ - var msg='Please fill out this field with a valid '+type+'.'; //TODO internationalisation - return ''; - }); - var ariaDescribedBy=(function(value){ - return { 'aria-describedby': value }; - }); - var ariaInvalid=(function(value){ - return { 'aria-invalid': value }; - }); - var validationStates={ - hasWarning: 'has-warning', - hasError: 'has-error', - hasSuccess: 'has-success' - }; - var getTypeForSubmittableElement=(function(el){ - // Reference from https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Form-associated_content - if($(el).is('input')){ - return $(el).attr('type'); // available types at https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Form__types - } - else if($(el).is('select')){ - return 'select'; - } - else if($(el).is('textarea')){ - return 'textarea'; - } - else - return 'unknown'; - }); - var isValid=(function(type, value){ - // TODO add more validation for each new type coming along by: - // 1. defining a function at dmproadmap.utils.validate - // 2. adding the case in the switch below - switch(type){ - case 'text': - case 'textarea': - return dmproadmap.utils.validate.text(value); - case 'email': - return dmproadmap.utils.validate.email(value); - default: - return false; - } - }); - var valid=(function(el){ - $(el).parent().removeClass(validationStates.hasError); - $(el).attr(ariaInvalid(false)); - $(el).next().hide(); - }); - var invalid=(function(el){ - $(el).parent().addClass(validationStates.hasError); - $(el).attr(ariaInvalid(true)); - $(el).next().show(); - }); - ctx.init=ctx.init || (function(options){ - if($ && options && options.selector){ - requiredFields(options.selector).each(function(i,el){ - $(el).attr(ariaDescribedBy('help'+i)); - $(el).after(blockHelp('help'+i, getTypeForSubmittableElement(el))); - }); - $(options.selector+' [type="submit"]').click(function(e){ - requiredFields(options.selector).each(function(i,el){ - if(isValid(getTypeForSubmittableElement(el),$(el).val())){ - valid(el); - } - else{ - e.preventDefault(); - invalid(el); - } - }); - }); - } - }); -})(define('dmproadmap.utils.ariatiseForm')); \ No newline at end of file diff --git a/lib/assets/javascripts/utils/collateTable.js b/lib/assets/javascripts/utils/collateTable.js deleted file mode 100644 index a410c12..0000000 --- a/lib/assets/javascripts/utils/collateTable.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - tablesorter is an external library located in vendor/tablesorter. - - it does not provide us with icons though, so we add our own below along - with logic to change them to up/down arrows when the user sorts the column -*/ - -(function(ctx){ - ctx.init = ctx.init || (function(options){ - if($ && options && options.selector){ - /* Bind the table to the external tablesorter JS (see vendor/tablesorter) */ - $(options.selector).tablesorter({ - theme: 'bootstrap_3', - headerTemplate: '{content} {icon}', - cssIconAsc: 'fa fa-sort-asc', - cssIconDesc: 'fa fa-sort-desc', - cssIconNone: 'fa fa-sort' - }); - } - }); -})(define('dmproadmap.utils.collateTable')); \ No newline at end of file diff --git a/lib/assets/javascripts/utils/debounce.js b/lib/assets/javascripts/utils/debounce.js index aa00c85..8013d68 100644 --- a/lib/assets/javascripts/utils/debounce.js +++ b/lib/assets/javascripts/utils/debounce.js @@ -1,33 +1,24 @@ -(function(ctx){ - /* - Delays invoking of the function passed until after wait milliseconds have elapsed since - the last time the debounced function was invoked. - @param {function} func - the function to execute later on - @param {number} wait - the number of milliseconds to wait until func is executed - @returns The debounced function. It comes with a cancel method to cancel delayed func invocation - */ - ctx.debounce = ctx.debounce || (function(func, wait){ - var timeoutID = null; - function cancel() { - if(timeoutID !== null){ - clearTimeout(timeoutID); - return true; - } - return false; - } - return (function() { - var debounced = function() { - var ctx = this; - var args = arguments; - var later = function() { - timeoutID = null; - func.apply(ctx, args); - } - clearTimeout(timeoutID); - timeoutID = setTimeout(later, wait || 1000); - } - debounced.cancel = cancel; - return debounced; - })(); - }); -})(define('dmproadmap.utils')); \ No newline at end of file +import { isFunction, isNumber } from './isType'; + +export default function debounce(func, wait) { + if (isFunction(func) && (wait || isNumber(wait))) { + let timeoutID = null; + const closureDebounce = (...args) => { + const delayed = () => { + timeoutID = null; + func.apply(this, args); + }; + clearTimeout(timeoutID); + timeoutID = setTimeout(delayed, wait || 1000); + }; + closureDebounce.cancel = () => { + if (timeoutID) { + clearTimeout(timeoutID); + return true; + } + return false; + }; + return closureDebounce; + } + return null; +} diff --git a/lib/assets/javascripts/utils/define.js b/lib/assets/javascripts/utils/define.js deleted file mode 100644 index b1e44ed..0000000 --- a/lib/assets/javascripts/utils/define.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - define is an utility to avoid namespace collisions. This function assumes that the root is dmproadmap. - The local names are visited and constructed if they do not exist to return the context - object of the last local name specified in the String. - The string has to be separated by '.' and currently there are no restrictions for local names - (e.g. empty strings) are allowed. - @param value String value representing a fully namespace - @return The last local name context from the hierarchy of objects - - Usage: define('dmproadmap.a.b.c.d') will return the context of d by creating the following hierarchy if - does not exist: - dmproadmap: { - a: { - b: { - c: { - d: {} - } - } - } - } -*/ -var define=function(value){ - var root='dmproadmap'; - if(Object.prototype.toString.call(value) === '[object String]'){ - var arrayNs=value.split('.'); - var restNs; - if(arrayNs[0] === root){ - var restNs=arrayNs.slice(1); - return restNs.reduce(function(ns, value){ - if(!ns[value]) return ns[value]={}; - return ns[value]; - }, window[root] = window[root] || {}); - } - } -}; \ No newline at end of file diff --git a/lib/assets/javascripts/utils/filteriseTable.js b/lib/assets/javascripts/utils/filteriseTable.js deleted file mode 100644 index 25e2aff..0000000 --- a/lib/assets/javascripts/utils/filteriseTable.js +++ /dev/null @@ -1,46 +0,0 @@ -/* - filteriseTable adds filter capabilities to an HTML table - table rows are shown/hidden as the user enters text into the filter input field - all rows are made visible when the user clicks the 'clear' icon -*/ -(function(ctx){ - - var filter = (function(el){ - var query = $(el).val(), - regex = new RegExp(query, 'i'); - - $.each($(el).closest("table").find("tbody tr"), function(idx, tr){ - if(regex.test($(tr).text())){ - $(tr).show(); - }else{ - $(tr).hide(); - } - }); - }); - - var clear = (function(el){ - $(el).val(''); - $(el).closest("table").find("tbody tr").show(); - }); - - ctx.init = ctx.init || (function(options){ - if($ && options && options.selector){ - var id = $(this).attr("id"); - - /* initialize a debounced listener for the filter box */ - var debounced = dmproadmap.utils.debounce(filter); - - /* Bind the clear function to the clear icon's click event */ - $(options.selector).keyup(function(){ - debounced(this); - }); - - $(options.selector).siblings("#clear_filter").click(function(e){ - e.preventDefault(); - clear(this); - debounced.cancel(); - }); - } - }); - -})(define('dmproadmap.utils.filteriseTable')); \ No newline at end of file diff --git a/lib/assets/javascripts/utils/isType.js b/lib/assets/javascripts/utils/isType.js new file mode 100644 index 0000000..f8fd966 --- /dev/null +++ b/lib/assets/javascripts/utils/isType.js @@ -0,0 +1,68 @@ +const toString = Object.prototype.toString; +/* + Checks whether or the value passed is type Array. + @param value to check + @return true or false +*/ +export const isArray = Array.isArray; +/* + Checks whether or the value passed is type boolean. + Note the use of new is discouraged, e.g. new Boolean(true) and might return false in some cases + @param value to check + @return true or false +*/ +export const isBoolean = value => typeof value === 'boolean'; +/* + Checks whether or the value passed is type Date. + @param value to check + @return true or false +*/ +export const isDate = value => toString.call(value) === '[object Date]'; +/* + Checks whether or the value passed is type function. + Note the use of new is discouraged, e.g. new Function(...) and might return false in some cases + @param value to check + @return true or false +*/ +export const isFunction = value => typeof value === 'function'; +/* + Checks whether or the value passed is type number. + Note the use of new is discouraged, e.g. new Number(1) and might return false in some cases. + This method will return true for NaN and Infinity too. + @param value to check + @return true or false +*/ +export const isNumber = value => typeof value === 'number'; +/* + Checks whether or the value passed is type null. + @param value to check + @return true or false +*/ +export const isNull = value => value === null; +/* + Checks whether or the value passed is type object. + This will return true for any kind of object (Array, Date, RegExp ...) so consider + using more accurate method defined here. + @param value to check + @return true or false +*/ +export const isObject = value => value !== null && typeof value === 'object'; +/* + Checks whether or the value passed is type RegExp + @param value to check + @return true or false +*/ +export const isRegExp = value => toString.call(value) === '[object RegExp]'; +/* + Checks whether or the value passed is type string. + Note the use of new is discouraged, e.g. new String('aaa') and might return false in some cases + @param value to check + @return true or false +*/ +export const isString = value => typeof value === 'string'; +/* + Checks whether or the value passed is type undefined. + @param value to check + @return true or false +*/ +export const isUndefined = value => value === undefined; diff --git a/lib/assets/javascripts/utils/isTypeSpec.js b/lib/assets/javascripts/utils/isTypeSpec.js new file mode 100644 index 0000000..f85ef1a --- /dev/null +++ b/lib/assets/javascripts/utils/isTypeSpec.js @@ -0,0 +1,146 @@ +import { + isArray, + isBoolean, + isDate, + isFunction, + isNumber, + isNull, + isObject, + isRegExp, + isString, + isUndefined } from './isType'; + +describe('isArray test suite', () => { + it('expect true for []', () => expect(isArray([])).toBe(true)); + it('expect false for Boolean', () => expect(isArray(true)).toBe(false)); + it('expect false for Date', () => expect(isArray(new Date())).toBe(false)); + it('expect false for Function', () => expect(isArray(() => {})).toBe(false)); + it('expect false for Number', () => expect(isArray(1)).toBe(false)); + it('expect false for Null', () => expect(isArray(null)).toBe(false)); + it('expect false for Object', () => expect(isArray({})).toBe(false)); + it('expect false for RegExp', () => expect(isArray(/foo/)).toBe(false)); + it('expect false for String', () => expect(isArray('Hello World!')).toBe(false)); + it('expect false for Undefined', () => expect(isArray(undefined)).toBe(false)); + it('expect false for zero args', () => expect(isArray()).toBe(false)); +}); + +describe('isBoolean test suite', () => { + it('expect true for true', () => expect(isBoolean(true)).toBe(true)); + it('expect true for false', () => expect(isBoolean(true)).toBe(true)); + it('expect false for []', () => expect(isBoolean([])).toBe(false)); + it('expect false for Date', () => expect(isBoolean(new Date())).toBe(false)); + it('expect false for Function', () => expect(isBoolean(() => {})).toBe(false)); + it('expect false for Number', () => expect(isBoolean(1)).toBe(false)); + it('expect false for Null', () => expect(isBoolean(null)).toBe(false)); + it('expect false for Object', () => expect(isBoolean({})).toBe(false)); + it('expect false for RegExp', () => expect(isBoolean(/foo/)).toBe(false)); + it('expect false for String', () => expect(isBoolean('Hello World!')).toBe(false)); + it('expect false for Undefined', () => expect(isBoolean(undefined)).toBe(false)); + it('expect false for zero args', () => expect(isBoolean()).toBe(false)); +}); + +describe('isDate test suite', () => { + it('expect true for Date', () => expect(isDate(new Date())).toBe(true)); + it('expect fase for []', () => expect(isDate([])).toBe(false)); + it('expect fase for Boolean', () => expect(isDate(true)).toBe(false)); + it('expect fase for Function', () => expect(isDate(() => {})).toBe(false)); + it('expect fase for Number', () => expect(isDate(1)).toBe(false)); + it('expect fase for Null', () => expect(isDate(null)).toBe(false)); + it('expect fase for Object', () => expect(isDate({})).toBe(false)); + it('expect fase for RegExp', () => expect(isDate(/foo/)).toBe(false)); + it('expect fase for String', () => expect(isDate('Hello World!')).toBe(false)); + it('expect fase for zero args', () => expect(isDate(undefined)).toBe(false)); +}); + +describe('isFunction test suite', () => { + it('expect true for Function', () => expect(isFunction(() => {})).toBe(true)); + it('expect false for []', () => expect(isFunction([])).toBe(false)); + it('expect false for Boolean', () => expect(isFunction(true)).toBe(false)); + it('expect false for Date', () => expect(isFunction(new Date())).toBe(false)); + it('expect false for Number', () => expect(isFunction(1)).toBe(false)); + it('expect false for Null', () => expect(isFunction(null)).toBe(false)); + it('expect false for Object', () => expect(isFunction({})).toBe(false)); + it('expect false for RegExp', () => expect(isFunction(/foo/)).toBe(false)); + it('expect false for String', () => expect(isFunction('Hello World!')).toBe(false)); + it('expect false for undefined', () => expect(isFunction(undefined)).toBe(false)); + it('expect false for zero args', () => expect(isFunction()).toBe(false)); +}); + +describe('isNumber test suite', () => { + it('expect true for 1', () => expect(isNumber(1)).toBe(true)); + it('expect true for NaN', () => expect(isNumber(NaN)).toBe(true)); + it('expect true for Infinity', () => expect(isNumber(Infinity)).toBe(true)); + it('expect false for []', () => expect(isNumber([])).toBe(false)); + it('expect false for Boolean', () => expect(isNumber(true)).toBe(false)); + it('expect false for Date', () => expect(isNumber(new Date())).toBe(false)); + it('expect false for Null', () => expect(isNumber(null)).toBe(false)); + it('expect false for Object', () => expect(isNumber({})).toBe(false)); + it('expect false for RegExp', () => expect(isNumber(/foo/)).toBe(false)); + it('expect false for String', () => expect(isNumber('Hello World!')).toBe(false)); + it('expect false for undefined', () => expect(isNumber(undefined)).toBe(false)); + it('expect false for zero args', () => expect(isNumber()).toBe(false)); +}); + +describe('isNull test suite', () => { + it('expect true for Null', () => expect(isNull(null)).toBe(true)); + it('expect false for []', () => expect(isNull([])).toBe(false)); + it('expect false for Boolean', () => expect(isNull(true)).toBe(false)); + it('expect false for Date', () => expect(isNull(new Date())).toBe(false)); + it('expect false for Number', () => expect(isNull(1)).toBe(false)); + it('expect false for Object', () => expect(isNull(Object.create(null))).toBe(false)); + it('expect false for RegExp', () => expect(isNull(/foo/)).toBe(false)); + it('expect false for String', () => expect(isNull('null')).toBe(false)); + it('expect false for undefined', () => expect(isNull(undefined)).toBe(false)); + it('expect false for zero args', () => expect(isNull()).toBe(false)); +}); + +describe('isObject test suite', () => { + it('expect true for {}', () => expect(isObject({})).toBe(true)); + it('expect true for []', () => expect(isObject([])).toBe(true)); + it('expect true for Date', () => expect(isObject(new Date())).toBe(true)); + it('expect true for RegExp', () => expect(isObject(/foo/)).toBe(true)); + it('expect false for Number', () => expect(isObject(1)).toBe(false)); + it('expect false for Null', () => expect(isObject(null)).toBe(false)); + it('expect false for String', () => expect(isObject('Hello World!')).toBe(false)); + it('expect false for undefined', () => expect(isObject(undefined)).toBe(false)); + it('expect false for zero args', () => expect(isObject()).toBe(false)); +}); + +describe('isRegExp test suite', () => { + it('expect true for RegExp', () => expect(isRegExp(/foo/)).toBe(true)); + it('expect true for RegExp', () => expect(isRegExp(new RegExp('foo'))).toBe(true)); + it('expect false for []', () => expect(isRegExp([])).toBe(false)); + it('expect false for Boolean', () => expect(isRegExp(true)).toBe(false)); + it('expect false for Date', () => expect(isRegExp(new Date())).toBe(false)); + it('expect false for Number', () => expect(isRegExp(1)).toBe(false)); + it('expect false for Null', () => expect(isRegExp(null)).toBe(false)); + it('expect false for String', () => expect(isRegExp('Hello World!')).toBe(false)); + it('expect false for undefined', () => expect(isRegExp(undefined)).toBe(false)); + it('expect false for zero args', () => expect(isRegExp()).toBe(false)); +}); + +describe('isString test suite', () => { + it('expect true for String', () => expect(isString('Hello World!')).toBe(true)); + it('expect false for []', () => expect(isString([])).toBe(false)); + it('expect false for Boolean', () => expect(isString(true)).toBe(false)); + it('expect false for Date', () => expect(isString(new Date())).toBe(false)); + it('expect false for Function', () => expect(isString(() => {})).toBe(false)); + it('expect false for Number', () => expect(isString(1)).toBe(false)); + it('expect false for Null', () => expect(isString(null)).toBe(false)); + it('expect false for RegExp', () => expect(isString(/foo/)).toBe(false)); + it('expect false for Undefined', () => expect(isString(undefined)).toBe(false)); + it('expect false for zero args', () => expect(isString()).toBe(false)); +}); + +describe('isUndefined test suite', () => { + it('expect true for Undefined', () => expect(isUndefined(undefined)).toBe(true)); + it('expect false for zero args', () => expect(isUndefined()).toBe(true)); + it('expect false for []', () => expect(isUndefined([])).toBe(false)); + it('expect false for Boolean', () => expect(isUndefined(true)).toBe(false)); + it('expect false for Date', () => expect(isUndefined(new Date())).toBe(false)); + it('expect false for Function', () => expect(isUndefined(() => {})).toBe(false)); + it('expect false for Number', () => expect(isUndefined(1)).toBe(false)); + it('expect false for Null', () => expect(isUndefined(null)).toBe(false)); + it('expect false for RegExp', () => expect(isUndefined(/foo/)).toBe(false)); + it('expect false for String', () => expect(isUndefined('Hello World!')).toBe(false)); +}); diff --git a/lib/assets/javascripts/utils/isValidInputType.js b/lib/assets/javascripts/utils/isValidInputType.js new file mode 100644 index 0000000..6073730 --- /dev/null +++ b/lib/assets/javascripts/utils/isValidInputType.js @@ -0,0 +1,56 @@ +import { isString, isNumber } from './isType'; +import { + PASSWORD_MIN_LENGTH, + PASSWORD_MAX_LENGTH } from '../constants'; + +/* + Validates whether or not the value passed matches to a valid email + @param value String to search for a match + @return true or false +*/ +export const isValidEmail = (value) => { + if (isString(value)) { + return /[^@\s]+@(?:[-a-z0-9]+\.)+[a-z]{2,}$/.test(value); + } + return false; +}; + +/* + Validates whether or not the value passed is a valid number. + @param value Number to validate +*/ +export const isValidNumber = (value) => { + if (isString(value)) { // Only if is string value we try to convert to Number + // since Number([]), Number(new Date()), Number(null) are converted to zero + return !isNaN(Number(value)); + } + return isNumber(value); +}; + +/* + Validates whether or not the value passed falls between the min and max length + string specified for a password. + @param value String to verify its length + @return true or false +*/ +export const isValidPassword = (value) => { + if (isString(value)) { + const trimmed = value.trim(); + return trimmed.length >= PASSWORD_MIN_LENGTH && + trimmed.length <= PASSWORD_MAX_LENGTH; + } + return false; +}; + +/* + Validates whether or not the value passed is a non-empty String type. + @param value String to verify its length + @return true or false +*/ +export const isValidText = (value) => { + if (isString(value)) { + return value.trim().length > 0; + } + return false; +}; + diff --git a/lib/assets/javascripts/utils/isValidInputTypeSpec.js b/lib/assets/javascripts/utils/isValidInputTypeSpec.js new file mode 100644 index 0000000..6eaf31f --- /dev/null +++ b/lib/assets/javascripts/utils/isValidInputTypeSpec.js @@ -0,0 +1,43 @@ +import { + isValidEmail, + isValidNumber, + isValidPassword, + isValidText, +} from './isValidInputType'; + +describe('isValidEmail test suite', () => { + it('expect true for someone@somewhere.com', () => expect(isValidEmail('someone@somewhere.com')).toBe(true)); + it('expect true for s@somewhere.ac.uk', () => expect(isValidEmail('s@somewhere.ac.uk')).toBe(true)); + it('expect true for someone@somewhere.gov.ac.uk', () => expect(isValidEmail('someone@somewhere.gov.ac.uk')).toBe(true)); + it('expect false for @somewhere.com', () => expect(isValidEmail('@somewhere.com')).toBe(false)); + it('expect false for s@somewhere.ac.u', () => expect(isValidEmail('s@somewhere.ac.u')).toBe(false)); + it('expect false for someone@somewhere.gov.ac.u', () => expect(isValidEmail('someone@somewhere.gov.ac.u')).toBe(false)); +}); + +describe('isValidNumber test suite', () => { + it('expect true for 1', () => expect(isValidNumber(1)).toBe(true)); + it('expect true for \'1\'', () => expect(isValidNumber('1')).toBe(true)); + it('expect true for Infinity', () => expect(isValidNumber(Infinity)).toBe(true)); + it('expect false for Array', () => expect(isValidNumber([])).toBe(false)); + it('expect false for Boolean', () => expect(isValidNumber(true)).toBe(false)); + it('expect false for Date', () => expect(isValidNumber(new Date())).toBe(false)); + it('expect false for Function', () => expect(isValidNumber(() => {})).toBe(false)); + it('expect false for Object', () => expect(isValidNumber({})).toBe(false)); + it('expect false for RegExp', () => expect(isValidNumber(/foo/)).toBe(false)); + it('expect false for String', () => expect(isValidNumber('Hello World!')).toBe(false)); + it('expect false for Null', () => expect(isValidNumber(null)).toBe(false)); + it('expect false for Array', () => expect(isValidNumber(undefined)).toBe(false)); + it('expect false for zero args', () => expect(isValidNumber()).toBe(false)); +}); + +describe('isValidPassword test suite', () => { + it('expect true for hjkl7890', () => expect(isValidPassword('hjkl7890')).toBe(true)); + it('expect false for hjkl', () => expect(isValidPassword('hjkl')).toBe(false)); + it('expect false for \' abcd \'', () => expect(isValidPassword(' abcd ')).toBe(false)); + it('expect false for non-string', () => expect(isValidPassword(null)).toBe(false)); +}); + +describe('isValid test suite', () => { + it('expect true for h', () => expect(isValidText('h')).toBe(true)); + it('expect false for \' \'', () => expect(isValidText(' ')).toBe(false)); +}); diff --git a/lib/assets/javascripts/utils/tinymce.js b/lib/assets/javascripts/utils/tinymce.js deleted file mode 100644 index 657a352..0000000 --- a/lib/assets/javascripts/utils/tinymce.js +++ /dev/null @@ -1,101 +0,0 @@ -(function(ctx){ - ctx.tinymce = ctx.tinymce || {}; - var tinymce = ctx.tinymce; - /* - Factory to create tinyMCE object that overrides defaults with the given object options passed - @param options An object with known properties for tinyMCE - @return A tinyMCE object or throws an error if jQuery is not present when this function is invoked - */ - tinymce.factory = tinymce.factory || (function(options){ - if($){ - return $.extend(true, { - selector: 'textarea.tinymce', - statusbar: false, - menubar: false, - toolbar: 'bold italic | bullist numlist | link | table', - plugins: 'table autoresize link paste advlist', - advlist_bullet_styles: 'circle,disc,square', //only disc bullets display on htmltoword - target_list: false, - autoresize_min_height: 130, - autoresize_bottom_margin: 10, - extended_valid_elements: 'iframe[tooltip] , a[href|target=_blank]', - extended_valid_elements: 'a[href|target=_blank]', - paste_auto_cleanup_on_paste : true, - paste_remove_styles: true, - paste_retain_style_properties: 'none', - paste_convert_middot_lists: true, - paste_remove_styles_if_webkit: true, - paste_remove_spans: true, - paste_strip_class_attributes: "all", - table_default_attributes: { - border: 1 - } - }, options); - } - else - throw Error('$ is undefined'); - }); - /* - Initialises a tinymce editor given the object passed. If the object is - undefined, a default object generated by dmproadmap.utils.tinymce.factory will be used - @param obj An object with known properties for tinyMCE - */ - tinymce.init = tinymce.init || (function(options){ - if(window.tinymce){ - //TODO, there is a bug on Firefox when init is executed again after partially refreshing page (e.g. https://github.com/tinymce/tinymce/issues/3763) - window.tinymce.init(tinymce.factory(options)); - } - }); - /* - Finds any tinyMCE editor whose target element/textarea has className passed - @param className A string representing the class name of the tinyMCE editor target element/textarea to look for - @return An Array of tinymce.Editor objects - */ - tinymce.findEditorsByClassName = tinymce.findEditorsByClassName || (function(className){ - if($ && window.tinymce && className){ - return window.tinymce.editors.reduce(function(acc,e){ - if($(e.getElement()).hasClass(className)) - return acc.concat([e]); - return acc; - },[]); - } - return []; - }); - /* - Finds a tinyMCE editor whose target element/textarea has id passed - @param id A string representing the id of the tinyMCE editor target element/textarea to look for - @return tinymce.Editor object or undefined if not found - */ - tinymce.findEditorById = tinymce.findEditorById || (function(id){ - if($ && window.tinymce && id){ - // Usage of Array.prototype.find below is desired, however IE does not support it. - for(var i=0, l=window.tinymce.editors.length;i 0; - return false; - }); - /* - Validates whether or not the value matches the regular expression - for an email - @param value String the to search for a match - @return true if there is match between the email regex and the specific string; otherwise, false. - */ - ctx.email = ctx.email || (function(value){ - if(Object.prototype.toString.call(value) === '[object String]') - return /[^@\s]+@(?:[-a-z0-9]+\.)+[a-z]{2,}$/.test(value); - return false; - }); -})(define('dmproadmap.utils.validate')); \ No newline at end of file diff --git a/lib/assets/javascripts/utils_es5/ariatiseForm.js b/lib/assets/javascripts/utils_es5/ariatiseForm.js new file mode 100644 index 0000000..6762df8 --- /dev/null +++ b/lib/assets/javascripts/utils_es5/ariatiseForm.js @@ -0,0 +1,122 @@ +/* + dmproadmap.utils.ariatiseForm augmentates a HTML form by: + - Associating help text with form controls + - Adding validation state to each form group + - Adding specific attributes for user with assistive technologies. + + For example the following form : +
+
+ + +
+
+ + +
+
+ + +
+ +
+ will be augmentated as follows: +
+
+ + + +
+
+ + + +
+
+ + +
+ +
+ and any time the buttton is clicked the validation according to each type (e.g. text, email) will be triggered. An invalid result for a form-control will: + 1. Add has-error class to its form-group parent and aria-invalid="true" to the form-control + 2. Show its help-block following sibling + 3. Prevent form to be submitted +*/ +(function(ctx){ + var requiredFields=(function(selector){ + return $(selector).find('.form-control').filter('[aria-required="true"]'); + }); + var blockHelp=(function (id,type){ + var msg='Please fill out this field with a valid '+type+'.'; //TODO internationalisation + return ''; + }); + var ariaDescribedBy=(function(value){ + return { 'aria-describedby': value }; + }); + var ariaInvalid=(function(value){ + return { 'aria-invalid': value }; + }); + var validationStates={ + hasWarning: 'has-warning', + hasError: 'has-error', + hasSuccess: 'has-success' + }; + var getTypeForSubmittableElement=(function(el){ + // Reference from https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Form-associated_content + if($(el).is('input')){ + return $(el).attr('type'); // available types at https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Form__types + } + else if($(el).is('select')){ + return 'select'; + } + else if($(el).is('textarea')){ + return 'textarea'; + } + else + return 'unknown'; + }); + var isValid=(function(type, value){ + // TODO add more validation for each new type coming along by: + // 1. defining a function at dmproadmap.utils.validate + // 2. adding the case in the switch below + switch(type){ + case 'text': + case 'textarea': + return dmproadmap.utils.validate.text(value); + case 'email': + return dmproadmap.utils.validate.email(value); + default: + return false; + } + }); + var valid=(function(el){ + $(el).parent().removeClass(validationStates.hasError); + $(el).attr(ariaInvalid(false)); + $(el).next().hide(); + }); + var invalid=(function(el){ + $(el).parent().addClass(validationStates.hasError); + $(el).attr(ariaInvalid(true)); + $(el).next().show(); + }); + ctx.init=ctx.init || (function(options){ + if($ && options && options.selector){ + requiredFields(options.selector).each(function(i,el){ + $(el).attr(ariaDescribedBy('help'+i)); + $(el).after(blockHelp('help'+i, getTypeForSubmittableElement(el))); + }); + $(options.selector+' [type="submit"]').click(function(e){ + requiredFields(options.selector).each(function(i,el){ + if(isValid(getTypeForSubmittableElement(el),$(el).val())){ + valid(el); + } + else{ + e.preventDefault(); + invalid(el); + } + }); + }); + } + }); +})(define('dmproadmap.utils.ariatiseForm')); \ No newline at end of file diff --git a/lib/assets/javascripts/utils_es5/collateTable.js b/lib/assets/javascripts/utils_es5/collateTable.js new file mode 100644 index 0000000..a410c12 --- /dev/null +++ b/lib/assets/javascripts/utils_es5/collateTable.js @@ -0,0 +1,21 @@ +/* + tablesorter is an external library located in vendor/tablesorter. + + it does not provide us with icons though, so we add our own below along + with logic to change them to up/down arrows when the user sorts the column +*/ + +(function(ctx){ + ctx.init = ctx.init || (function(options){ + if($ && options && options.selector){ + /* Bind the table to the external tablesorter JS (see vendor/tablesorter) */ + $(options.selector).tablesorter({ + theme: 'bootstrap_3', + headerTemplate: '{content} {icon}', + cssIconAsc: 'fa fa-sort-asc', + cssIconDesc: 'fa fa-sort-desc', + cssIconNone: 'fa fa-sort' + }); + } + }); +})(define('dmproadmap.utils.collateTable')); \ No newline at end of file diff --git a/lib/assets/javascripts/utils_es5/debounce.js b/lib/assets/javascripts/utils_es5/debounce.js new file mode 100644 index 0000000..aa00c85 --- /dev/null +++ b/lib/assets/javascripts/utils_es5/debounce.js @@ -0,0 +1,33 @@ +(function(ctx){ + /* + Delays invoking of the function passed until after wait milliseconds have elapsed since + the last time the debounced function was invoked. + @param {function} func - the function to execute later on + @param {number} wait - the number of milliseconds to wait until func is executed + @returns The debounced function. It comes with a cancel method to cancel delayed func invocation + */ + ctx.debounce = ctx.debounce || (function(func, wait){ + var timeoutID = null; + function cancel() { + if(timeoutID !== null){ + clearTimeout(timeoutID); + return true; + } + return false; + } + return (function() { + var debounced = function() { + var ctx = this; + var args = arguments; + var later = function() { + timeoutID = null; + func.apply(ctx, args); + } + clearTimeout(timeoutID); + timeoutID = setTimeout(later, wait || 1000); + } + debounced.cancel = cancel; + return debounced; + })(); + }); +})(define('dmproadmap.utils')); \ No newline at end of file diff --git a/lib/assets/javascripts/utils_es5/define.js b/lib/assets/javascripts/utils_es5/define.js new file mode 100644 index 0000000..b1e44ed --- /dev/null +++ b/lib/assets/javascripts/utils_es5/define.js @@ -0,0 +1,35 @@ +/* + define is an utility to avoid namespace collisions. This function assumes that the root is dmproadmap. + The local names are visited and constructed if they do not exist to return the context + object of the last local name specified in the String. + The string has to be separated by '.' and currently there are no restrictions for local names + (e.g. empty strings) are allowed. + @param value String value representing a fully namespace + @return The last local name context from the hierarchy of objects + + Usage: define('dmproadmap.a.b.c.d') will return the context of d by creating the following hierarchy if + does not exist: + dmproadmap: { + a: { + b: { + c: { + d: {} + } + } + } + } +*/ +var define=function(value){ + var root='dmproadmap'; + if(Object.prototype.toString.call(value) === '[object String]'){ + var arrayNs=value.split('.'); + var restNs; + if(arrayNs[0] === root){ + var restNs=arrayNs.slice(1); + return restNs.reduce(function(ns, value){ + if(!ns[value]) return ns[value]={}; + return ns[value]; + }, window[root] = window[root] || {}); + } + } +}; \ No newline at end of file diff --git a/lib/assets/javascripts/utils_es5/filteriseTable.js b/lib/assets/javascripts/utils_es5/filteriseTable.js new file mode 100644 index 0000000..25e2aff --- /dev/null +++ b/lib/assets/javascripts/utils_es5/filteriseTable.js @@ -0,0 +1,46 @@ +/* + filteriseTable adds filter capabilities to an HTML table + table rows are shown/hidden as the user enters text into the filter input field + all rows are made visible when the user clicks the 'clear' icon +*/ +(function(ctx){ + + var filter = (function(el){ + var query = $(el).val(), + regex = new RegExp(query, 'i'); + + $.each($(el).closest("table").find("tbody tr"), function(idx, tr){ + if(regex.test($(tr).text())){ + $(tr).show(); + }else{ + $(tr).hide(); + } + }); + }); + + var clear = (function(el){ + $(el).val(''); + $(el).closest("table").find("tbody tr").show(); + }); + + ctx.init = ctx.init || (function(options){ + if($ && options && options.selector){ + var id = $(this).attr("id"); + + /* initialize a debounced listener for the filter box */ + var debounced = dmproadmap.utils.debounce(filter); + + /* Bind the clear function to the clear icon's click event */ + $(options.selector).keyup(function(){ + debounced(this); + }); + + $(options.selector).siblings("#clear_filter").click(function(e){ + e.preventDefault(); + clear(this); + debounced.cancel(); + }); + } + }); + +})(define('dmproadmap.utils.filteriseTable')); \ No newline at end of file diff --git a/lib/assets/javascripts/utils_es5/tinymce.js b/lib/assets/javascripts/utils_es5/tinymce.js new file mode 100644 index 0000000..657a352 --- /dev/null +++ b/lib/assets/javascripts/utils_es5/tinymce.js @@ -0,0 +1,101 @@ +(function(ctx){ + ctx.tinymce = ctx.tinymce || {}; + var tinymce = ctx.tinymce; + /* + Factory to create tinyMCE object that overrides defaults with the given object options passed + @param options An object with known properties for tinyMCE + @return A tinyMCE object or throws an error if jQuery is not present when this function is invoked + */ + tinymce.factory = tinymce.factory || (function(options){ + if($){ + return $.extend(true, { + selector: 'textarea.tinymce', + statusbar: false, + menubar: false, + toolbar: 'bold italic | bullist numlist | link | table', + plugins: 'table autoresize link paste advlist', + advlist_bullet_styles: 'circle,disc,square', //only disc bullets display on htmltoword + target_list: false, + autoresize_min_height: 130, + autoresize_bottom_margin: 10, + extended_valid_elements: 'iframe[tooltip] , a[href|target=_blank]', + extended_valid_elements: 'a[href|target=_blank]', + paste_auto_cleanup_on_paste : true, + paste_remove_styles: true, + paste_retain_style_properties: 'none', + paste_convert_middot_lists: true, + paste_remove_styles_if_webkit: true, + paste_remove_spans: true, + paste_strip_class_attributes: "all", + table_default_attributes: { + border: 1 + } + }, options); + } + else + throw Error('$ is undefined'); + }); + /* + Initialises a tinymce editor given the object passed. If the object is + undefined, a default object generated by dmproadmap.utils.tinymce.factory will be used + @param obj An object with known properties for tinyMCE + */ + tinymce.init = tinymce.init || (function(options){ + if(window.tinymce){ + //TODO, there is a bug on Firefox when init is executed again after partially refreshing page (e.g. https://github.com/tinymce/tinymce/issues/3763) + window.tinymce.init(tinymce.factory(options)); + } + }); + /* + Finds any tinyMCE editor whose target element/textarea has className passed + @param className A string representing the class name of the tinyMCE editor target element/textarea to look for + @return An Array of tinymce.Editor objects + */ + tinymce.findEditorsByClassName = tinymce.findEditorsByClassName || (function(className){ + if($ && window.tinymce && className){ + return window.tinymce.editors.reduce(function(acc,e){ + if($(e.getElement()).hasClass(className)) + return acc.concat([e]); + return acc; + },[]); + } + return []; + }); + /* + Finds a tinyMCE editor whose target element/textarea has id passed + @param id A string representing the id of the tinyMCE editor target element/textarea to look for + @return tinymce.Editor object or undefined if not found + */ + tinymce.findEditorById = tinymce.findEditorById || (function(id){ + if($ && window.tinymce && id){ + // Usage of Array.prototype.find below is desired, however IE does not support it. + for(var i=0, l=window.tinymce.editors.length;i 0; + return false; + }); + /* + Validates whether or not the value matches the regular expression + for an email + @param value String the to search for a match + @return true if there is match between the email regex and the specific string; otherwise, false. + */ + ctx.email = ctx.email || (function(value){ + if(Object.prototype.toString.call(value) === '[object String]') + return /[^@\s]+@(?:[-a-z0-9]+\.)+[a-z]{2,}$/.test(value); + return false; + }); +})(define('dmproadmap.utils.validate')); \ No newline at end of file diff --git a/lib/assets/karma.conf.js b/lib/assets/karma.conf.js new file mode 100644 index 0000000..3e95689 --- /dev/null +++ b/lib/assets/karma.conf.js @@ -0,0 +1,72 @@ +// Karma configuration +const webpackConfig = require('./webpack.karma.config.js'); + +module.exports = function karmaConfig(config) { + config.set({ + + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: '', + + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['jasmine'], + + + // list of files / patterns to load in the browser + files: [ + './node_modules/phantomjs-polyfill/bind-polyfill.js', + './javascripts/**/*Spec.js', + ], + + + // list of files to exclude + exclude: [ + ], + + + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + './javascripts/**/*Spec.js': ['webpack'], + }, + + webpack: webpackConfig, + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ['progress'], + + + // web server port + port: 9876, + + + // enable / disable colors in the output (reporters and logs) + colors: true, + + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || + // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + // browsers: ['Chrome'], + browsers: ['Chrome'], + + // Continuous Integration mode + // if true, Karma captures browsers, runs the tests and exits + singleRun: false, + + // Concurrency level + // how many browser should be started simultaneous + concurrency: Infinity, + }); +}; diff --git a/lib/assets/package.json b/lib/assets/package.json index 81b0e14..4d13c5e 100644 --- a/lib/assets/package.json +++ b/lib/assets/package.json @@ -4,7 +4,7 @@ "description": "Roadmap front-end dependencies", "main": "null", "scripts": { - "test": "echo \"There are no test in place yet\" && exit 0", + "test": "node_modules/.bin/karma start", "bundle": "node_modules/.bin/webpack" }, "repository": { @@ -42,6 +42,11 @@ "eslint-plugin-import": "^2.7.0", "extract-text-webpack-plugin": "^3.0.0", "file-loader": "^0.11.2", + "jasmine-core": "^2.7.0", + "karma": "^1.7.0", + "karma-chrome-launcher": "^2.2.0", + "karma-jasmine": "^1.1.0", + "karma-webpack": "^2.0.4", "node-sass": "^4.5.3", "sass-loader": "^6.0.6", "style-loader": "^0.18.2", diff --git a/lib/assets/webpack.config.js b/lib/assets/webpack.config.js index f22dbcb..0011906 100644 --- a/lib/assets/webpack.config.js +++ b/lib/assets/webpack.config.js @@ -86,7 +86,7 @@ { from: './node_modules/timeago/jquery.timeago.js', to: `${destPath}/javascripts/` }, { from: './node_modules/tinymce/tinymce.min.js', to: `${destPath}/javascripts/` }, { from: './node_modules/bootstrap-sass/assets/javascripts/bootstrap.min.js', to: `${destPath}/javascripts/` }, - { from: './javascripts/utils/**/*', to: `${destPath}/` }, + { from: './javascripts/utils_es5/**/*', to: `${destPath}/` }, { from: './javascripts/dmproadmap/**/*', to: `${destPath}/` }, { from: './javascripts/views/**/*', to: `${destPath}/` }, { from: './javascripts/admin.js', to: `${destPath}/javascripts/` }, diff --git a/lib/assets/webpack.karma.config.js b/lib/assets/webpack.karma.config.js new file mode 100644 index 0000000..0091971 --- /dev/null +++ b/lib/assets/webpack.karma.config.js @@ -0,0 +1,29 @@ +// Karma configuration +module.exports = function webpackKarmaConf(config) { + config.set({ + // ... normal karma configuration + files: [ + // all files ending in "_test" + { pattern: './javascripts/**/*Spec.js', watched: false }, + // each file acts as entry point for the webpack configuration + ], + + preprocessors: { + './javascripts/**/*Spec.js': ['webpack'], + }, + + webpack: { + // karma watches the test entry points + // (you don't need to specify the entry option) + // webpack watches dependencies + + // webpack configuration + }, + + webpackMiddleware: { + // webpack-dev-middleware configuration + // i. e. + stats: 'errors-only', + }, + }); +};