Skip to content

Instantly share code, notes, and snippets.

@danrichards
Created March 28, 2016 02:36
Show Gist options
  • Save danrichards/22c247f5d35173fc06c8 to your computer and use it in GitHub Desktop.
Save danrichards/22c247f5d35173fc06c8 to your computer and use it in GitHub Desktop.
Javascript wizard class, uses Jquery(for AJAX), and Underscore.
/**
* 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