define([
"lodash",
"thorax",
"larynx"
], function (_, Thorax, Larynx) {
/**
* @module bauplan%view
* @extends Thorax.View
* @return {constructor} BauplanView
* @description ## Generic view
*
* var BauplanView = require("bauplan.view");
*
* or as part of the Bauplan bundle
*
* var Bauplan = require("bauplan");
* var BauplanView = Bauplan.View;
*
* To create and instantiate a new view class
*
* var FooView = Bauplan.View.extend({
* name: "foo"
* });
* var fooviewinstance = new FooView();
*
* #### View templates
*
* In the example above, FooView automatically uses a template named "foo.view".
*
* To create a view that uses a non-default view template
*
* var FooTemplateView = Bauplan.View.extend({
* name: "foo",
* template: "bar.view"
* });
*
* #### View models
*
* To create a view that uses the bar model
*
* var FooModelView = Bauplan.View.extend({
* name: "foo",
* model: Bar
* });
*
* To create a view that requires a more complex context than simply the model view’s attributes
*
* var FooContextView = Bauplan.View.extend({
* name: "foo",
* model: Bar,
* context: function () {
* var attributes = _.extend({}, someOtherObject, this.model.attributes);
* delete attributes.notneeded;
* return attributes;
* }
* });
*
* NB. this is standard Thorax behaviour
*
* ### Saving the view model
*
* Without any further intervention, a view form will, when submitted, save the view model and then redirect to the previous route. But often, that will not be sufficient.
*
* NB. views do not need to have a form and do not need to save
*
* #### Post-save redirecting
*
* To create a view that redirects to the "bar" route on success and "baz" on error
*
* var FooRedirectView = Bauplan.View.extend({
* name: "foo",
* successRoute: "bar",
* errorRoute: "baz"
* });
*
* or alternatively
*
* var FooRedirectAltView = Bauplan.View.extend({
* name: "foo",
* saveOptions: {
* success: {
* route: "bar"
* },
* error: {
* route: "baz"
* }
* }
* });
*
* #### Post-save callbacks
*
* To create a view that calls bar() on success and baz() on error
*
* var FooCallbackView = Bauplan.View.extend({
* name: "foo",
* successCallback: bar,
* errorCallback: baz
* });
*
* or alternatively
*
* var FooCallbackAltView = Bauplan.View.extend({
* name: "foo",
* saveOptions: {
* success: {
* callback: bar
* },
* error: {
* callback: baz
* }
* }
* });
*
* #### Saving without propagating to endpoint
*
* To create a view that does not save the model to the server and executes its success handler immediately
*
* var FooNoSaveView = Bauplan.View.extend({
* name: "foo",
* saveOptions: {
* save: false
* }
* });
*
* #### Overriding default save and outcome handling methods
*
* To create a view that has custom methods for saving and dealing with the outcomes
*
* var FooCustomMethodsView = Bauplan.View.extend({
* name: "foo",
* saveForm: function (options) { … },
* onSuccess: function (model, response, options) { … },
* onError: function (model, response, options) { … }
* });
*
* #### Preventing view refreshing
*
* To create a view that does not re-render when its model updates
*
* var FooNoRenderOnUpdateView = Bauplan.View.extend({
* name: "foo",
* renderUpdate: false
* });
*
* #### Instance methods of note
*
* ##### Checking and enabling the view form
*
* - {@link module:bauplan%view#validateAllControls}
* - {@link module:bauplan%view#enableForm}
* - {@link module:bauplan%view#disableForm}
*
* ##### Accessing child control views of the view
*
* - {@link module:bauplan%view#getControl}
* - {@link module:bauplan%view#addControlError}
*
* ##### View navigation
*
* - {@link module:bauplan%view#previous}
*
* @listens module:bauplan%view~click:saveSelector
* @listens module:bauplan%view~click:cancelSelector
* @listens module:bauplan%view~on:rendered
*
* @see module:bauplan%control%view
*/
/**
* @event window:on:mouseenter
* @description Faux-focuses elements as the mouse moves over them and removes focus from any focused control of kinds, input[type=text], input[type=password], textarea
*
* This gets around blur events causing 2 clicks to be required on buttons
*
* Applies to a, button, [type=submit], label
*/
var stashFocused;
var elementsToBlur = "a, button, [type=submit], label";
jQuery(document).on("mouseenter", elementsToBlur, function() {
var focused = jQuery(":focus");
if (focused.view() && focused.view().name === "control" && focused.eq(0).is("input[type=text], input[type=password], textarea")) {
stashFocused = focused;
if (stashFocused.is(":in-viewport")) {
stashFocused.addClass("faux-focused");
}
stashFocused.blur();
}
});
/**
* @event window:on:click
* @description Un-faux-focuses elements and removes focus on any previously focused control
*
* Applies to a, button, [type=submit], label
*/
jQuery(document).on("click", elementsToBlur, function() {
if (stashFocused) {
stashFocused.removeClass("faux-focused");
stashFocused = null;
}
});
/**
* @event window:on:mouseleave
* @description Un-faux-focuses elements and resets focus on any previously focused control
*
* Applies to a, button, [type=submit], label
*/
jQuery(document).on("mouseleave", elementsToBlur, function() {
if (stashFocused && stashFocused.is(":in-viewport")) {
stashFocused.focus();
stashFocused.removeClass("faux-focused");
stashFocused = null;
}
});
var Bauplan;
var BauplanView = Thorax.View.extend(
/**
* @method extend
* @return {constructor} View
* @static
* @param {object} options Constructor options
* @param {string} options.name=default View name
* @param {string} [options.template] View template - defaults to view name
* @param {contructor} [options.model] View model or collection. An object or array can be passed instead to create respectively a model or collection automatically
* @param {string} [options.saveSelector=[name=action-submit]] CSS selector to match save|submit element
* @param {string} [options.cancelSelector=[name=action-cancel]] CSS selector to match cancel element
* @param {boolean} [options.initialValues=true] Denotes that the form’s initial values should not constitute a form in a valid state
* @param {function} [options.context] Explicitly override Thorax.View.prototype.context
* @param {function} [options.saveForm] Explicitly override default saveForm method
* @param {function} [options.onSuccess] Explicitly override default onSuccess method
* @param {function} [options.onError] Explicitly override default onError method
* @param {object} [options.saveOptions] Options to pass to post-success handler
* @param {boolean} [options.saveOptions.save=true] Save the model to the endpoint. If false, skip straight to the onSuccess method
* @param {object} [options.saveOptions.success] Success handler options
* @param {string} [options.saveOptions.success.route] Success redirect route if successRoute has not been defined
* @param {function} [options.saveOptions.success.callback] Success callback if successCallback has not been defined
* @param {object} [options.saveOptions.error] Error handler options
* @param {string} [options.saveOptions.error.route] Error redirect route if successRoute has not been defined
* @param {function} [options.saveOptions.error.callback] Error callback if errorCallback has not been defined
* @param {string} [options.successRoute] Explicit route to redirect to post success
* @param {string} [options.errorRoute] Explicit route to redirect to post error
* @param {function} [options.successCallback] Explicit callback post success
* @param {function} [options.errorCallback] Explicit callback error
* @param {boolean} [renderUpdate=true] Whether to render view when the model updates
*/
{
name: "default",
/**
* @override
* @description Suppresses Thorax.View.prototype.populate since control views provide that functionality
*/
populate: function(){},
saveSelector: "[name=action-submit]",
cancelSelector: "[name=action-cancel]",
saveOptions: {},
initialValues: true,
//postRender: function(e) {
// this.validateAllControls();
//},
/**
* @method constructor
* @private
* @static
* @param {arguments} arguments Applied to Thorax.View
* @description Sets:
*
* - layoutmodel
* - template (defaulting to name + ".view")
* - controlmodel
*/
constructor: function () {
this.layoutmodel = new Thorax.LayoutViewModel(this.name);
if (!this.template) {
this.template = this.name + ".view";
}
if (this.bauplan) {
Bauplan = this.bauplan;
}
if (this.controlmodel) {
this.model = this.controlmodel;
this.renderUpdate = false;
}
//Thorax.View.prototype.constructor.apply(this, arguments);
Thorax.View.apply(this, arguments);
if (this.model && this.renderUpdate !== undefined) {
var model = this.model;
this.setModel();
this.setModel(model, {render: this.renderUpdate});
}
},
/**
* @private
* @description Creates a parallel instance to track changes
*/
setDirtyModel: function () {
this.dirtymodel = new Thorax.Model( _.extend({}, this.model ? this.model.attributes : {}));
},
/**
* @method validateAllControls
* @instance
* @param {object} [options]
* @param {boolean} [options.update] Whether to update the controls
* @param {boolean} [options.validate] Whether to validate the controls
* @description Validates all view controls and enables or disables the form accordingly
*/
validateAllControls: function (options) {
options = options || {};
options.norevalidation = true;
if (options.update) {
options.forceDisplay = true;
}
var checkmethod;
if (options.update) {
checkmethod = "updateControl";
} else if (options.validate) {
checkmethod = "validateControl";
}
this.hasNoErrors = true;
var children = this.children;
for (var view in children) {
var control = children[view];
if (control.name !== "control") {
continue;
}
if (checkmethod && control.$el) {
var value = control.getValue();
control[checkmethod](value, options);
}
if (control.getError()) {
this.hasNoErrors = false;
}
}
this.enableForm(this.hasNoErrors);
},
/**
* @method enableForm
* @instance
* @param {boolean} enable Whether to enable or disable form
* @param {boolean} force Whether to force the state chnge
* @description Enables the view form
*/
enableForm: function (enable, force) {
var disable = enable === false;
if (this.initialValues && !force) {
// if allow to run without model changes, put checks here
// since dirtymodel combines all model changes that should be ok
// currently manually unsetting, when a valid change comes through for the model
// this does not catch the case of the model being put back into its initial state
disable = true;
}
this.disabled = disable;
// would be better if this triggered a change to the actions view
this.$el && this.$el.find(this.saveSelector).toggleClass("disabled", disable);
},
/**
* @method disableForm
* @instance
* @description Disables the view form
*/
disableForm: function() {
this.enableForm(false);
},
/**
* @method getControl
* @instance
* @param {string} name Control name
* @description Returns the named control view that is a child of the current view
* @return {view} ControlView
*/
getControl: function (name) {
var kids = this.children;
for (var kid in kids) {
if (kids[kid].controlname === name) {
return kids[kid];
}
}
},
/**
* @method addControlError
* @instance
* @param {string} name Control name
* @param {error} error Error to display
* @param {object} [options]
* @description Display an error on the named control
*/
addControlError: function (name, error, options) {
var control = this.getControl(name);
options = options || {};
options.error = error;
options.norevalidation = true;
control.updateControl(options);
},
/**
* @method onHandle
* @instance
* @param {string} type=success|error Type of result
* @param {model} model Model/collection in a state reflecting the outcome of the save operation
* @param {XHR} response The full jqXHR response object
* @param {object} [options] Additional options to be passed to the callback function
* @description Generic handling of success/error outcomes when saving a model or collection instance
*
* #### [success|error]Route
*
* #### [success|error]Callback
*/
onHandle: function (type, model, response, options) {
var saveOptions = this.saveOptions;
var handleRoute = this[type+"Route"];
if (!handleRoute && saveOptions[type]) {
handleRoute = saveOptions[type].route;
}
if (handleRoute) {
Bauplan.Router.callRoute(handleRoute);
} else {
var handleCallback = this[type+"Callback"];
if (!handleCallback && saveOptions[type]) {
handleCallback = saveOptions[type].callback;
}
if (handleCallback) {
handleCallback.apply(this, [model, response, options]);
} else {
// Do we really always want to go back?
// maybe should be check for routePrevious?
//Bauplan.Router.previous();
}
}
},
/**
* @method onSuccess
* @instance
* @param {model} model Model/collection in a state reflecting the successful outcome
* @param {XHR} response The full jqXHR response object
* @param {object} options Additional options to be passed to the callback function
* @description Success wrapper method for {@link module:bauplan%view#onHandle}
*/
onSuccess: function (model, response, options) {
this.onHandle("success", model, response, options);
},
/**
* @method errorCallback
* @instance
* @param {model} model Model/collection in a state reflecting the outcome
* @param {XHR} response The full jqXHR response object
* @param {object} options Additional options to be passed to the callback function
* @description Default error callback method
*
* Displays any error message returned, using it as an internationalisation key if it exists
*
* Disables view form submission
*/
errorCallback: function (model, response, options) {
var json = response.responseJSON;
this.error = json.message || json.messagekey;
// this should be done elsewhere
this.error = Larynx.Phrase.get(this.error) || this.error;
this.model.trigger("change");
this.disableForm();
},
/**
* @method onError
* @instance
* @param {model} model Model/collection in a state reflecting the outcome
* @param {XHR} response The full jqXHR response object
* @param {object} options Additional options to be passed to the callback function
* @description Error wrapper method for {@link module:bauplan%view#onHandle}
*/
onError: function (model, response, options) {
this.onHandle("error", model, response, options);
// maybe this should be in onHandle
this.hasbeensubmitted = false;
this.$el && this.$el.find(this.saveSelector).removeClass("submitting");
// make it like onSuccess
/*if (this.errorRoute) {
Bauplan.Router.callRoute(this.errorRoute);
} else if (this.errorCallback) {
this.errorCallback();
}*/
},
/**
* @method saveForm
* @instance
* @param {object} options
* @description Optionally (by default) save the form’s model and then invoke the handler method for the outcome
*/
saveForm: function (options) {
if (this.saveOptions.save === false) {
this.onSuccess(options);
} else {
var that = this;
var model = this.model;
model.save(model.attributes, {
// should apply arguments
success: function(model, response) {
that.onSuccess(model, response, options);
},
error: function(model, response) {
that.onError(model, response, options);
}
});
}
},
/**
* @method previous
* @instance
* @param {event} [e] DOM event
* @description Navigate app to previous route view
*/
previous: function (e) {
Bauplan.Router.previous();
},
/**
* @description Default events to mix in with events if overridden
*/
defaultEvents: {
/**
* @event on:rendered
* @description Validate all the controls post view rendering
*/
rendered: function (e) {
this.validateAllControls({rendered:true});
}
},
/**
* @description Basic events for BauplanView
*/
events: function (e) {
var viewEvents = _.extend({}, this.defaultEvents);
/**
* @event click:saveSelector
* @description When user attempts to submit the view form
*
* - prevents double submission
* - checks whether it is in a submittable state
* - provides feedback that it is being submitted
* - submits it
*/
viewEvents["click " + this.saveSelector] = function(e) {
if (this.hasbeensubmitted) {
e.preventDefault();
e.stopPropagation();
return;
}
this.validateAllControls({update:true});
if (this.hasNoErrors && !this.disabled) {
this.hasbeensubmitted = true;
this.$el && this.$el.find(this.saveSelector).addClass("submitting");
this.saveForm(this.saveOptions, e);
} else {
jQuery(e.target).blur();
e.preventDefault();
//e.stopPropagation();
}
};
/**
* @event click:cancelSelector
* @description When user cancels the view form
*
* - discards changes
* - loads previous view
*/
viewEvents["click " + this.cancelSelector] = function(e) {
if (this.cleanModel) {
this.model.set(this.cleanModel, {silent:true});
}
// NOOOO! We don't always want to go back
this.previous();
// alternatively - though a bit expensive possibly
/*var that = this;
this.model.fetch({
success: function() {
that.previous();
}
});*/
};
return viewEvents;
}
});
return BauplanView;
});