UNPKG

can

Version:

MIT-licensed, client-side, JavaScript framework that makes building rich web applications easy.

684 lines (608 loc) 23.6 kB
steal('can/util', 'can/map', 'can/list', function (can) { /** @add can.Model **/ // ## model.js // (Don't steal this file directly in your code.) // ## pipe // `pipe` lets you pipe the results of a successful deferred // through a function before resolving the deferred. var pipe = function (def, thisArg, func) { // The piped result will be available through a new Deferred. var d = new can.Deferred(); def.then(function () { var args = can.makeArray(arguments), success = true; try { // Pipe the results through the function. args[0] = func.apply(thisArg, args); } catch (e) { success = false; // The function threw an error, so reject the Deferred. d.rejectWith(d, [e].concat(args)); } if (success) { // Resolve the new Deferred with the piped value. d.resolveWith(d, args); } }, function () { // Pass on the rejection if the original Deferred never resolved. d.rejectWith(this, arguments); }); // `can.ajax` returns a Deferred with an abort method to halt the AJAX call. if (typeof def.abort === 'function') { d.abort = function () { return def.abort(); }; } // Return the new (piped) Deferred. return d; }, // ## modelNum // When new model constructors are set up without a full name, // `modelNum` lets us name them uniquely (to keep track of them). modelNum = 0, // ## getId getId = function (inst) { // `can.__observe` makes a note that `id` was just read. can.__observe(inst, inst.constructor.id); // Use `__get` instead of `attr` for performance. (But that means we have to remember to call `can.__observe`.) return inst.___get(inst.constructor.id); }, // ## ajax // This helper method makes it easier to make an AJAX call from the configuration of the Model. ajax = function (ajaxOb, data, type, dataType, success, error) { var params = {}; // A string here would be something like `"GET /endpoint"`. if (typeof ajaxOb === 'string') { // Split on spaces to separate the HTTP method and the URL. var parts = ajaxOb.split(/\s+/); params.url = parts.pop(); if (parts.length) { params.type = parts.pop(); } } else { // If the first argument is an object, just load it into `params`. can.extend(params, ajaxOb); } // If the `data` argument is a plain object, copy it into `params`. params.data = typeof data === "object" && !can.isArray(data) ? can.extend(params.data || {}, data) : data; // Substitute in data for any templated parts of the URL. params.url = can.sub(params.url, params.data, true); return can.ajax(can.extend({ type: type || 'post', dataType: dataType || 'json', success: success, error: error }, params)); }, // ## makeRequest // This function abstracts making the actual AJAX request away from the Model. makeRequest = function (modelObj, type, success, error, method) { var args; // If `modelObj` is an Array, it it means we are coming from // the queued request, and we're passing already-serialized data. if (can.isArray(modelObj)) { // In that case, modelObj's signature will be `[modelObj, serializedData]`, so we need to unpack it. args = modelObj[1]; modelObj = modelObj[0]; } else { // If we aren't supplied with serialized data, we'll make our own. args = modelObj.serialize(); } args = [args]; var deferred, model = modelObj.constructor, jqXHR; // When calling `update` and `destroy`, the current ID needs to be the first parameter in the AJAX call. if (type === 'update' || type === 'destroy') { args.unshift(getId(modelObj)); } jqXHR = model[type].apply(model, args); // Make sure that can.Model can react to the request before anything else does. deferred = pipe(jqXHR, modelObj, function (data) { // `method` is here because `"destroyed" !== "destroy" + "d"`. // TODO: Do something smarter/more consistent here? modelObj[method || type + "d"](data, jqXHR); return modelObj; }); // Hook up `abort` if (jqXHR.abort) { deferred.abort = function () { jqXHR.abort(); }; } deferred.then(success, error); return deferred; }, converters = { // ## models // The default function for converting into a list of models. Needs to be stored separate // because we will reference it in models static `setup`, too. models: function (instancesRawData, oldList, xhr) { // Increment reqs counter so new instances will be added to the store. // (This is cleaned up at the end of the method.) can.Model._reqs++; // If there is no data, we can't really do anything with it. if (!instancesRawData) { return; } // If the "raw" data is already a List, it's not raw. if (instancesRawData instanceof this.List) { return instancesRawData; } var self = this, // `tmp` will hold the models before we push them onto `modelList`. tmp = [], // `ML` (see way below) is just `can.Model.List`. ListClass = self.List || ML, modelList = oldList instanceof can.List ? oldList : new ListClass(), // Check if we were handed an Array or a model list. rawDataIsList = instancesRawData instanceof ML, // Get the "plain" objects from the models from the list/array. raw = rawDataIsList ? instancesRawData.serialize() : instancesRawData; raw = self.parseModels(raw, xhr); if(raw.data) { instancesRawData = raw; raw = raw.data; } if (typeof raw === 'undefined' || !can.isArray(raw)) { throw new Error('Could not get any raw data while converting using .models'); } //!steal-remove-start if (!raw.length) { can.dev.warn("model.js models has no data."); } //!steal-remove-end // If there was anything left in the list we were given, get rid of it. if (modelList.length) { modelList.splice(0); } // If we pushed these directly onto the list, it would cause a change event for each model. // So, we push them onto `tmp` first and then push everything at once, causing one atomic change event that contains all the models at once. can.each(raw, function (rawPart) { tmp.push(self.model(rawPart, xhr)); }); modelList.push.apply(modelList, tmp); // If there was other stuff on `instancesRawData`, let's transfer that onto `modelList` too. if (!can.isArray(instancesRawData)) { can.each(instancesRawData, function (val, prop) { if (prop !== 'data') { modelList.attr(prop, val); } }); } // Clean up the store on the next turn of the event loop. (`this` is a model constructor.) setTimeout(can.proxy(this._clean, this), 1); return modelList; }, // ## model // A function that, when handed a plain object, turns it into a model. model: function (attributes, oldModel, xhr) { // If there're no properties, there can be no model. if (!attributes) { return; } // If this object knows how to serialize, parse, or access itself, we'll use that instead. if (typeof attributes.serialize === 'function') { attributes = attributes.serialize(); } else { attributes = this.parseModel(attributes, xhr); } var id = attributes[this.id]; // Models from the store always have priority // 0 is a valid ID. if((id || id === 0) && this.store[id]) { oldModel = this.store[id]; } var model = oldModel && can.isFunction(oldModel.attr) ? // If this model is in the store already, just update it. oldModel.attr(attributes, this.removeAttr || false) : // Otherwise, we need a new model. new this(attributes); return model; } }, // ## makeParser // This object describes how to take the data from an AJAX request and prepare it for `models` and `model`. // These functions are meant to be overwritten (if necessary) in an extended model constructor. makeParser = { parseModel: function (prop) { return function (attributes) { return prop ? can.getObject(prop, attributes) : attributes; }; }, parseModels: function (prop) { return function (attributes) { if(can.isArray(attributes)) { return attributes; } prop = prop || 'data'; var result = can.getObject(prop, attributes); if(!can.isArray(result)) { throw new Error('Could not get any raw data while converting using .models'); } return result; }; } }, // ## ajaxMethods // This object describes how to make an AJAX request for each ajax method (`create`, `update`, etc.) // Each AJAX method is an object in `ajaxMethods` and can have the following properties: // // - `url`: Which property on the model contains the default URL for this method. // - `type`: The default HTTP request method. // - `data`: A method that takes the arguments from `makeRequest` (see above) and returns a data object for use in the AJAX call. ajaxMethods = { create: { url: "_shortName", type: "post" }, update: { // ## update.data data: function (id, attrs) { attrs = attrs || {}; // `this.id` is the property that represents the ID (and is usually `"id"`). var identity = this.id; // If the value of the property being used as the ID changed, // indicate that in the request and replace the current ID property. if (attrs[identity] && attrs[identity] !== id) { attrs["new" + can.capitalize(id)] = attrs[identity]; delete attrs[identity]; } attrs[identity] = id; return attrs; }, type: "put" }, destroy: { type: 'delete', // ## destroy.data data: function (id, attrs) { attrs = attrs || {}; // `this.id` is the property that represents the ID (and is usually `"id"`). attrs.id = attrs[this.id] = id; return attrs; } }, findAll: { url: "_shortName" }, findOne: {} }, // ## ajaxMaker // Takes a method defined just above and a string that describes how to call that method // and makes a function that calls that method with the given data. // // - `ajaxMethod`: The object defined above in `ajaxMethods`. // - `str`: The string the configuration provided (such as `"/recipes.json"` for a `findAll` call). ajaxMaker = function (ajaxMethod, str) { return function (data) { data = ajaxMethod.data ? // If the AJAX method mentioned above has its own way of getting `data`, use that. ajaxMethod.data.apply(this, arguments) : // Otherwise, just use the data passed in. data; // Make the AJAX call with the URL, data, and type indicated by the proper `ajaxMethod` above. return ajax(str || this[ajaxMethod.url || "_url"], data, ajaxMethod.type || "get"); }; }, // ## createURLFromResource // For each of the names (create, update, destroy, findOne, and findAll) use the // URL provided by the `resource` property. For example: // // ToDo = can.Model.extend({ // resource: "/todos" // }, {}); // // Will create a can.Model that is identical to: // // ToDo = can.Model.extend({ // findAll: "GET /todos", // findOne: "GET /todos/{id}", // create: "POST /todos", // update: "PUT /todos/{id}", // destroy: "DELETE /todos/{id}" // },{}); // // - `model`: the can.Model that has the resource property // - `method`: a property from the ajaxMethod object createURLFromResource = function(model, name) { if (!model.resource) { return; } var resource = model.resource.replace(/\/+$/, ""); if (name === "findAll" || name === "create") { return resource; } else { return resource + "/{" + model.id + "}"; } }; // # can.Model // A can.Map that connects to a RESTful interface. /** @static */ can.Model = can.Map.extend({ // `fullName` identifies the model type in debugging. fullName: "can.Model", _reqs: 0, // ## can.Model.setup setup: function (base, fullName, staticProps, protoProps) { // Assume `fullName` wasn't passed. (`can.Model.extend({ ... }, { ... })`) // This is pretty usual. if (typeof fullName !== "string") { protoProps = staticProps; staticProps = fullName; } // Assume no static properties were passed. (`can.Model.extend({ ... })`) // This is really unusual for a model though, since there's so much configuration. if (!protoProps) { //!steal-remove-start can.dev.warn("can/model/model.js: can.Model extended without static properties."); //!steal-remove-end protoProps = staticProps; } // Create the model store here, in case someone wants to use can.Model without inheriting from it. this.store = {}; can.Map.setup.apply(this, arguments); if (!can.Model) { return; } // `List` is just a regular can.Model.List that knows what kind of Model it's hooked up to. if(staticProps && staticProps.List) { this.List = staticProps.List; this.List.Map = this; } else { this.List = base.List.extend({ Map: this }, {}); } var self = this, clean = can.proxy(this._clean, self); // Go through `ajaxMethods` and set up static methods according to their configurations. can.each(ajaxMethods, function (method, name) { // Check the configuration for this ajaxMethod. // If the configuration isn't a function, it should be a string (like `"GET /endpoint"`) // or an object like `{url: "/endpoint", type: 'GET'}`. //if we have a string(like `"GET /endpoint"`) or an object(ajaxSettings) set in the static definition(not inherited), //convert it to a function. if(staticProps && staticProps[name] && (typeof staticProps[name] === 'string' || typeof staticProps[name] === 'object')) { self[name] = ajaxMaker(method, staticProps[name]); } //if we have a resource property set in the static definition, but check if function exists already else if(staticProps && staticProps.resource && !can.isFunction(staticProps[name])) { self[name] = ajaxMaker(method, createURLFromResource(self, name)); } // There may also be a "maker" function (like `makeFindAll`) that alters the behavior of acting upon models // by changing when and how the function we just made with `ajaxMaker` gets called. // For example, you might cache responses and only make a call when you don't have a cached response. if (self["make" + can.capitalize(name)]) { // Use the "maker" function to make the new "ajaxMethod" function. var newMethod = self["make" + can.capitalize(name)](self[name]); // Replace the "ajaxMethod" function in the configuration with the new one. // (`_overwrite` just overwrites a property in a given Construct.) can.Construct._overwrite(self, base, name, function () { // Increment the numer of requests... can.Model._reqs++; // ...make the AJAX call (and whatever else you're doing)... var def = newMethod.apply(this, arguments); // ...and clean up the store. var then = def.then(clean, clean); // Pass along `abort` so you can still abort the AJAX call. then.abort = def.abort; return then; }); } }); var hasCustomConverter = {}; // Set up `models` and `model`. can.each(converters, function(converter, name) { var parseName = "parse" + can.capitalize(name), dataProperty = (staticProps && staticProps[name]) || self[name]; // For legacy e.g. models: 'someProperty' we set the `parseModel(s)` property // to the given string and set .model(s) to the original converter if(typeof dataProperty === 'string') { self[parseName] = dataProperty; can.Construct._overwrite(self, base, name, converter); } else if((staticProps && staticProps[name])) { hasCustomConverter[parseName] = true; } }); // Sets up parseModel(s) can.each(makeParser, function(maker, parseName) { var prop = (staticProps && staticProps[parseName]) || self[parseName]; // e.g. parseModels: 'someProperty' make a default parseModel(s) if(typeof prop === 'string') { can.Construct._overwrite(self, base, parseName, maker(prop)); } else if( (!staticProps || !can.isFunction(staticProps[parseName])) && !self[parseName] ) { var madeParser = maker(); madeParser.useModelConverter = hasCustomConverter[parseName]; // Add a default parseModel(s) if there is none can.Construct._overwrite(self, base, parseName, madeParser); } }); // Make sure we have a unique name for this Model. if (self.fullName === "can.Model" || !self.fullName) { self.fullName = "Model" + (++modelNum); } can.Model._reqs = 0; this._url = this._shortName + "/{" + this.id + "}"; }, _ajax: ajaxMaker, _makeRequest: makeRequest, // ## can.Model._clean // `_clean` cleans up the model store after a request happens. _clean: function () { can.Model._reqs--; // Don't clean up unless we have no pending requests. if (!can.Model._reqs) { for (var id in this.store) { // Delete all items in the store without any event bindings. if (!this.store[id]._bindings) { delete this.store[id]; } } } return arguments[0]; }, models: converters.models, model: converters.model }, /** @prototype */ { // ## can.Model#setup setup: function (attrs) { // Try to add things as early as possible to the store (#457). // This is the earliest possible moment, even before any properties are set. var id = attrs && attrs[this.constructor.id]; if (can.Model._reqs && id != null) { this.constructor.store[id] = this; } can.Map.prototype.setup.apply(this, arguments); }, // ## can.Model#isNew // Something is new if its ID is `null` or `undefined`. isNew: function () { var id = getId(this); // 0 is a valid ID. // TODO: Why not `return id === null || id === undefined;`? return !(id || id === 0); // If `null` or `undefined` }, // ## can.Model#save // `save` calls `create` or `update` as necessary, based on whether a model is new. save: function (success, error) { return makeRequest(this, this.isNew() ? 'create' : 'update', success, error); }, // ## can.Model#destroy // Acts like can.Map.destroy but it also makes an AJAX call. destroy: function (success, error) { // If this model is new, don't make an AJAX call. // Instead, we have to construct the Deferred ourselves and return it. if (this.isNew()) { var self = this; var def = can.Deferred(); def.then(success, error); return def.done(function (data) { self.destroyed(data); }).resolve(self); } // If it isn't new, though, go ahead and make a request. return makeRequest(this, 'destroy', success, error, 'destroyed'); }, // ## can.Model#bind and can.Model#unbind // These aren't actually implemented here, but their setup needs to be changed to account for the store. _bindsetup: function () { var modelInstance = this.___get(this.constructor.id); if (modelInstance != null) { this.constructor.store[modelInstance ] = this; } return can.Map.prototype._bindsetup.apply(this, arguments); }, _bindteardown: function () { delete this.constructor.store[getId(this)]; return can.Map.prototype._bindteardown.apply(this, arguments); }, // Change the behavior of `___set` to account for the store. ___set: function (prop, val) { can.Map.prototype.___set.call(this, prop, val); // If we add or change the ID, update the store accordingly. // TODO: shouldn't this also delete the record from the old ID in the store? if (prop === this.constructor.id && this._bindings) { this.constructor.store[getId(this)] = this; } } }); // Returns a function that knows how to prepare data from `findAll` or `findOne` calls. // `name` should be either `model` or `models`. var makeGetterHandler = function (name) { return function (data, readyState, xhr) { return this[name](data, null, xhr); }; }, // Handle data returned from `create`, `update`, and `destroy` calls. createUpdateDestroyHandler = function (data) { if(this.parseModel.useModelConverter) { return this.model(data); } return this.parseModel(data); }; var responseHandlers = { makeFindAll: makeGetterHandler("models"), makeFindOne: makeGetterHandler("model"), makeCreate: createUpdateDestroyHandler, makeUpdate: createUpdateDestroyHandler, makeDestroy: createUpdateDestroyHandler }; // Go through the response handlers and make the actual "make" methods. can.each(responseHandlers, function (method, name) { can.Model[name] = function (oldMethod) { return function () { var args = can.makeArray(arguments), // If args[1] is a function, we were only passed one argument before success and failure callbacks. oldArgs = can.isFunction(args[1]) ? args.splice(0, 1) : args.splice(0, 2), // Call the AJAX method (`findAll` or `update`, etc.) and pipe it through the response handler from above. def = pipe(oldMethod.apply(this, oldArgs), this, method); def.then(args[0], args[1]); return def; }; }; }); // ## can.Model.created, can.Model.updated, and can.Model.destroyed // Livecycle methods for models. can.each([ "created", "updated", "destroyed" ], function (funcName) { // Each of these is pretty much the same, except for the events they trigger. can.Model.prototype[funcName] = function (attrs) { var self = this, constructor = self.constructor; // Update attributes if attributes have been passed if(attrs && typeof attrs === 'object') { this.attr(can.isFunction(attrs.attr) ? attrs.attr() : attrs); } // triggers change event that bubble's like // handler( 'change','1.destroyed' ). This is used // to remove items on destroyed from Model Lists. // but there should be a better way. can.dispatch.call(this, {type:funcName, target: this}, []); //!steal-remove-start can.dev.log("Model.js - " + constructor.shortName + " " + funcName); //!steal-remove-end // Call event on the instance's Class can.dispatch.call(constructor, funcName, [this]); }; }); // # can.Model.List // Model Lists are just like `Map.List`s except that when their items are // destroyed, they automatically get removed from the List. var ML = can.Model.List = can.List.extend({ // ## can.Model.List.setup // On change or a nested named event, setup change bubbling. // On any other type of event, setup "destroyed" bubbling. _bubbleRule: function(eventName, list) { var bubbleRules = can.List._bubbleRule(eventName, list); bubbleRules.push('destroyed'); return bubbleRules; } },{ setup: function (params) { // If there was a plain object passed to the List constructor, // we use those as parameters for an initial findAll. if (can.isPlainObject(params) && !can.isArray(params)) { can.List.prototype.setup.apply(this); this.replace(can.isPromise(params) ? params : this.constructor.Map.findAll(params)); } else { // Otherwise, set up the list like normal. can.List.prototype.setup.apply(this, arguments); } this.bind('destroyed', can.proxy(this._destroyed, this)); }, _destroyed: function (ev, attr) { if (/\w+/.test(attr)) { var index; while((index = this.indexOf(ev.target)) > -1) { this.splice(index, 1); } } } }); return can.Model; });