Skip to content

Instantly share code, notes, and snippets.

@noahmiller
Created April 30, 2016 02:03
Show Gist options
  • Save noahmiller/d5188323a011dadcc9ef935c39a483bd to your computer and use it in GitHub Desktop.
Save noahmiller/d5188323a011dadcc9ef935c39a483bd to your computer and use it in GitHub Desktop.
Backbone.Model Properties by Configuration
/**
* A model base class that extends Backbone.Model to provide both
* common functionality and common model properties.
*
* Common properties are defined by property configuration data objects returned
* by the propConfig getter.
*
* Common functionality determined by propConfig includes:
* - defaults
* - server data parsing
*/
class ABaseModel extends Backbone.Model {
/**
* Returns an array of objects that are used to generate properties and
* default values for the model class. Subclasses that override this should
* concat additional prop objects to the results of super, e.g.:
* return super.propConfig.concat([{...}]);
* so that properties defined in ABaseModel or other parent classes aren't lost.
*
* Each prop object can have the following values:
* - prop {string}: the name of the property to be defined.
* Required.
* - attr {string}: the name of the Backbone model attribute. Useful if
* the server returns an attribute like `_xzserver_full_name` that would
* read better in the frontend as `fullName`.
* Defaults to `prop` value.
* - get {func}: the getter function.
* Defaults to `Backbone.Model.get(attr)`.
* - set {func}: the setter function. If this is present but undefined,
* no setter will be created for the property, resulting in a read-only prop.
* Defaults to `Backbone.Model.set(attr, value)`
* - configurable {bool}: `true` if and only if the type of this property
* descriptor may be changed and if the property may be deleted from
* the corresponding object.
* Defaults to `false`.
* - enumerable {bool}: `true` if and only if this property shows up during
* enumeration of the properties on the corresponding object.
* Defaults to `true`.
*
* - default {* | func}: default value for the property, as returned by
* Backbone.Model.defaults. If a function, called with `this` set to
* the model instance. A function should be used to return all
* object defaults (i.e. non-primitives), e.g.:
* {prop: 'data', default: function() {return {};}}
* since otherwise all model instances would share the same single object.
*
* - mapping {string}: if set, `parse` will map server values to frontend values.
* Options:
* 'date': passes the value (which should be a string in a format
* recognized by the `Date.parse()`` method) to a new `Date` constructor,
* and stores the date object in the model instance.
* 'relation': passes the value (which should be an object or an array
* of objects with model attributes) to a model constructor,
* as specified by the `class` property, and stores the new
* model instance or a model collection in the owning model instance.
* Uses a new Backbone.Collection if the server value is an array.
* - class {class}: the class to construct for relation mappings.
* Required if mapping == 'relation'.
*
* Examples:
*
* // Simple prop
* {prop: 'name'}
*
* // Prop with different server attribute name
* {prop: 'firstName', attr: 'first_name'}
*
* // Un-modifiable prop
* {prop: 'emailConfirmed', set: undefined}
*
* // Un-enumerable prop
* {prop: 'syncId', enumerable: false}
*
* // Primitive default value
* {prop: 'active', default: true}
*
* // Object default value
* {prop: 'extraData', default: function() { return {}; }}
*
* // Date default value
* {prop: 'created', default: function() { return new Date(); }}
*
* // Uses date parsing
* {prop: 'lastModified', mapping: 'date'}
*
* // Uses relationship parsing
* {prop: 'address', mapping: 'relation', class: AAddress}
*
* @returns {array}
*/
static get propConfig() {
return [];
}
/**
* Configures properties on the model class based on the results of propConfig.
* Should be called on each subclass of ABaseModel.
*/
static configureProps() {
for (const propData of this.propConfig) {
const prop = propData.prop;
// attr is optional, defaults to prop
const attr = propData.attr || propData.prop;
const descriptor = propData;
Object.defineProperty(this.prototype, prop, Object.assign({
get: function() {
return this.get(attr);
},
set: function(value) {
this.set(attr, value);
},
enumerable: true,
}, descriptor));
}
}
/**
* `Backbone.Model.defaults` override gets defaults from propConfig.
* Called automatically by Backbone.Model.constructor.
*/
get defaults() {
const defaults = {};
this.constructor.propConfig.forEach((propData) => {
const attr = propData.attr || propData.prop;
let def = propData.default;
if ('function' === typeof def)
{
def = propData.default.call(this)
}
defaults[attr] = def;
})
return defaults;
}
/**
* Returns `propConfig` array, filtered by properties that have a `mapping` value.
* @returns {array}
*/
get mappings() {
return this.constructor.propConfig.filter((propData) => {
return !!propData.mapping;
});
}
/**
* Override `Backbone.Model.parse` to map API values to model values based on
* propConfig. `parse` is called automatically whenever a model's data is
* returned by the server, in `fetch`, and `save`.
*/
parse(response) {
for (const propData of this.mappings) {
const attr = propData.attr || propData.prop;
const mapping = propData.mapping;
const val = response[attr];
if (null !== val && undefined !== val) {
switch (mapping) {
case 'date':
response[attr] = new Date(val);
break;
case 'relation':
if (val instanceof Array)
{
const collection = new Backbone.Collection();
collection.model = propData.class;
collection.add(val, {parse: true});
response[attr] = collection;
}
else
{
response[attr] = new propData.class(val, {parse: true});
}
break;
default:
throw new Error(`Unknown mapping type ${mapping} for prop ${attr}`);
}
}
}
return response;
}
}
// Create some test data
window.fred = new AContact({
id: 1,
first_name: 'Fred',
last_name: 'Barns',
email: 'fbarns@foo.com',
home: {
id: 1,
address: '55 Lux Ave',
city: 'Asheville',
state: 'NC',
},
offices: [
{
id: 2,
address: '1 State St',
city: 'NYC',
state: 'NY',
},
{
id: 3,
address: '50 Birch St',
city: 'Portland',
state: 'OR',
},
],
}, {parse: true});
window.erin = new AContact({
id: 2,
first_name: 'Erin',
last_name: 'Birch',
active: false,
created_at: 'Jan 1 1980 12:00:00 GMT-0400',
}, {parse: true});
window.home = new AAddress({
id: 4,
address: '123 Main St.',
city: 'Burlington',
state: 'VT',
}, {parse: true});
<!doctype html>
<head>
<meta charset="utf-8">
<title>Backbone.Model Properties by Configuration</title>
<meta name="viewport" content="initial-scale=1.0,user-scalable=no,maximum-scale=1,width=device-width">
</head>
<body>
<header class="page-header">
<div class="container">
<h1>Backbone.Model Properties by Configuration</h1>
<p>Example of creating Backbone.Model properties through configuration.</p>
</div>
</header>
<div>
<div>
<h2>Fred</h2>
<p>Example object created from a contact model that uses prop configuration:</p>
<pre>
window.fred = new AContact({
id: 1,
first_name: 'Fred',
last_name: 'Barns',
email: 'fbarns@foo.com',
home: {
id: 1,
address: '55 Lux Ave',
city: 'Asheville',
state: 'NC',
},
offices: [
{
id: 2,
address: '1 State St',
city: 'NYC',
state: 'NY',
},
],
}, {parse: true});
</pre>
<p>The prop config parses, sets defaults, and creates properties:</p>
<pre>
fred.firstName
> "Fred"
fred.createdAt
> Tue Apr 26 2016 14:17:16 GMT-0400 (EDT)
fred.home.address
> "55 Lux Ave"
fred.offices.get(2)
> AAddress {cid: "c3", attributes...}
</pre>
</div>
</div>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.3.3/backbone-min.js"></script>
<script src="baseModel.js"></script>
<script src="models.js"></script>
<script src="data.js"></script>
</body>
/**
* A common model superclass defines properties that all models should have.
*/
class ACommonModel extends ABaseModel {
static get propConfig() {
return super.propConfig.concat([
// Property used in server data parsing and defaults
{
prop: 'createdAt',
attr: 'created_at',
mapping: 'date',
default: function() {return new Date();},
},
]);
}
}
ACommonModel.configureProps();
/**
* An address model has properties for a street address.
*/
class AAddress extends ACommonModel {
static get propConfig() {
return super.propConfig.concat([
{prop: 'address'},
{prop: 'city'},
{prop: 'state'},
]);
}
}
AAddress.configureProps();
/**
* A contact model has properties for a personal contact, including
* one-to-one and one-to-many address relations.
*/
class AContact extends ACommonModel {
static get propConfig() {
return super.propConfig.concat([
// Properties with different server attribute names
{prop: 'firstName', attr: 'first_name'},
{prop: 'lastName', attr: 'last_name'},
// Property that can't be modified
{prop: 'email', set: undefined},
// Property that isn't enumerable, e.g. using for (const prop in model)
{prop: 'syncId', enumerable: false},
// Property with a primitive default
{prop: 'active', default: true},
// Property with an object default
{prop: 'data', default: function() { return {};}},
// To-one relation property, where a model instance is created
{prop: 'home', mapping: 'relation', class: AAddress},
// To-many relation property, where a Backbone.Collection is created
{prop: 'offices', mapping: 'relation', class: AAddress},
]);
}
}
AContact.configureProps();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment