Skip to content

Instantly share code, notes, and snippets.

@jtamminga
Last active February 3, 2020 05:28
Show Gist options
  • Save jtamminga/78788156b26847416d461cb4d710c346 to your computer and use it in GitHub Desktop.
Save jtamminga/78788156b26847416d461cb4d710c346 to your computer and use it in GitHub Desktop.
Bootstrap Form Validator

Form Validator

Bootstrap form validator that uses HTML 5 native validations with custom validation support.

Usage

Basic Example

Regular Bootstrap form layout:

<form id="example-form">
  <div class="form-group">
    <label class="control-label">First Name</label>
    <input type="text" name="first_name" required>
    <span class="help-block error-block"></span>
  </div>
  <input type="submit" value="Submit">
</form>

Initiate the validator:

var validator = new FormValidator('#example-form');

Custom Validator

For custom validators just add a data-validate attribute with the validators. Validators are delimited with spaces.

<form id="example-form">
  <div class="form-group">
    <label class="control-label">First Name</label>
    <input type="text" name="first_name" data-validate="is-foo">
    <span class="help-block error-block"></span>
  </div>
  ...
</form>

For multiple validators on one input:

<input type="text" name="first_name" data-validate="validator-1 validator-2">

You can also combine native and custom validations:

<input type="text" required data-validate="validator-1">

Validations are run in the order they were added. Therefore native validations will run and display before custom validations.

Defining Custom Validators

Custom validators must be defined. This can be done in two ways.

You can add validators with the addValidator function:

var validator = new FormValidator('#example-form');

validator.addValidator('is-foo', function($element) {
  return $element.val() == 'foo' ? [] : ['Not foo'];
});

You can also pass validators through the options:

var validator = new FormValidator('#example-form', {
  validators: {
    'is-foo': function($element) {
      return $element.val() == 'foo' ? [] : ['Not foo'];
    }
  }
});

Custom Validation Return Types

The simplest return type is an array containing the error message(s). An empty array means it's valid.

var isFoo = function($element) {
  return $element.val() == 'foo' ? [] : ['Not foo'];
}

For live elements its useful to return an object instead. This is because you can return more information. (Read Live Element States to learn about the undetermined element state)

var isFooLive = function($element) {
  var valid = $element.val() == 'foo'
  var undetermined = $element.val().length < 3
  return {
    errors: valid ? [] : ['Not foo'],
    undetermined: undetermined
  }
}

Custom Error Messages

If you want to display a custom error message for native validations then add an data-error attribute:

<select required data-error="Select an item from the list">

Group Validations

Group validations are done by adding a data-validate attribute to the parent tag. It is important to put an .error-block element as a child of the parent. This is because inputs of the group could have their own validations and error blocks.

<form id="example-form">
  <div class="row" data-validate="is-same">
    <div class="col-sm-6">
      <div class="form-group">
        <input type="text">
      </div>
    </div>
    <div class="col-sm-6">
      <div class="form-group">
        <input type="text">
      </div>
    </div>
    <span class="help-block error-block"></span>
  </div>
  ...
</form>

All elements of a group are passed to the validator:

var validator = new FormValidator('#example-form');

validator.addValidator('is-same', function($elements) {
  return $element[0].value == $element[1].value ? [] : ['Not the same'];
});

Groups can also be created using data-group attribute. This is useful if the inputs are seperated in the HTML.

<form>
  <div class="form-group">
    <input type="number" data-group="max-number">
  </div>
  <div class="form-group">
    <input type="number" data-group="max-number">
  </div>
</form>

The group name above is max-number. A max-number validator needs to be added:

new FormValidator('form' {
  validators: {
    'max-number': function($elements) {
      total = $elements.get().reduce(
        (sum,el) => sum + parseInt($(el).val() || 0), 0)
      return total <= 5 ? [] : ['over limit.']
    }
  }
});

Triggers

Triggers are added to elements to trigger validations of other elements.

<form>
  <div class="form-group">
    <input id="first_name" type="text" data-trigger-validation="#last_name">
  </div>
  <div class="form-group">
    <input id="last_name" type="text">
  </div>
</form>

In the above example if the first_name is changed then the last_name validation is triggered.

Live Elements

Live elements are elements that are validated on every key press. This is useful if validations need to be run as the user types.

Just add the has-feeback class to the form group to add a live element. An extra span is also needed with a form-control-feedback class after the input.

<div class="form-group has-feedback">
  <label>Credit Card</label>
  <input type="text" class="form-control" data-validate="credit-card" />
  <span class="form-control-feedback"></span>
  <span class="help-block error-block"></span>
</div>

Live Element States

Live elements have two states. There is the valid state just like every element, then there is an undetermined state. An element in an undetermined state is shown to the user an neither valid or invalid. The element is however still considered invalid until the state is determined (undetermined = false).

Options

Options can be passed through the constructor:

var validator = new FormValidator('#test-form', { options });
Name Type Default Description
showAllErrors Boolean false Show errors from all elements when submit is clicked
focusError Boolean true Focus to first invalid input when submit is clicked
validateInvisible Boolean false Validate invisible elements
validators Object undefined Custom validator functions
ignore String undefined Ignore elements in a form using a selector

Methods

Methods with a filter parameter (update, isvalid, state, reset) can be passed a jQuery selector, resulting in the method only applying to a subset of the form elements.

Example

validator.isValid(':not(.form-ignore)')

The above example will validate all elements except those that contain the .form-ignore class. If all elements in this subset are valid then true is returned.

update(filter, focusError)

Updates the form.

If there are errors the form will show the error(s) and focus the first input with the error. The behaviour is determined by the options showAllErrors and focusError.

  • focusError Override the focusError option for this call

Returns true if the form is valid.

reset(filter)

Reset the form. This will remove all errors showing.

isValid(filter)

Determine if the form is valid.

Returns true if the form is valid.

state(filter)

Get the state of form.

The state consists of whether the form is valid and the issues the form has. Each issues contains the controls with the issue and the errors.

Example state:

{
  valid: false,
  issues: [
    controls: <jQuery>,
    errors: ['Some error']
  ]
}

Returns the State object

addValidator(name, validator)

Add a validator for custom validations.

  • name The name of the validator
  • validator The validator function. The validator function is passed the controlls then the group as jQuery objects.

Usage

validator.addValidator('is-foo', function($element) {
  return $element.val() == 'foo' ? [] : ['Not foo'];
});

Usage in the HTML

<input type="text" data-validate="is-foo">

addElement(selector)

Add an element to the form validator. This is useful if the element is outside of the form.

formChanged

This should be called when the form changes. For example if inputs are added or removed.

# 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment