forked from AmpersandJS/ampersand-state
-
Notifications
You must be signed in to change notification settings - Fork 0
/
ampersand-state.js
807 lines (716 loc) · 27.7 KB
/
ampersand-state.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
'use strict';
/*$AMPERSAND_VERSION*/
var uniqueId = require('lodash.uniqueid');
var assign = require('lodash.assign');
var cloneObj = function(obj) { return assign({}, obj); };
var omit = require('lodash.omit');
var escape = require('lodash.escape');
var forOwn = require('lodash.forown');
var includes = require('lodash.includes');
var isString = require('lodash.isstring');
var isObject = require('lodash.isobject');
var isDate = require('lodash.isdate');
var isFunction = require('lodash.isfunction');
var _isEqual = require('lodash.isequal'); // to avoid shadowing
var has = require('lodash.has');
var result = require('lodash.result');
var bind = require('lodash.bind'); // because phantomjs doesn't have Function#bind
var union = require('lodash.union');
var Events = require('ampersand-events');
var KeyTree = require('key-tree-store');
var arrayNext = require('array-next');
var changeRE = /^change:/;
function Base(attrs, options) {
options || (options = {});
this.cid || (this.cid = uniqueId('state'));
this._events = {};
this._values = {};
this._definition = Object.create(this._definition);
if (options.parse) attrs = this.parse(attrs, options);
this.parent = options.parent;
this.collection = options.collection;
this._keyTree = new KeyTree();
this._initCollections();
this._initChildren();
this._cache = {};
this._previousAttributes = {};
if (attrs) this.set(attrs, assign({silent: true, initial: true}, options));
this._changed = {};
if (this._derived) this._initDerived();
if (options.init !== false) this.initialize.apply(this, arguments);
}
assign(Base.prototype, Events, {
// can be allow, ignore, reject
extraProperties: 'ignore',
idAttribute: 'id',
namespaceAttribute: 'namespace',
typeAttribute: 'modelType',
// Stubbed out to be overwritten
initialize: function () {
return this;
},
// Get ID of model per configuration.
// Should *always* be how ID is determined by other code.
getId: function () {
return this[this.idAttribute];
},
// Get namespace of model per configuration.
// Should *always* be how namespace is determined by other code.
getNamespace: function () {
return this[this.namespaceAttribute];
},
// Get type of model per configuration.
// Should *always* be how type is determined by other code.
getType: function () {
return this[this.typeAttribute];
},
// A model is new if it has never been saved to the server, and lacks an id.
isNew: function () {
return this.getId() == null;
},
// get HTML-escaped value of attribute
escape: function (attr) {
return escape(this.get(attr));
},
// Check if the model is currently in a valid state.
isValid: function (options) {
return this._validate({}, assign(options || {}, { validate: true }));
},
// Parse can be used remap/restructure/rename incoming properties
// before they are applied to attributes.
parse: function (resp, options) {
//jshint unused:false
return resp;
},
// Serialize is the inverse of `parse` it lets you massage data
// on the way out. Before, sending to server, for example.
serialize: function (options) {
var attrOpts = assign({props: true}, options);
var res = this.getAttributes(attrOpts, true);
forOwn(this._children, function (value, key) {
res[key] = this[key].serialize();
}, this);
forOwn(this._collections, function (value, key) {
res[key] = this[key].serialize();
}, this);
return res;
},
// Main set method used by generated setters/getters and can
// be used directly if you need to pass options or set multiple
// properties at once.
set: function (key, value, options) {
var self = this;
var extraProperties = this.extraProperties;
var changing, changes, newType, newVal, def, cast, err, attr,
attrs, dataType, silent, unset, currentVal, initial, hasChanged, isEqual;
// Handle both `"key", value` and `{key: value}` -style arguments.
if (isObject(key) || key === null) {
attrs = key;
options = value;
} else {
attrs = {};
attrs[key] = value;
}
options = options || {};
if (!this._validate(attrs, options)) return false;
// Extract attributes and options.
unset = options.unset;
silent = options.silent;
initial = options.initial;
changes = [];
changing = this._changing;
this._changing = true;
// if not already changing, store previous
if (!changing) {
this._previousAttributes = this.attributes;
this._changed = {};
}
// For each `set` attribute...
for (attr in attrs) {
newVal = attrs[attr];
newType = typeof newVal;
currentVal = this._values[attr];
def = this._definition[attr];
if (!def) {
// if this is a child model or collection
if (this._children[attr] || this._collections[attr]) {
this[attr].set(newVal, options);
continue;
} else if (extraProperties === 'ignore') {
continue;
} else if (extraProperties === 'reject') {
throw new TypeError('No "' + attr + '" property defined on ' + (this.type || 'this') + ' model and extraProperties not set to "ignore" or "allow"');
} else if (extraProperties === 'allow') {
def = this._createPropertyDefinition(attr, 'any');
} else if (extraProperties) {
throw new TypeError('Invalid value for extraProperties: "' + extraProperties + '"');
}
}
isEqual = this._getCompareForType(def.type);
dataType = this._dataTypes[def.type];
// check type if we have one
if (dataType && dataType.set) {
cast = dataType.set(newVal);
newVal = cast.val;
newType = cast.type;
}
// If we've defined a test, run it
if (def.test) {
err = def.test.call(this, newVal, newType);
if (err) {
throw new TypeError('Property \'' + attr + '\' failed validation with error: ' + err);
}
}
// If we are required but undefined, throw error.
// If we are null and are not allowing null, throw error
// If we have a defined type and the new type doesn't match, and we are not null, throw error.
if (newVal === undefined && def.required) {
throw new TypeError('Required property \'' + attr + '\' must be of type ' + def.type + '. Tried to set ' + newVal);
}
if (newVal === null && def.required && !def.allowNull) {
throw new TypeError('Property \'' + attr + '\' must be of type ' + def.type + ' (cannot be null). Tried to set ' + newVal);
}
if ((def.type && def.type !== 'any' && def.type !== newType) && newVal !== null && newVal !== undefined) {
throw new TypeError('Property \'' + attr + '\' must be of type ' + def.type + '. Tried to set ' + newVal);
}
if (def.values && !includes(def.values, newVal)) {
throw new TypeError('Property \'' + attr + '\' must be one of values: ' + def.values.join(', ') + '. Tried to set ' + newVal);
}
hasChanged = !isEqual(currentVal, newVal, attr);
// enforce `setOnce` for properties if set
if (def.setOnce && currentVal !== undefined && hasChanged && !initial) {
throw new TypeError('Property \'' + attr + '\' can only be set once.');
}
// keep track of changed attributes
// and push to changes array
if (hasChanged) {
changes.push({prev: currentVal, val: newVal, key: attr});
self._changed[attr] = newVal;
} else {
delete self._changed[attr];
}
}
// actually update our values
changes.forEach(function (change) {
self._previousAttributes[change.key] = change.prev;
if (unset) {
delete self._values[change.key];
} else {
self._values[change.key] = change.val;
}
});
if (!silent && changes.length) self._pending = true;
if (!silent) {
changes.forEach(function (change) {
self.trigger('change:' + change.key, self, change.val, options);
});
}
// You might be wondering why there's a `while` loop here. Changes can
// be recursively nested within `"change"` events.
if (changing) return this;
if (!silent) {
while (this._pending) {
this._pending = false;
this.trigger('change', this, options);
}
}
this._pending = false;
this._changing = false;
return this;
},
get: function (attr) {
return this[attr];
},
// Toggle boolean properties or properties that have a `values`
// array in its definition.
toggle: function (property) {
var def = this._definition[property];
if (def.type === 'boolean') {
// if it's a bool, just flip it
this[property] = !this[property];
} else if (def && def.values) {
// If it's a property with an array of values
// skip to the next one looping back if at end.
this[property] = arrayNext(def.values, this[property]);
} else {
throw new TypeError('Can only toggle properties that are type `boolean` or have `values` array.');
}
return this;
},
// Get all of the attributes of the model at the time of the previous
// `"change"` event.
previousAttributes: function () {
return cloneObj(this._previousAttributes);
},
// Determine if the model has changed since the last `"change"` event.
// If you specify an attribute name, determine if that attribute has changed.
hasChanged: function (attr) {
if (attr == null) return !!Object.keys(this._changed).length;
return has(this._changed, attr);
},
// Return an object containing all the attributes that have changed, or
// false if there are no changed attributes. Useful for determining what
// parts of a view need to be updated and/or what attributes need to be
// persisted to the server. Unset attributes will be set to undefined.
// You can also pass an attributes object to diff against the model,
// determining if there *would be* a change.
changedAttributes: function (diff) {
if (!diff) return this.hasChanged() ? cloneObj(this._changed) : false;
var val, changed = false;
var old = this._changing ? this._previousAttributes : this.attributes;
var def, isEqual;
for (var attr in diff) {
def = this._definition[attr];
if (!def) continue;
isEqual = this._getCompareForType(def.type);
if (isEqual(old[attr], (val = diff[attr]))) continue;
(changed || (changed = {}))[attr] = val;
}
return changed;
},
toJSON: function () {
return this.serialize();
},
unset: function (attrs, options) {
var self = this;
attrs = Array.isArray(attrs) ? attrs : [attrs];
attrs.forEach(function (key) {
var def = self._definition[key];
var val;
if (def.required) {
val = result(def, 'default');
return self.set(key, val, options);
} else {
return self.set(key, val, assign({}, options, {unset: true}));
}
});
},
clear: function (options) {
var self = this;
Object.keys(this.attributes).forEach(function (key) {
self.unset(key, options);
});
return this;
},
previous: function (attr) {
if (attr == null || !Object.keys(this._previousAttributes).length) return null;
return this._previousAttributes[attr];
},
// Get default values for a certain type
_getDefaultForType: function (type) {
var dataType = this._dataTypes[type];
return dataType && dataType['default'];
},
// Determine which comparison algorithm to use for comparing a property
_getCompareForType: function (type) {
var dataType = this._dataTypes[type];
if (dataType && dataType.compare) return bind(dataType.compare, this);
return _isEqual; // if no compare function is defined, use _.isEqual
},
// Run validation against the next complete set of model attributes,
// returning `true` if all is well. Otherwise, fire an `"invalid"` event.
_validate: function (attrs, options) {
if (!options.validate || !this.validate) return true;
attrs = assign({}, this.attributes, attrs);
var error = this.validationError = this.validate(attrs, options) || null;
if (!error) return true;
this.trigger('invalid', this, error, assign(options || {}, {validationError: error}));
return false;
},
_createPropertyDefinition: function (name, desc, isSession) {
return createPropertyDefinition(this, name, desc, isSession);
},
// just makes friendlier errors when trying to define a new model
// only used when setting up original property definitions
_ensureValidType: function (type) {
return includes(['string', 'number', 'boolean', 'array', 'object', 'date', 'any']
.concat(Object.keys(this._dataTypes)), type) ? type : undefined;
},
getAttributes: function (options, raw) {
assign({
session: false,
props: false,
derived: false
}, options || {});
var res = {};
var val, item, def;
for (item in this._definition) {
def = this._definition[item];
if ((options.session && def.session) || (options.props && !def.session)) {
val = (raw) ? this._values[item] : this[item];
if (typeof val === 'undefined') val = result(def, 'default');
if (typeof val !== 'undefined') res[item] = val;
}
}
if (options.derived) {
for (item in this._derived) res[item] = this[item];
}
return res;
},
_initDerived: function () {
var self = this;
forOwn(this._derived, function (value, name) {
var def = self._derived[name];
def.deps = def.depList;
var update = function (options) {
options = options || {};
var newVal = def.fn.call(self);
if (self._cache[name] !== newVal || !def.cache) {
if (def.cache) {
self._previousAttributes[name] = self._cache[name];
}
self._cache[name] = newVal;
self.trigger('change:' + name, self, self._cache[name]);
}
};
def.deps.forEach(function (propString) {
self._keyTree.add(propString, update);
});
});
this.on('all', function (eventName) {
if (changeRE.test(eventName)) {
self._keyTree.get(eventName.split(':')[1]).forEach(function (fn) {
fn();
});
}
}, this);
},
_getDerivedProperty: function (name, flushCache) {
// is this a derived property that is cached
if (this._derived[name].cache) {
//set if this is the first time, or flushCache is set
if (flushCache || !this._cache.hasOwnProperty(name)) {
this._cache[name] = this._derived[name].fn.apply(this);
}
return this._cache[name];
} else {
return this._derived[name].fn.apply(this);
}
},
_initCollections: function () {
var coll;
if (!this._collections) return;
for (coll in this._collections) {
this._safeSet(coll, new this._collections[coll](null, {parent: this}));
}
},
_initChildren: function () {
var child;
if (!this._children) return;
for (child in this._children) {
this._safeSet(child, new this._children[child]({}, {parent: this}));
this.listenTo(this[child], 'all', this._getEventBubblingHandler(child));
}
},
// Returns a bound handler for doing event bubbling while
// adding a name to the change string.
_getEventBubblingHandler: function (propertyName) {
return bind(function (name, model, newValue) {
if (changeRE.test(name)) {
this.trigger('change:' + propertyName + '.' + name.split(':')[1], model, newValue);
} else if (name === 'change') {
this.trigger('change', this);
}
}, this);
},
// Check that all required attributes are present
_verifyRequired: function () {
var attrs = this.attributes; // should include session
for (var def in this._definition) {
if (this._definition[def].required && typeof attrs[def] === 'undefined') {
return false;
}
}
return true;
},
// expose safeSet method
_safeSet: function safeSet(property, value) {
if (property in this) {
throw new Error('Encountered namespace collision while setting instance property `' + property + '`');
}
this[property] = value;
return this;
}
});
// getter for attributes
Object.defineProperties(Base.prototype, {
attributes: {
get: function () {
return this.getAttributes({props: true, session: true});
}
},
all: {
get: function () {
return this.getAttributes({
session: true,
props: true,
derived: true
});
}
},
isState: {
get: function () { return true; },
set: function () { }
}
});
// helper for creating/storing property definitions and creating
// appropriate getters/setters
function createPropertyDefinition(object, name, desc, isSession) {
var def = object._definition[name] = {};
var type, descArray;
if (isString(desc)) {
// grab our type if all we've got is a string
type = object._ensureValidType(desc);
if (type) def.type = type;
} else {
//Transform array of ['type', required, default] to object form
if (Array.isArray(desc)) {
descArray = desc;
desc = {
type: descArray[0],
required: descArray[1],
'default': descArray[2]
};
}
type = object._ensureValidType(desc.type);
if (type) def.type = type;
if (desc.required) def.required = true;
if (desc['default'] && typeof desc['default'] === 'object') {
throw new TypeError('The default value for ' + name + ' cannot be an object/array, must be a value or a function which returns a value/object/array');
}
def['default'] = desc['default'];
def.allowNull = desc.allowNull ? desc.allowNull : false;
if (desc.setOnce) def.setOnce = true;
if (def.required && def['default'] === undefined && !def.setOnce) def['default'] = object._getDefaultForType(type);
def.test = desc.test;
def.values = desc.values;
}
if (isSession) def.session = true;
// define a getter/setter on the prototype
// but they get/set on the instance
Object.defineProperty(object, name, {
set: function (val) {
this.set(name, val);
},
get: function () {
if (!this._values) {
throw Error('You may be trying to `extend` a state object with "' + name + '" which has been defined in `props` on the object being extended');
}
var value = this._values[name];
var typeDef = this._dataTypes[def.type];
if (typeof value !== 'undefined') {
if (typeDef && typeDef.get) {
value = typeDef.get(value);
}
return value;
}
value = result(def, 'default');
this._values[name] = value;
return value;
}
});
return def;
}
// helper for creating derived property definitions
function createDerivedProperty(modelProto, name, definition) {
var def = modelProto._derived[name] = {
fn: isFunction(definition) ? definition : definition.fn,
cache: (definition.cache !== false),
depList: definition.deps || []
};
// add to our shared dependency list
def.depList.forEach(function (dep) {
modelProto._deps[dep] = union(modelProto._deps[dep] || [], [name]);
});
// defined a top-level getter for derived names
Object.defineProperty(modelProto, name, {
get: function () {
return this._getDerivedProperty(name);
},
set: function () {
throw new TypeError('"' + name + '" is a derived property, it can\'t be set directly.');
}
});
}
var dataTypes = {
string: {
'default': function () {
return '';
}
},
date: {
set: function (newVal) {
var newType;
if (newVal == null) {
newType = typeof null;
} else if (!isDate(newVal)) {
var err = null;
var dateVal = new Date(newVal).valueOf();
if (isNaN(dateVal)) {
// If the newVal cant be parsed, then try parseInt first
dateVal = new Date(parseInt(newVal, 10)).valueOf();
if (isNaN(dateVal)) err = true;
}
newVal = dateVal;
newType = 'date';
if (err) {
newType = typeof newVal;
}
} else {
newType = 'date';
newVal = newVal.valueOf();
}
return {
val: newVal,
type: newType
};
},
get: function (val) {
if (val == null) { return val; }
return new Date(val);
},
'default': function () {
return new Date();
}
},
array: {
set: function (newVal) {
return {
val: newVal,
type: Array.isArray(newVal) ? 'array' : typeof newVal
};
},
'default': function () {
return [];
}
},
object: {
set: function (newVal) {
var newType = typeof newVal;
// we have to have a way of supporting "missing" objects.
// Null is an object, but setting a value to undefined
// should work too, IMO. We just override it, in that case.
if (newType !== 'object' && newVal === undefined) {
newVal = null;
newType = 'object';
}
return {
val: newVal,
type: newType
};
},
'default': function () {
return {};
}
},
// the `state` data type is a bit special in that setting it should
// also bubble events
state: {
set: function (newVal) {
var isInstance = newVal instanceof Base || (newVal && newVal.isState);
if (isInstance) {
return {
val: newVal,
type: 'state'
};
} else {
return {
val: newVal,
type: typeof newVal
};
}
},
compare: function (currentVal, newVal, attributeName) {
var isSame = currentVal === newVal;
// if this has changed we want to also handle
// event propagation
if (!isSame) {
if (currentVal) {
this.stopListening(currentVal);
}
if (newVal != null) {
this.listenTo(newVal, 'all', this._getEventBubblingHandler(attributeName));
}
}
return isSame;
}
}
};
// the extend method used to extend prototypes, maintain inheritance chains for instanceof
// and allow for additions to the model definitions.
function extend(protoProps) {
/*jshint validthis:true*/
var parent = this;
var child;
// The constructor function for the new subclass is either defined by you
// (the "constructor" property in your `extend` definition), or defaulted
// by us to simply call the parent's constructor.
if (protoProps && protoProps.hasOwnProperty('constructor')) {
child = protoProps.constructor;
} else {
child = function () {
return parent.apply(this, arguments);
};
}
// Add static properties to the constructor function from parent
assign(child, parent);
// Set the prototype chain to inherit from `parent`, without calling
// `parent`'s constructor function.
var Surrogate = function () { this.constructor = child; };
Surrogate.prototype = parent.prototype;
child.prototype = new Surrogate();
// set prototype level objects
child.prototype._derived = assign({}, parent.prototype._derived);
child.prototype._deps = assign({}, parent.prototype._deps);
child.prototype._definition = assign({}, parent.prototype._definition);
child.prototype._collections = assign({}, parent.prototype._collections);
child.prototype._children = assign({}, parent.prototype._children);
child.prototype._dataTypes = assign({}, parent.prototype._dataTypes || dataTypes);
// Mix in all prototype properties to the subclass if supplied.
if (protoProps) {
var omitFromExtend = [
'dataTypes', 'props', 'session', 'derived', 'collections', 'children'
];
for(var i = 0; i < arguments.length; i++) {
var def = arguments[i];
if (def.dataTypes) {
forOwn(def.dataTypes, function (def, name) {
child.prototype._dataTypes[name] = def;
});
}
if (def.props) {
forOwn(def.props, function (def, name) {
createPropertyDefinition(child.prototype, name, def);
});
}
if (def.session) {
forOwn(def.session, function (def, name) {
createPropertyDefinition(child.prototype, name, def, true);
});
}
if (def.derived) {
forOwn(def.derived, function (def, name) {
createDerivedProperty(child.prototype, name, def);
});
}
if (def.collections) {
forOwn(def.collections, function (constructor, name) {
child.prototype._collections[name] = constructor;
});
}
if (def.children) {
forOwn(def.children, function (constructor, name) {
child.prototype._children[name] = constructor;
});
}
assign(child.prototype, omit(def, omitFromExtend));
}
}
// Set a convenience property in case the parent's prototype is needed
// later.
child.__super__ = parent.prototype;
return child;
}
Base.extend = extend;
// Our main exports
module.exports = Base;