Created
March 28, 2016 02:36
-
-
Save danrichards/22c247f5d35173fc06c8 to your computer and use it in GitHub Desktop.
Javascript wizard class, uses Jquery(for AJAX), and Underscore.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* SubmitWizard | |
* | |
* @returns {SubmitWizard} | |
* @constructor | |
*/ | |
function SubmitWizard(options) { | |
var that = this; | |
/** | |
* Initialize our options. | |
*/ | |
this.name = options.name; | |
this.state = options.initialState; | |
this.buttons = options.buttons; | |
this.templates = options.templates; | |
this.steps = options.steps; | |
this.el = {}; | |
// ------------------------------------------------------------------------ | |
// SubmitWizard Class Methods | |
// ------------------------------------------------------------------------ | |
/** | |
* @return boolean | |
*/ | |
this.isFinished = function(state) | |
{ | |
state = _.default(state, this.state); | |
return _.findIndex(this.steps, function(step) { | |
return step.state == state; | |
}) == (this.steps.length - 1); | |
}; | |
/** | |
* @return boolean | |
*/ | |
this.isInitial = function(state) | |
{ | |
state = _.default(state, this.state); | |
return _.findIndex(this.steps, function(step) { | |
return step.state == state; | |
}) == 0; | |
}; | |
/** | |
* @returns this.state string | |
*/ | |
this.getState = function() | |
{ | |
return this.state; | |
}; | |
/** | |
* Set a valid state. | |
* | |
* @return SubmitWizard | |
*/ | |
this.setState = function(state) | |
{ | |
var exists = ! _.isUndefined(_.find(this.steps, function(step) { | |
return step.state == state; | |
})); | |
if (exists) { | |
this.state = state; | |
} else { | |
console.log('Wizard.Exception', ''+state+' is not a valid state.'); | |
} | |
return this; | |
}; | |
/** | |
* Get a step by state. | |
* | |
* @param state string | |
* @return this.steps.# object | |
*/ | |
this.getStep = function(state) | |
{ | |
if (_.isUndefined(state)) { | |
return this.getStep(this.getState()); | |
} | |
return _.find(this.steps, function (obj) { | |
return obj.state == state; | |
}); | |
}; | |
/** | |
* Provided an array of states, get the steps (in order) | |
* | |
* @param states array | |
* @return this.steps.* array | |
*/ | |
this.getSteps = function(states) | |
{ | |
var steps = []; | |
_.each(states, function(state) { | |
steps.push(that.getStep(state)); | |
}); | |
return steps; | |
}; | |
/** | |
* Steps occurring before the provided step. | |
* | |
* @param state string | |
* @return this.steps.* array | |
*/ | |
this.getStepsBefore = function (state) | |
{ | |
var states = this.getStatesBefore(state); | |
return this.getSteps(states); | |
}; | |
/** | |
* Steps occurring after the provided step. | |
* | |
* @param state string | |
* @return this.steps.* array | |
*/ | |
this.getStepsAfter = function (state) | |
{ | |
var states = this.getStatesAfter(state); | |
return this.getSteps(states); | |
}; | |
/** | |
* Steps occurring before the provided step. | |
* | |
* @param state string | |
* @return this.steps.*.state array | |
*/ | |
this.getStatesBefore = function (state) | |
{ | |
var index = _.findIndex(this.steps, function(step) { | |
return step.state == state; | |
}); | |
return this.steps.length | |
? _.pluck(_.first(this.steps, index), 'state') | |
: []; | |
}; | |
/** | |
* Steps occurring after the provided step. | |
* | |
* @param state string | |
* @return this.steps.*.state array | |
*/ | |
this.getStatesAfter = function (state) | |
{ | |
var index = _.findIndex(this.steps, function(step) { | |
return step.state == state; | |
}); | |
return this.steps.length | |
? _.pluck(_.last(this.steps, this.steps.length - index - 1), 'state') | |
: []; | |
}; | |
/** | |
* Get a step by state. | |
* | |
* @param state | |
* @param property | |
* @param defaultValue | |
*/ | |
this.getStepProperty = function(state, property, defaultValue) | |
{ | |
defaultValue = _.default(defaultValue, null); | |
var step = this.getStep(state); | |
return _.default(step[property], defaultValue); | |
}; | |
/** | |
* The next step by state, or null if no further steps. | |
* | |
* @param state | |
* @returns this.step.# | |
*/ | |
this.getNextStep = function(state) | |
{ | |
state = _.default(state, this.state); | |
var index = _.findIndex(this.steps, function(step) { | |
return step.state == state; | |
}); | |
if ((this.steps.length - 1) == index) { | |
return null; | |
} | |
return this.steps[index + 1]; | |
}; | |
/** | |
* The next state. | |
* | |
* @param state | |
* @returns {*} | |
*/ | |
this.getNextState = function(state) { | |
var next = this.getNextStep(this.getStep(state).state); | |
return _.isNull(next) ? next : next.state; | |
}; | |
/** | |
* The previous step by state, or null if first step given. | |
* | |
* @param state | |
* @returns this.step.# | |
*/ | |
this.getPreviousStep = function(state) | |
{ | |
state = _.default(state, this.state); | |
var index = _.findIndex(this.steps, function(step) { | |
return step.state == state; | |
}); | |
if (index == 0) { | |
return null; | |
} | |
return this.steps[index - 1]; | |
}; | |
/** | |
* The previous state. | |
* | |
* @param state | |
* @returns {*} | |
*/ | |
this.getPreviousState = function(state) { | |
var previous = this.getPreviousStep(this.getStep(state).state); | |
return _.isNull(previous) ? previous : previous.state; | |
}; | |
/** | |
* Setup the UI | |
*/ | |
if (this.steps.length == 0) { | |
console.log("Wizard.Exception', 'Your wizard has no steps."); | |
} else { | |
var state = this.steps[0].state; | |
this.elInit() | |
.elState(state); | |
} | |
return this; | |
} | |
/** | |
* SubmitWizard prototype | |
*/ | |
SubmitWizard.prototype = { | |
/** | |
* Whenever you replace an Object's Prototype, you need to repoint | |
* the base Constructor back at the original constructor Function, | |
* otherwise `instanceof` calls will fail. | |
*/ | |
constructor: SubmitWizard, | |
/** | |
* Initialize the UI | |
*/ | |
elInit: function() | |
{ | |
var that = this; | |
// Wizard and steps | |
this.el.wizard = $('#wizard-'+this.name); | |
this.el.alerts = this.el.wizard.find('#wizard-'+this.name+'-alerts'); | |
this.el.steps = {}; | |
_.each(this.steps, function(step) { | |
that.el.steps[step.state] = that.el.wizard.find('#wizard-'+that.name+'-'+step.state); | |
}); | |
// Program bar and text | |
this.el.progress = this.el.wizard.find('.progress'); | |
this.el.progressText = this.el.wizard.find('.progress-text'); | |
// Buttons | |
this.el.btnNext = this.el.wizard.find('.btn-next, .btn-submit'); | |
this.el.btnPrevious = this.el.wizard.find('.btn-previous'); | |
this.el.btnCancel = this.el.wizard.find('.btn-cancel'); | |
this.el.btnNext.off('click').on('click', function() { | |
that.next(that.state); | |
}); | |
this.el.btnPrevious.off('click').on('click', function() { | |
that.previous(that.state); | |
}); | |
this.el.btnCancel.off('click').on('click', function() { | |
location.reload(); | |
}); | |
// When an .validate element is changed, check / run callback. | |
this.validateOnChange(true); | |
return this; | |
}, | |
/** | |
* Update the UI based on the state. | |
* | |
* @param state string | |
* @param previousState string | |
*/ | |
elState: function(state, previousState) | |
{ | |
var step = this.getStep(state); | |
var goingBack = _.contains(this.getStatesBefore(previousState), state); | |
// Step visible | |
this.el.wizard.find('div.step').hide();; | |
this.el.steps[state].fadeIn(); | |
// Progress bar and text | |
var progress = this.getStepProperty(state, 'progress'); | |
this.el.progress.find('.progress-bar') | |
.css('width', progress+'%').prop('aria-valuenow', progress); | |
this.el.progress.find('sr-only').text(progress+'% Complete'); | |
if (goingBack) { | |
this.el.progressText.find('li[data-state=' + previousState + ']') | |
.removeClass('complete active').addClass('incomplete'); | |
this.el.progressText.find('li[data-state=' + state + ']') | |
.removeClass('complete incomplete').addClass('active'); | |
} else { | |
this.el.progressText.find('li[data-state=' + previousState + ']') | |
.removeClass('active incomplete').addClass('complete'); | |
} | |
this.el.progressText.find('li[data-state='+state+']') | |
.removeClass('active incomplete').addClass('active'); | |
// Buttons visible | |
var hide = this.getStepProperty(state, 'hide', []); | |
$(this.buttons.join(',')).hide(); | |
$(_.difference(this.buttons, hide).join(',')).show(); | |
return this; | |
}, | |
/** | |
* Populate fields with data (likely from a response). When step is | |
* undefined, we will attempt to update all fields. | |
* | |
* @param state string | |
* @param data Object | |
* @return Array Fields that were actually updated. | |
*/ | |
elData: function(state, data) | |
{ | |
//console.log("elData", data); | |
return this.el.steps[state].populate(data); | |
}, | |
/** | |
* The change event fires when an input on the wizard is changed. | |
*/ | |
elChange: function(state, fields) | |
{ | |
var response = this.validateFields(state, fields); | |
if (! _.isEmpty(response) || ! _.isUndefined(response.errors)) { | |
this.handleValidation(state, response); | |
} | |
}, | |
/** | |
* Render general alert to the alerts container. | |
* | |
* @param message | |
* @param type | |
* @param dismissable | |
*/ | |
elAlert: function(message, type, dismissable) { | |
var alert = this.alert(message, type, dismissable); | |
this.el.alerts.append(alert).show(); | |
}, | |
/** | |
* Execute the state machine | |
* | |
* @param state | |
*/ | |
next: function(state) | |
{ | |
var step = this.getStep(state); | |
if (_.default(this.isFinished(state), false)) { | |
console.log('Wizard.Exception', 'next() invoked on final step.'); | |
return false; | |
} | |
var that = this; | |
var data = this.el.steps[state].formParams(); | |
/** | |
* Step may have a previous callback which can halt traversal. | |
*/ | |
var obj = this.validateNext(state); | |
if (! _.isEmpty(obj)) { | |
this.handleValidation(state, obj); | |
return; | |
} | |
this.save(step, data) | |
.done(function (response) { | |
if (response.success) { | |
if (! _.isUndefined(step.done) && _.isFunction(step.done)) { | |
/** | |
* Step may have a done callback which can halt traversal. | |
*/ | |
if (step.done(response, state, that) === false) { | |
return; | |
} | |
} | |
if (that.isFinished()) { | |
console.log('Wizard.Exception', 'next() invoked on final state. No further steps (states).'); | |
} else { | |
//console.log('next:response', response); | |
var nextStep = that.getNextStep(state); | |
that.state = nextStep.state; | |
that.elState(that.state, state) | |
.elData(that.state, that.parse(that.state, response)); | |
/** | |
* Next step may have a load callback. | |
*/ | |
if (!_.isUndefined(nextStep.load) && _.isFunction(nextStep.load)) { | |
nextStep.load(response, nextStep.state, that); | |
} | |
} | |
} else { | |
that.handleValidation(that.state, response); | |
} | |
//console.log('next:wizard:', that); | |
}) | |
.fail(function (jqXHR) { | |
var response = $.parseJSON(_.default(jqXHR.responseText, "{}")); | |
console.log("next fail ~ ", response); | |
/** | |
* Step may have a fail callback which can halt error handling. | |
*/ | |
if (step.fail(response, that) === false) { | |
return; | |
} | |
that.handleValidation(that.state, response); | |
}) | |
.always(function(response) { | |
/** | |
* Step may have a always callback which can halt proceeding always logic. | |
*/ | |
if (!_.isUndefined(step.always) && step.always(response, state, that) === false) { | |
//return; | |
// nothing to do here... | |
} | |
}); | |
}, | |
/** | |
* Execute the state machine | |
* | |
* @param state | |
*/ | |
previous: function(state) | |
{ | |
var step = this.getStep(state); | |
var previousState = this.getPreviousState(state); | |
if (_.default(this.isInitial(state), false) || _.isNull(previousState)) { | |
console.log('Wizard.Exception', 'previous() invoked on initial step.'); | |
return false; | |
} | |
/** | |
* Step may have a previous callback which can halt traversal. | |
*/ | |
var errors = this.validatePrevious(state); | |
if (! _.isEmpty(errors)) { | |
this.handleValidation(state, {'errors': errors}); | |
return; | |
} | |
this.setState(previousState).elState(previousState, state); | |
}, | |
/** | |
* Save our step data. | |
* | |
* @param step | |
* @param data | |
* @returns {*|Ajax} | |
*/ | |
save: function(step, data) | |
{ | |
return $.post(step.endpoint, data) | |
.done(function(response) { | |
// console.log('save:done', step, data, response); | |
}) | |
.fail(function(jqXHR) { | |
// console.log('save:fail', step, data, jqXHR.responseText); | |
}) | |
.always(function(response) { | |
// console.log('save:always', step, data, response); | |
}); | |
}, | |
/** | |
* How do we acquire data for a given state with our AJAX response? | |
* | |
* @param state | |
* @param response | |
*/ | |
parse: function(state, response) { | |
return response.data; | |
}, | |
/** | |
* Go back to a previous state. | |
* | |
* @param state | |
*/ | |
backTo: function(state) { | |
var previousStates = this.getStatesBefore(this.state); | |
if (this.isInitial(this.state)) { | |
this.elAlert('You are already on the initial state.', 'warning'); | |
return; | |
} | |
if (! _.contains(previousStates, state)) { | |
this.elAlert('Use the next button to move forward to a new state.'); | |
return; | |
} | |
this.setState(state).elState(state, this.state); | |
}, | |
/** | |
* Retrieve the form data for a step. | |
* | |
* @param state | |
* @returns Object | |
*/ | |
formParams: function(state) | |
{ | |
return this.el.steps[state].find('form').formParams(); | |
}, | |
/** | |
* A step may have a callback for the Next button. | |
* | |
* @param state | |
* @returns Object | |
*/ | |
validateNext: function(state) | |
{ | |
var step = this.getStep(state); | |
if (! _.isUndefined(step.validation) && | |
! _.isUndefined(step.validation.next) && | |
_.isFunction(step.validation.next)) | |
{ | |
console.log('validateNext', this.formParams(state), state, this); | |
var result = step.validation.next(this.formParams(state), state, this); | |
if (result !== true) { | |
return _.isString(result) | |
? {errors: {message: result}} : result; | |
} | |
} | |
return {}; | |
}, | |
/** | |
* A step may have a callback for the Previous button. | |
* | |
* @param state | |
* @returns Object {}|{errors: {message: string}} | |
*/ | |
validatePrevious: function(state) | |
{ | |
var step = this.getStep(state); | |
if (! _.isUndefined(step.validation) && | |
! _.isUndefined(step.validation.previous) && | |
_.isFunction(step.validation.previous)) | |
{ | |
var string = step.validation.previous(this.formParams(state), state, this); | |
if (string !== true) { | |
return {errors: {message: string}}; | |
} | |
} | |
return {}; | |
}, | |
/** | |
* Run validation callbacks on each field, if fields is not specified, run | |
* it all the fields for that step. Build a response that is just like a | |
* error response from a request. | |
* | |
* @param state string | |
* @param fields array|string|undefined | |
* @return Object {}|{errors: {fields: [{field_name: 'name', message: 'Please provide a name.'}]}} | |
*/ | |
validateFields: function(state, fields) | |
{ | |
var that = this; | |
var step = this.getStep(state); | |
// Get data or get out of town. | |
if (_.isUndefined(step.validation) || _.isUndefined(step.validation.fields)) { | |
return {}; | |
} | |
var data = this.formParams(state); | |
if (_.isEmpty(data)) { | |
return {}; | |
} | |
// Use an array of fields. | |
if (! _.isUndefined(fields) && _.isString(fields)) { | |
fields = [fields]; | |
} | |
if (_.isUndefined(fields) || ! _.isArray(fields)) { | |
fields = []; | |
this.el.steps[state].find('.validate').each(function() { | |
fields.push($(this).prop('name')); | |
}); | |
} | |
// Execute each callback, if any, passing the data and wizard. | |
var errorsArray = []; | |
if (fields.length > 0) { | |
_.each(fields, function(field) { | |
if (_.isFunction(step.validation.fields[field])) { | |
var error = step.validation.fields[field](data, state, that); | |
//console.log('validationFields.error: ', error); | |
if (error !== false) { | |
//console.log('validationFields.error (not false): ', field, error); | |
errorsArray.push({'field': field, 'message': error}); | |
} | |
} | |
}); | |
if (errorsArray.length > 0) { | |
return {errors: {fields: errorsArray}} | |
} | |
} | |
return {}; | |
}, | |
/** | |
* Turn on/off validation on field changes. | |
* | |
* @param on boolean | |
*/ | |
validateOnChange: function(on) { | |
var that = this; | |
if (on) { | |
this.el.wizard.find('.validate').off('change').on('change', function() { | |
var field = $(this).data('validate'); | |
field = ! _.isUndefined(field) ? field : $(this).prop('name'); | |
that.elChange(that.getState(), field); | |
}); | |
} else { | |
this.el.wizard.find('.validate').off('change'); | |
} | |
return this; | |
}, | |
/** | |
* Soft JS validation errors. Not from request. | |
* | |
* @param state string | |
* @param response Object | |
*/ | |
handleValidation: function (state, response) | |
{ | |
var that = this; | |
if (! _.isUndefined(response.errors)) { | |
if (! _.isUndefined(response.errors.message)) { | |
this.elAlert(response.errors.message); | |
} | |
if (! _.isUndefined(response.errors.fields) && | |
_.isArray(response.errors.fields)) | |
{ | |
_.each(response.errors.fields, function(obj) { | |
var label = that.el.steps[state].find('label[for='+obj.field+']'); | |
var text = label.data('text'); | |
label.text(_.isUndefined(text) ? obj.message : text+': '+obj.message); | |
label.addClass('error'); | |
}); | |
} | |
} | |
}, | |
/** | |
* Alert template. | |
* | |
* @param message | |
* @param type | |
*/ | |
alert: function(message, type) | |
{ | |
return this.templates.alert(_.defaults( | |
{ | |
'message': message, | |
'type': type | |
}, | |
{ | |
'message': 'An error occurred.', | |
'type': 'danger' | |
}) | |
); | |
} | |
}; | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment