|
# require lodash |
|
|
|
# |
|
# Best form validator ever |
|
# Detailed Documentation: docs/form_validator_documentation.markdown |
|
# version: 1.0.0 |
|
# |
|
class @FormValidator |
|
|
|
# default options |
|
defaultOptions = |
|
showAllErrors: false |
|
focusError: true |
|
validateInvisible: false |
|
ignore: '' |
|
|
|
# |
|
# Create instance of the form validator |
|
# |
|
# @param {jQuery} form The form to validate |
|
# @param {Options} options Options for the form validator |
|
# |
|
constructor: (form, @options = {}) -> |
|
# setting up instance variables |
|
@form = $(form) |
|
@form.attr('novalidate', true) |
|
@validators = @defaultValidators() |
|
_.defaults(@options, defaultOptions) |
|
_.merge(@validators, @options.validators) |
|
@submitBtn = @getSubmitBtn(@form, @options) |
|
@selector = @generateSelector(@options) |
|
@formChanged() |
|
|
|
# setup listeners |
|
@submitBtn.on('mousedown', => @submitting = true) |
|
$(document).on('mouseup', => @submitting = false; @elementUpdater?()) |
|
@submitBtn.click(@onSubmit) |
|
|
|
|
|
# |
|
# Public functions |
|
# ================ |
|
|
|
|
|
# |
|
# Update the form |
|
# Show feedback to the user |
|
# |
|
# @param {String} selector Update a subsection of the form |
|
# @param {Boolean} focusError Override the focusError option for this call |
|
# @return {Boolean} True if the form is valid |
|
# |
|
update: (selector, focusError = @options.focusError) -> |
|
@elements.forEach (element) -> element.valid = true |
|
|
|
if @options.showAllErrors |
|
@filterElements(selector, @invalidElements()).forEach (element) => |
|
@addError(element, element.errors) |
|
else |
|
@filterElements(selector).every (element) => @updateElement(element) |
|
|
|
@focusError() if focusError |
|
formValid = @elementsValid() |
|
@updateFormElement(formValid) |
|
formValid |
|
|
|
# |
|
# Check if the form is valid |
|
# |
|
# @param {String} selector Validate only a subsection of the form |
|
# @return {Boolean} True if the form is valid |
|
# |
|
isValid: (selector) -> |
|
@filterElements(selector) |
|
.every (element) => @validateElement(element).valid |
|
|
|
# |
|
# Get the current state of the form |
|
# This will return wheither it's valid and the current issues if invalid |
|
# |
|
# @param {String} selector Get the state of only a subsection of the form |
|
# @return { valid: <Boolean>, issues: { controls: <jQuery>, errors: <Array> }} |
|
# |
|
state: (selector) -> |
|
issues = @filterElements(selector, @invalidElements()) |
|
.map (element) -> _.pick(element, ['controls', 'errors']) |
|
|
|
{ valid: issues.length == 0, issues } |
|
|
|
# |
|
# Add validator |
|
# |
|
# @param {String} name The name of the validator |
|
# @param {Function} validator The validator function |
|
# |
|
# @example Sample Validator |
|
# addValidator('at-least-one', function($elements, $group) { |
|
# ... |
|
# return ['errors if there is any']; |
|
# }) |
|
# |
|
addValidator: (name, validator) -> |
|
@validators[name] = validator |
|
|
|
# |
|
# Add element to the form validator |
|
# Note: When formChanged is called then elements added via addElement |
|
# are wiped out |
|
# |
|
# @param {String} selector Element to add to the form validation |
|
# |
|
addElement: (selector) -> |
|
$element = $(selector) |
|
element = @generateElement($element) |
|
@elements.push(element) |
|
element.controls.on('change', @onChange.bind(@, element)) |
|
|
|
|
|
# |
|
# Reset the form from all errors |
|
# |
|
# @param {String} bselector reset only a subsection of the form |
|
# |
|
reset: (selector) -> |
|
@filterElements(selector).forEach((el) => @removeError(el)) |
|
|
|
# |
|
# Call this when the form changed |
|
# |
|
formChanged: -> |
|
@curElementId = 0 |
|
if @elements? |
|
@elements.forEach (element) => |
|
element.controls.off('.form-validator') |
|
@removeError(element, false) |
|
@updateLiveIcon(element, true, true) if element.isLive |
|
|
|
preElements = @elements |
|
@elements = @generateElements(@form) |
|
@registerCustomGroups() |
|
@registerTriggers() |
|
|
|
# register change events |
|
@elements.forEach (element) => |
|
element.controls.on('change.form-validator', |
|
@onChange.bind(@, element)) |
|
|
|
# register live events |
|
@elements |
|
.filter((element) -> element.isLive) |
|
.forEach (element) => |
|
element.controls.on('input.form-validator', |
|
@onLiveChange.bind(@, element)) |
|
|
|
@processElements(preElements, @elements) |
|
|
|
|
|
# |
|
# Event Handlers |
|
# ============== |
|
|
|
|
|
# Called when the submit button is clicked |
|
onSubmit: (event) => |
|
@update() |
|
|
|
# Called when an input has changed |
|
onChange: (element) -> |
|
elementUpdater = => |
|
setTimeout => |
|
@updateElement(element) |
|
@updateElement(el) for el in element.triggers when el.changed |
|
element.changed = true |
|
@updateFormElement() |
|
, 0 |
|
|
|
if @submitting |
|
@elementUpdater = elementUpdater |
|
else |
|
elementUpdater() |
|
|
|
|
|
# Called when a live element changes |
|
onLiveChange: (element) => |
|
@updateElement(element, true) |
|
@updateFormElement() |
|
|
|
|
|
# |
|
# Private Functions |
|
# ================= |
|
|
|
# |
|
# Determine if all elements are valid |
|
# @return {Boolean} True if all elements are valid |
|
# |
|
elementsValid: -> |
|
@elements.every (element) -> element.valid |
|
|
|
# |
|
# Update the form element |
|
# Toggle a class on the form element depending on if there is an error or not |
|
# |
|
# @param {Boolean} formValid True if the form is valid |
|
# |
|
updateFormElement: (formValid = @elementsValid()) -> |
|
@form.toggleClass('contains-error', !formValid) |
|
|
|
# |
|
# Generate all elements in the form |
|
# Elements are entities that are evaluated which include controls and groups |
|
# |
|
# @param {jQuery} $form The form to get the elements from |
|
# @return {Array} The array of elements |
|
# |
|
generateElements: ($form) -> |
|
$form.find(@selector).get().map (element) => |
|
@generateElement(element) |
|
|
|
# |
|
# Register all triggers |
|
# This should be called after generating all the elements |
|
# |
|
registerTriggers: -> |
|
for el in @elements |
|
if !el.isGroup && (sel = el.controls.data('trigger-validation'))? |
|
el.triggers = @filterElements(sel) |
|
else |
|
el.triggers = [] |
|
|
|
# |
|
# Register custom groups |
|
# These are groups defined by the data-group attribute |
|
# This should be called after generating all the elements |
|
# |
|
registerCustomGroups: -> |
|
customGroups = @elements |
|
.filter((el) -> !el.isGroup && el.controls.is('[data-group]')) |
|
|
|
customGroups = _.groupBy(customGroups, (el) -> el.controls.data('group')) |
|
|
|
for group, elements of customGroups |
|
controls = $() |
|
(controls = controls.add(el.controls)) for el in elements |
|
|
|
@elements.push |
|
id: ++@curElementId |
|
isGroup: true |
|
isLive: false |
|
validations: [group] |
|
controls: controls, |
|
group: controls.parents('.form-group') |
|
valid: true |
|
changed: false |
|
|
|
# |
|
# Generate a single element |
|
# |
|
# @param {Element} domElement The dom element |
|
# @return {FormElement} The element object |
|
# |
|
generateElement: (domElement) -> |
|
$element = $(domElement) |
|
id = @curElementId++ |
|
isGroup = $element.is('[data-validate]:not(input,select)') |
|
group = if isGroup then $element else $element.parents('.form-group') |
|
isLive = group.is('.has-feedback') && !isGroup |
|
controls = if isGroup then @getControls($element) else $element |
|
validations = @getValidations($element, isGroup) |
|
|
|
{ |
|
id, validations, controls, group, isGroup, isLive, |
|
valid: true, changed: false |
|
} |
|
|
|
# |
|
# Generate the control selector based on the options |
|
# |
|
# @param {Options} options The options |
|
# @return {String} The selector |
|
# |
|
generateSelector: (options) -> |
|
visibleSelector = if options.validateInvisible then "" else ":visible" |
|
ignoreSelector = @ignoreSelector(options) |
|
|
|
" |
|
input#{visibleSelector}:not([type=hidden],[type=submit], |
|
[type=reset],button)#{ignoreSelector}, |
|
select#{visibleSelector}#{ignoreSelector}, |
|
[data-validate]#{visibleSelector} |
|
" |
|
|
|
# |
|
# Get the elements that contain a control |
|
# Note: The dom element is the $control[0] and it returns the array of element |
|
# objects that contain the dom element |
|
# |
|
# @param {Element} domElement The dom element using to search (ex. $control[0]) |
|
# @return {Array<FormElement>} Return an array of element objects |
|
# |
|
findElements: (domElement) -> |
|
@elements.filter (element) => |
|
element.controls.find(domElement).length || element.controls.is(domElement) |
|
|
|
# |
|
# Filter elements with a jQuery selector |
|
# |
|
# @param {jQuery|String} selector jQuery object or selector |
|
# @param {Array} elements The elements to filter |
|
# @return {Array} The array of elements that satisfy the |
|
# selector |
|
# |
|
filterElements: (selector, elements = @elements) -> |
|
if selector? |
|
elements.filter (element) => |
|
element.controls.is(selector) |
|
else |
|
elements |
|
|
|
# |
|
# Get all the controls within a parent |
|
# |
|
# @param {jQuery} $parent The parent to get all controls from |
|
# @return {jQuery} All the controls in the parent |
|
# |
|
getControls: ($parent) -> |
|
$parent.find(@selector) |
|
|
|
# |
|
# Get validations for the element |
|
# |
|
# @return {Array<String>} The array of validator names |
|
# |
|
getValidations: ($element, isGroup) -> |
|
if $element.is('[data-validate]') |
|
validations = $element.data('validate').split(' ') |
|
validations.push 'native' if !isGroup |
|
validations |
|
else |
|
['native'] |
|
|
|
# |
|
# Get all invalid elements |
|
# |
|
# @return {Array} An array of new elements with valid and errors properties |
|
# |
|
invalidElements: (elements = @elements) -> |
|
elements |
|
.map (element) => _.assign({}, element, @validateElement(element)) |
|
.filter (element) -> !element.valid |
|
|
|
# |
|
# Validate an element |
|
# |
|
# @return {Object} Return validation object { valid, errors } |
|
# |
|
validateElement: (element) -> |
|
@mergeResults( |
|
element.validations.map (validation) => |
|
@validatorPipe @validators[validation](element.controls, element.group) |
|
) |
|
|
|
# |
|
# Updates an element showing the errors to the user |
|
# |
|
# @param {FormElement} element The element to update |
|
# @param {Boolean} liveChange If the element was invoked by live handler |
|
# @return {Boolean} True if the element is valid |
|
# |
|
updateElement: (element, liveChange = false) -> |
|
{ valid, undetermined, errors } = @validateElement(element) |
|
|
|
if valid || (element.isLive && liveChange && undetermined) |
|
@removeError(element) |
|
else |
|
@addError(element, errors) |
|
|
|
@updateLiveIcon(element, valid, undetermined) if element.isLive |
|
|
|
valid |
|
|
|
# |
|
# Add error to an element |
|
# |
|
# @param {FormElement} element The element to add the error to |
|
# @param {Array} errors The errors to add |
|
# @return {FormElement} The element the error was added to |
|
# |
|
addError: (element, errors) -> |
|
element.valid = false |
|
label = element.group |
|
.addClass('has-error') |
|
.find('.error-block') |
|
.last() |
|
|
|
error = { elementId: element.id, errors } |
|
@pushError(element.group, error) |
|
@pushError(label, error) |
|
@renderErrors(label) |
|
|
|
element |
|
|
|
# |
|
# Remove error from an element |
|
# |
|
# @param {FormElement} element Remove the error from this element |
|
# @return {FormElement} The element the error was removed from |
|
# |
|
removeError: (element, render = true) -> |
|
element.valid = true |
|
group = element.group |
|
label = group.find('.error-block').last() |
|
|
|
@removeErrors(group, element.id) |
|
@removeErrors(label, element.id) |
|
|
|
if render |
|
@renderErrors(label) |
|
if !@elementHasErrors(group) |
|
group.removeClass('has-error') |
|
|
|
element |
|
|
|
# |
|
# Update live elements |
|
# This just adds/removes the feedback icons |
|
# |
|
# @param {FormElement} element The live element to update |
|
# @param {Array} errors The validation errors of the element |
|
# @param {Boolean} valid True if the element is valid |
|
# @param {Boolean} undetermined True if the element is in an undetermined |
|
# state |
|
# |
|
updateLiveIcon: (element, valid, undetermined = false) -> |
|
group = element.group |
|
feedback = group.find '.form-control-feedback' |
|
|
|
if valid || undetermined |
|
feedback |
|
.removeClass('glyphicon-remove') |
|
.toggleClass('glyphicon glyphicon-ok', !undetermined) |
|
else |
|
feedback |
|
.removeClass('glyphicon-ok') |
|
.addClass('glyphicon glyphicon-remove') |
|
|
|
# |
|
# Focus the first input that has an error |
|
# |
|
focusError: -> |
|
element = _.find(@elements, valid: false) |
|
|
|
# Directly calling focus caused some problems with change events being |
|
# handled right away without letting update() finish. |
|
# Using setTimeout adds this to the end of the message queue allowing |
|
# update() to finish |
|
setTimeout((-> element.controls[0].focus()), 0) if element? |
|
|
|
# |
|
# Transforms the result of a validator to a standard format |
|
# |
|
# @param {Object} result The result from the validators |
|
# @return {Object} The validation result { valid, errors } |
|
# |
|
validatorPipe: (result) -> |
|
if _.isArray result |
|
errors = _.compact result |
|
valid: errors.length == 0 |
|
errors: errors |
|
undetermined: false |
|
else |
|
{ valid, errors, undetermined} = |
|
_.extend valid: true, undetermined: false, errors: [], result |
|
{ |
|
valid: valid && !undetermined && errors.length == 0, |
|
errors, |
|
undetermined |
|
} |
|
|
|
# |
|
# Merge validator results |
|
# |
|
# @param {Array} results The validator result array |
|
# @param {Object} The merged result of all the validator results |
|
# |
|
mergeResults: (results) -> |
|
results.reduce (final, result) -> |
|
valid: final.valid && result.valid |
|
undetermined: final.undetermined || result.undetermined |
|
errors: final.errors.concat(result.errors) |
|
, { valid: true, undetermined: false, errors: [] } |
|
|
|
# |
|
# Get the default validators |
|
# |
|
# @return {Object} An object containing the default validators |
|
# |
|
defaultValidators: -> |
|
native: ($element) -> |
|
element = $element[0] |
|
valid = element.checkValidity() |
|
errorMsg = $element.data('error') || element.validationMessage |
|
errors = if valid then [] else [errorMsg] |
|
{ valid, errors } |
|
select: ($element) -> |
|
valid = $element.attr('required') == 'required' && $element.val() != '' |
|
errors = if valid then [] else ['Please select an option'] |
|
{ valid, errors } |
|
|
|
# |
|
# Generate the ignore selector |
|
# |
|
# @param {Object} options The validator options |
|
# @return {String} The ignore selector |
|
# |
|
ignoreSelector: (options) -> |
|
if options.ignore then ":not(#{options.ignore})" else "" |
|
|
|
# |
|
# Get the submit button |
|
# |
|
# @param {jQuery} form The form to search |
|
# @param {Object} options The valdiator options |
|
# @return {jQuery} The form's submit button |
|
# |
|
getSubmitBtn: (form, options) -> |
|
ignore = @ignoreSelector(options) |
|
form.find("[type=submit]#{ignore}") |
|
|
|
# |
|
# Push error to jQuery element |
|
# |
|
# These errors are used to keep track of dom elements (labels, divs) |
|
# that are shared across multiple FormElements. For example if a label is used |
|
# for a group and a text input. There needs to be a way to track which |
|
# FormElement the error on the label is coming from, so that when the |
|
# validation state changes the appropriate error can be removed. |
|
# |
|
# @param {jQuery} $element The element to push the error to |
|
# @param {Error} error The error to push { elementId, errors } |
|
# |
|
pushError: ($element, error) -> |
|
errors = $element.data('errors') ? [] |
|
_.remove(errors, (e) -> e.elementId == error.elementId) |
|
errors.push error |
|
$element.data('errors', errors) |
|
|
|
# |
|
# Remove error from jQuery element |
|
# |
|
# @param {jQuery} $element The element to remove the error from |
|
# @param {Number} elementId The FormElement the error originated from |
|
# |
|
removeErrors: ($element, elementId) -> |
|
errors = $element.data('errors') ? [] |
|
_.remove(errors, (e) -> e.elementId == elementId) |
|
$element.data('errors', errors) |
|
|
|
# |
|
# Generate the error HTML for the labels |
|
# |
|
# @param {jQuery} $element The element (label) to render to |
|
# |
|
renderErrors: ($element) -> |
|
errors = ($element.data('errors') ? []) |
|
.reduce(((arr, n) -> arr.concat(n.errors)), []) |
|
.join('<br>') |
|
|
|
$element.html(errors) |
|
|
|
# |
|
# Determine if the element has errors |
|
# |
|
# @param {jQuery} $element Determine if this element has errors |
|
# |
|
elementHasErrors: ($element) -> |
|
($element.data('errors') ? []).length > 0 |
|
|
|
# |
|
# Determine if the two elements are equal |
|
# |
|
# @param {FormElement} a element A to compare |
|
# @param {FormElement} b element B to compare |
|
# @return {Boolean} true if both are equal |
|
# |
|
elementEqual: (a, b) -> |
|
['controls', 'group', 'isGroup', 'isLive'].every (prop) -> |
|
_.isEqual(a[prop], b[prop]) |
|
|
|
# |
|
# Process the elements after a form change |
|
# Removes errors from removed elements and makes sure the states are all good |
|
# |
|
# @param {Array} preElements The elements before the change |
|
# @param {Array} curElements The elements after the change |
|
# |
|
processElements: (preElements, curElements) -> |
|
if preElements? |
|
# remove all errors from elements that were removed |
|
removed = _.differenceWith(preElements, curElements, @elementEqual) |
|
@removeError(el) for el in removed |
|
|
|
# transfer states to the current elements from the previous elements |
|
for el in curElements |
|
preVersion = _.find(preElements, (a) => @elementEqual(a, el)) |
|
if preVersion? |
|
el.valid = preVersion.valid |
|
el.changed = preVersion.changed |