/*
* Your installation or use of this SugarCRM file is subject to the applicable
* terms available at
* http://support.sugarcrm.com/Resources/Master_Subscription_Agreements/.
* If you do not agree to all of the applicable terms or do not have the
* authority to bind the entity as an authorized representative, then do not
* install or use this SugarCRM file.
*
* Copyright (C) SugarCRM Inc. All rights reserved.
*/
const Bean = require('../data/bean');
const BeanCollection = require('../data/bean-collection');
const DataManager = require('../data/data-manager');
/**
* Helper function to determine if {@link Core/Context#loadData} can be called
* on this context.
*
* @return {boolean} `true` if {@link Core/Context#loadData} can be called.
* `false` otherwise.
* @private
*/
function shouldFetch() {
return (this.get('fetch') === void 0 || this.get('fetch')) &&
!this.isDataFetched() && !this.get('create');
}
/**
* The Context object is a state variable to hold the current application state.
* It extends `Backbone.Model`.
*
* The context contains various states of the current {@link View/View View} or
* {@link View/Layout Layout} -- this includes the current model and collection,
* as well as the current module focused and also possibly the url hash that was
* matched.
*
* ### Instantiate a context.
*
* ```
* const Context = require('./context');
* let myContext = new Context();
* ```
*
* ### Creating a child context or retrieving an existing one.
* Assuming `myContext` is a context instance.
*
* ```
* let childContext = myContext.getChildContext({cid: <contextId>});
* ```
*
* If <contextId> does not match any child context, a new child context will be
* created.
*
* ### Retrieving Data from the Context
* A context is a Backbone Model, so its data is stored in the attributes:
*
* ```
* var module = myContext.get('module'); // module = "Contacts"
* ```
*
* @module Core/Context
* @class
*/
const Context = Backbone.Model.extend({
/**
* Calls Backbone's `initialize` and initializes this context's properties.
*
* @param {Object} [attributes] The initial hash of attributes.
* @memberOf Core/Context
* @instance
*/
initialize: function(attributes) {
Backbone.Model.prototype.initialize.call(this, attributes);
this.id = this.cid;
this.parent = null;
this.children = [];
this._fetchCalled = false;
},
/**
* Recursively clears this context and its children.
*
* Unbinds the event listeners, clears the attributes, aborts the request
* in progress if any, and resets the load flag (calls
* {@link Core/Context#resetLoadFlag}).
*
* @param {Object} [options] Standard `Backbone.Model` options.
* @memberOf Core/Context
* @instance
*/
clear: function(options) {
var collection = this.get('collection');
if (collection) {
collection.abortFetchRequest();
}
_.each(this.children, function(child) {
child.clear(options);
});
this.children = [];
this.parent = null;
// Remove event listeners attached to models and collections in the
// context before clearing them.
_.each(this.attributes, function(value) {
if (value && (value.off === Backbone.Events.off)) {
value.off();
value.stopListening();
if (_.isFunction(value.dispose)) {
value.dispose();
}
}
}, this);
this.off();
Backbone.Model.prototype.clear.call(this, options);
this.resetLoadFlag();
},
/**
* Resets `load-data` state for this context and its child contexts.
*
* The {@link Core/Context#loadData} method sets an internal boolean flag
* to prevent multiple identical requests to the server.
* This method resets this flag.
*
* @param {Object} [options] A hash of options.
* @param {boolean} [options.recursive = true] `true` to reset the child
* contexts too.
* @param {boolean} [options.resetModel = true] `true` to reset the flag on
* the model.
* @param {boolean} [options.resetCollection = true] `true` to reset the
* flag on the collection.
* @memberOf Core/Context
* @instance
*/
resetLoadFlag: function (options) {
options = options || {};
var recursive = _.isUndefined(options.recursive) ? true : options.recursive;
var resetModel = _.isUndefined(options.resetModel) ? true : options.resetModel;
var resetCollection = _.isUndefined(options.resetCollection) ? true : options.resetCollection;
this._fetchCalled = false;
if (this.get('model') && resetModel) {
this.get('model').dataFetched = false;
}
if (this.get('collection') && resetCollection) {
this.get('collection').dataFetched = false;
}
if (recursive) {
_.each(this.children, function(child) {
child.resetLoadFlag();
});
}
},
/**
* Checks if this context is used for a create view.
*
* @return {boolean} `true` if this context has the `create` flag set.
* @memberOf Core/Context
* @instance
*/
isCreate: function() {
return this.get("create") === true;
},
/**
* Gets an existing related context or creates a new one.
*
* @param {Object} [def] Child context definition.
* @param {string} [def.cid] The id of the context to retrieve. This takes
* precedence over the passed name, the link and the module.
* @param {string} [def.name] The name of the context to retrieve. This
* takes precedence over passed the link and the module.
* @param {string} [def.link] The link name to retrieve a context from. This
* takes precedence over the passed module name.
* @param {string} [def.module] The module name to retrieve a context from.
* The first child context matching the module attribute will be returned.
* @param {boolean} [def.forceNew] `true` to force the creation a new
* context, without trying to retrieve an existing one.
* @return {Core/Context} The child context.
* @memberOf Core/Context
* @instance
*/
getChildContext: function(def) {
def = def || {};
var context;
var force = def.forceNew || false;
delete def.forceNew;
// Re-use a child context if it already exists
// We search by either link name or module name
// Consider refactoring the way we store children: hash v.s. array
var name = def.cid || def.name || def.link || def.module;
if (name && !force) {
context = _.find(this.children, function(child) {
return ((child.cid == name) || (child.get("link") == name) || (child.get("module") == name));
});
}
if (!context) {
def = _.extend({ fetch: this.get('fetch') }, def);
context = new Context(def);
this.children.push(context);
context.parent = this;
}
if (def.link) {
var parentModel = this.get("model");
context.set({
parentModel: parentModel,
parentModule: parentModel ? parentModel.module : null
});
} else if(!def.module){
context.set({module:this.get("module")});
}
this.trigger("context:child:add", context);
return context;
},
/**
* Prepares instances of model and collection and sets them to this context.
*
* The method also create related contexts, based on the created bean fields:
* It creates child context for each `link` of each `collection` field
* present on the bean.
* This method does nothing if this context already contains an instance of
* a model or a collection.
*
* @param {boolean} [force] `true` to force the creation of the model and
* collection.
* @param {boolean} prepareRelated `true` to always prepare related contexts.
* @return {Core/Context} Returns this context instance.
* @memberOf Core/Context
* @instance
*/
prepare: function (force, prepareRelated) {
var link;
if (force || (!this.get('model') && !this.get('collection'))) {
var modelId = this.get('modelId');
var create = this.get('create');
link = this.get('link');
this.set(link ?
this._prepareRelated(link, modelId, create) :
this._prepare(modelId, create)
);
}
if ((force || !this._relatedCollectionsPopulated) && (!link || prepareRelated)) {
this._populateRelatedContexts();
}
return this;
},
/**
* Sets the `fetch` attribute recursively on the context and its children.
*
* A context with `fetch` set to `false` won't load the data.
*
* @param {boolean} fetch `true` to recursively set `fetch` to `true`
* in this context and its children.
* @param {Object} [options] A hash of options.
* @param {boolean} [options.recursive] `true` to recursively set the
* `fetch` boolean on the children.
* @memberOf Core/Context
* @instance
*/
setFetch: function (fetch, options) {
options = options || {};
this.set('fetch', fetch);
var recursive = options.recursive === void 0 ? true : options.recursive;
if (recursive) {
_.each(this.children, (child) => { child.setFetch(fetch); });
}
},
/**
* Prepares instances of model and collection.
*
* This method assumes that the module name (`module`) is set on the context.
* If not, instances of standard `Backbone.Model` and `Backbone.Collection`
* are created.
*
* @param {string} modelId Bean ID.
* @param {boolean} create Create flag.
* @return {Object} State to set on this context.
* @private
* @memberOf Core/Context
* @instance
*/
_prepare: function(modelId, create) {
var model, collection,
module = this.get("module"),
mixed = this.get("mixed"),
models;
if (modelId) {
model = DataManager.createBean(module, { id: modelId });
models = [model];
} else if (create === true) {
model = DataManager.createBean(module);
models = [model];
} else {
model = DataManager.createBean(module);
}
collection = mixed === true ?
DataManager.createMixedBeanCollection(models) :
DataManager.createBeanCollection(module, models);
return {
collection: collection,
model: model
};
},
/**
* Prepares instances of related model and collection.
*
* This method assumes that either a parent model (`parentModel`) or
* parent model ID (`parentModelId`) and parent model module name
* (`parentModule`) are set on this context.
*
* @param {string} link Relationship link name.
* @param {string} modelId Related bean ID.
* @param {boolean} create Create flag.
* @return {Object} State to set on this context.
* @private
* @memberOf Core/Context
* @instance
*/
_prepareRelated: function(link, modelId, create) {
var model, collection,
parentModel = this.get("parentModel");
parentModel = parentModel || DataManager.createBean(this.get("parentModule"), { id: this.get("parentModelId") });
if (modelId) {
model = DataManager.createRelatedBean(parentModel, modelId, link);
collection = DataManager.createRelatedCollection(parentModel, link, [model]);
} else if (create === true) {
model = DataManager.createRelatedBean(parentModel, null, link);
collection = DataManager.createRelatedCollection(parentModel, link, [model]);
} else {
model = DataManager.createRelatedBean(parentModel, null, link);
collection = DataManager.createRelatedCollection(parentModel, link);
}
if (!this.has("parentModule")) {
this.set({ "parentModule": parentModel.module }, { silent: true });
}
if (!this.has("module")) {
this.set({ "module": model.module }, { silent: true });
}
return {
parentModel: parentModel,
collection: collection,
model: model
};
},
/**
* Sets the `fields` attribute on this context by extending the current
* `fields` attribute with the passed-in `fieldsArray`.
*
* @param {string[]} fieldsArray The list of field names.
* @return {Core/Context} Instance of this model.
* @memberOf Core/Context
* @instance
*/
addFields: function(fieldsArray) {
if (!fieldsArray) {
return;
}
var fields = _.union(fieldsArray, this.get('fields') || []);
return this.set('fields', fields);
},
/**
* Loads data (calls fetch on either model or collection).
*
* This method sets an internal boolean flag to prevent consecutive fetch
* operations. Call {@link Core/Context#resetLoadFlag} to reset the
* context's state.
*
* @param {Object} [options] A hash of options passed to
* collection/model's fetch method.
* @param {boolean} [options.fetch] `true` to always fetch the data.
* @memberOf Core/Context
* @instance
*/
loadData: function(options) {
options = options || {};
if (!options.forceFetch && !shouldFetch.call(this)) {
return;
}
delete options.forceFetch;
var objectToFetch,
modelId = this.get("modelId"),
module = this.get("module"),
defaultOrdering = (SUGAR.App.config.orderByDefaults && module) ? SUGAR.App.config.orderByDefaults[module] : null;
objectToFetch = modelId ? this.get("model") : this.get("collection");
// If we have an orderByDefaults in the config, and this is a bean collection,
// try to use ordering from there (only if orderBy is not already set.)
if (defaultOrdering &&
objectToFetch instanceof BeanCollection &&
!objectToFetch.orderBy)
{
objectToFetch.orderBy = defaultOrdering;
}
// TODO: Figure out what to do when models are not
// instances of Bean or BeanCollection. No way to fetch.
if (objectToFetch && (objectToFetch instanceof Bean ||
objectToFetch instanceof BeanCollection)) {
if (this.get('dataView') && _.isString(this.get('dataView'))) {
objectToFetch.setOption('view', this.get('dataView'));
}
if (this.get('fields')) {
objectToFetch.setOption('fields', this.get('fields'));
}
if (this.get('limit')) {
objectToFetch.setOption('limit', this.get('limit'));
}
if (this.get('module_list')) {
objectToFetch.setOption('module_list', this.get('module_list'));
}
// Track models that user is actively viewing
if (this.get('viewed')) {
objectToFetch.setOption('viewed', this.get('viewed'));
}
options.context = this;
if (this.get("skipFetch") !== true) {
objectToFetch.fetch(options);
}
this._fetchCalled = true;
} else {
SUGAR.App.logger.warn("Skipping fetch because model is not Bean, Bean Collection, or it is not defined, module: " + this.get("module"));
}
},
/**
* Creates child context for each `link` of each `collection` field
* present on the bean.
*
* @private
* @memberOf Core/Context
* @instance
*/
_populateRelatedContexts: function () {
if (!this.get('collection')) {
return;
}
this.get('collection').each(function(bean) {
var collectionFields = bean.fieldsOfType('collection');
if (!_.isEmpty(collectionFields)) {
_.each(collectionFields, function(field) {
var links = field.links;
if (_.isString(links)) {
links = [links];
}
var linkCollections = {};
_.each(links, function(link) {
var rc = this.getChildContext({link: link});
rc.prepare();
}, this);
}, this);
}
}, this);
this._relatedCollectionsPopulated = true;
},
_shouldFetch: function() {
if (!SUGAR.App.config.sidecarCompatMode) {
SUGAR.App.logger.error('Core.Context#_shouldFetch is a private method that you are not allowed ' +
'to access. Please use only the public API.');
return;
}
SUGAR.App.logger.warn('Core.Context#_shouldFetch is a private method that you should not access. ' +
'You will NOT be allowed to access it in the next release. Please update your code to rely on the public ' +
'API only.');
return shouldFetch.call(this);
},
/**
* Refreshes the context's data and refetches the new data if
* the `skipFetch` attribute is `true`.
*
* @param {Object} [options] Options for {@link Core/Context#loadData} and
* the `reload` event.
* @fires reload
* @memberOf Core/Context
* @instance
*/
reloadData: function(options) {
options = options || {};
this.resetLoadFlag(options);
this.loadData(options);
/**
* Triggered before and after the context is reloaded.
* @param {Core/Context} this The context instance where the event is
* triggered.
* @param {Object} [options] The options passed during
* {@link Core/Context#reloadData} call.
* @memberOf Core/Context
* @event reload
*/
this.trigger('reload', this, options);
},
/**
* Checks if data has been successfully loaded.
*
* @return {boolean} `true` if data has been fetched, `false` otherwise.
* @memberOf Core/Context
* @instance
*/
isDataFetched: function() {
var objectToFetch = this.get('modelId') ? this.get('model') : this.get('collection');
return this._fetchCalled || (objectToFetch && !!objectToFetch.dataFetched);
},
});
module.exports = Context;