Browse files

added support and tests for binding contexts (aka persistent/transien…

…t models)
  • Loading branch information...
1 parent a6ced5d commit b13664a37720a8c886777eb648b3ee9dfb11e4db @politician committed Feb 13, 2012
Showing with 145 additions and 55 deletions.
  1. +26 −4 README.md
  2. +0 −19 TODO
  3. +48 −23 outback.js
  4. +1 −1 spec/SpecRunner.html
  5. +2 −2 spec/bindings/text.spec.js
  6. +2 −2 spec/bindings/value.spec.js
  7. +2 −2 spec/bindings/visible.spec.js
  8. +63 −1 spec/outback.spec.js
  9. +1 −1 spec/support/app.js
View
30 README.md
@@ -6,9 +6,17 @@ Setup up your Backbone.View like so:
```CoffeeScript
+class TodoModel extends Backbone.Model
+ defaults:
+ todo: 'Learn outback.js'
+
class TodoView extends Backbone.View
model: TodoModel
+ # setup a viewModel for transient state (optional)
+ viewModel: new Backbone.Model
+ isEditing: false
+
@render: ->
Backbone.outback.bind @
@@ -21,22 +29,36 @@ Sprinkle data-bind attributes into your templates:
```HTML
-<div id="edit">
- <input type="text" class="todo-input" data-bind="value: @todo">
+<!-- adds the CSS class 'editing' to the div when the
+ viewModel's isEditing attribute is true, removes it
+ when it isn't -->
+
+<div data-bind-view="css: { editing: @isEditing }">
+
+ <!-- the input's value is two-way bound to the model's todo
+ attribute, and the focus state of the control is two-way
+ bound to the viewModel's isEditing attribute -->
+
+ <input type="text"
+ class="todo-input"
+ data-bind="value: @todo"
+ data-bind-view="hasfocus: @isEditing">
+
</div>
```
## Can I configure the bindings in code instead?
-Yes, outback bindings can be setup in either code or markup.
+Yes, outback bindings can be setup in either code or markup; feel free to mix and match.
```CoffeeScript
class TodoView extends Backbone.View
model: TodoModel
- dataBindings:
+ # modelBindings (viewModelBindings) are bound to your model (viewModel)
+ modelBindings:
'#edit .todo-input':
value: Backbone.outback.modelRef 'todo'
View
19 TODO
@@ -2,24 +2,5 @@
- add bindings (options, selectedOptions, uniqueName?)
-- binding to the view instead of the model
-
- option 1: view/model paradigm
- data-bind="visible: @isSelected, visibleOptions: { durable: false }"
-
- ---or---
-
- option 2: nested views paradigm
-
- data-bind="visible: @isSelected, visibleOptions: { dataSource: '//' }"
- data-bind="visible: @isSelected, visibleOptions: { dataSource: '//model' }" (default)
-
- ---or---
-
- option 3: use a different attribute
-
- data-bind-view="visible: @isSelected"
-
-
- dependency cycle detection / breakage
View
71 outback.js
@@ -97,6 +97,7 @@
modelAttrName: modelAttrName,
valueAccessor: makeValueAccessorBuilder(model),
modelEvents: {
+ eventName: false, // TODO: Defaults to "change:modelAttrName"
subscribe: subscribe,
unsubscribe: unsubscribe
}
@@ -135,33 +136,33 @@
};
}
- function parseDataBindAttrBindingDecls (view, model) {
+ function parseDataBindAttrBindingDecls (databindAttr, view, model) {
var bindingDecls, selector;
bindingDecls = [];
- selector = "*[data-bind]";
+ selector = "*["+databindAttr+"]";
view.$(selector).each(function () {
var element, bindingExpr, directives;
element = view.$(this);
- bindingExpr = element.attr('data-bind');
+ bindingExpr = element.attr(databindAttr);
directives = rj.parse(bindingExpr, makeBindingDeclReviver(model));
bindingDecls.push({
element: element,
- directives: directives
+ directives: directives,
+ dataSource: model
});
});
return bindingDecls;
}
- function parseUnobtrusiveBindingDecls (view, model) {
- var bindingDecls, root, viewAttr;
+ function parseUnobtrusiveBindingDecls (viewAttr, view, model) {
+ var bindingDecls, root;
bindingDecls = [];
- viewAttr = 'dataBindings'; // TODO: hard-coded view attribute should be configurable
_.each(view[viewAttr], function(value, selector) {
if(!hop(view[viewAttr], selector)) return;
@@ -174,7 +175,8 @@
bindingDecls.push({
element: element,
- directives: directives
+ directives: directives,
+ dataSource: model
});
}
});
@@ -254,7 +256,7 @@
}
function applyBinding (view, binding) {
- var binders, binderArgs, modelEventName, updateFn;
+ var binders, binderArgs, eventName, updateFn;
binders = {
modelSubs: [],
@@ -264,25 +266,27 @@
modelUnsubs: []
};
- modelEventName = typeof binding.modelEventName === 'string' ? binding.modelEventName : false;
+ eventName = binding.modelEvents.eventName;
binderArgs = [binding.element, binding.valueAccessor, binding.allBindingsAccessor, view];
- function nop() {}
+ if (!hop(binding.handler, 'update')) {
+ return undefined;
+ }
- updateFn = hop(binding.handler, 'update') ? binding.handler.update : nop;
+ updateFn = binding.handler.update;
binders.updates.push(function() {
updateFn.apply(view, binderArgs);
});
binders.modelSubs.push(function() {
- binding.modelEvents.subscribe(modelEventName, function(m, val) {
+ binding.modelEvents.subscribe(eventName, function(m, val) {
updateFn.apply(view, binderArgs);
});
});
binders.modelUnsubs.push(function () {
- binding.modelEvents.unsubscribe(modelEventName);
+ binding.modelEvents.unsubscribe(eventName);
})
if (hop(binding.handler, 'init')) {
@@ -304,8 +308,8 @@
this.modelAttrName = modelAttrName;
};
- var OutbackBinder = function (view, model, bindingHandlers) {
- var allBinders;
+ var OutbackBinder = function (view, bindingHandlers) {
+ var bindingContexts, allBinders;
allBinders = {
modelSubs: [],
@@ -314,17 +318,38 @@
removes: [],
modelUnsubs: []
};
+
+ bindingContexts = {
+ model: {
+ dataSource: view.model,
+ databindAttr: 'data-bind',
+ unobtrusiveAttr: 'modelBindings'
+ },
+ viewModel: {
+ dataSource: view.viewModel,
+ databindAttr: 'data-bind-view',
+ unobtrusiveAttr: 'viewModelBindings'
+ }
+ };
+
+ function arrayConcatBindingContext(bindingDecls, context) {
+ if(context.dataSource) {
+ arrayConcat(bindingDecls, parseDataBindAttrBindingDecls(context.databindAttr, view, context.dataSource));
+ arrayConcat(bindingDecls, parseUnobtrusiveBindingDecls(context.unobtrusiveAttr, view, context.dataSource));
+ }
+ }
this.bind = function () {
var bindingDecls, bindings, summary;
summary = {
+ executableBindingsSkipped: 0,
executableBindingsInstalled: 0
};
bindingDecls = [];
- arrayConcat(bindingDecls, parseDataBindAttrBindingDecls(view, model));
- arrayConcat(bindingDecls, parseUnobtrusiveBindingDecls(view, model));
+ arrayConcatBindingContext(bindingDecls, bindingContexts.model);
+ arrayConcatBindingContext(bindingDecls, bindingContexts.viewModel);
bindings = [];
_.each(bindingDecls, function (bindingDecl) {
@@ -339,6 +364,10 @@
_.each(bindings, function (binding) {
binders = applyBinding(view, binding);
+ if (_.isUndefined(binders)) {
+ summary.executableBindingsSkipped++;
+ return;
+ }
arrayConcat(allBinders.modelSubs, binders.modelSubs);
arrayConcat(allBinders.inits, binders.inits);
@@ -355,10 +384,6 @@
eachfn(allBinders.updates);
eachfn(allBinders.modelSubs);
- // Fire a single model change event to sync the DOM.
-
- //model.trigger('change');
-
if (typeof view.bindingSummary === 'function') {
view.bindingSummary(summary);
}
@@ -376,7 +401,7 @@
Backbone.outback = {
version: "0.1.0",
bind: function(view){
- view.__outback_binder = new OutbackBinder(view, view.model, Backbone.outback.bindingHandlers); // TODO: Support for Collections
+ view.__outback_binder = new OutbackBinder(view, Backbone.outback.bindingHandlers);
view.__outback_binder.bind();
},
View
2 spec/SpecRunner.html
@@ -60,7 +60,7 @@
<script type="text/javascript" src="bindings/value.spec.js"></script>
<script type="text/javascript" src="bindings/hasfocus.spec.js"></script>
<script type="text/javascript" src="bindings/checked.spec.js"></script>
-
+ <!---->
</head>
<body>
<div id="jasmine_content"></div>
View
4 spec/bindings/text.spec.js
@@ -33,7 +33,7 @@ describe('the text binding', function() {
this.view = new FixtureView({model: this.model});
_.extend(this.view, {
innerHtml: "<p></p>",
- dataBindings: {
+ modelBindings: {
'#anchor p': {
text: Backbone.outback.modelRef('content')
}
@@ -59,7 +59,7 @@ describe('the text binding', function() {
});
it('should allow you to shoot yourself in the foot', function () {
- this.view.dataBindings['#anchor p'].textOptions = { escape: false };
+ this.view.modelBindings['#anchor p'].textOptions = { escape: false };
expect(this.view).toHaveAnElementWithContent('#anchor p', xssPayload);
});
View
4 spec/bindings/value.spec.js
@@ -43,7 +43,7 @@ describe('the value binding', function() {
this.view = new FixtureView({model: this.model});
_.extend(this.view, {
innerHtml: "<input type='text'>",
- dataBindings: {
+ modelBindings: {
'#anchor input': {
value: Backbone.outback.modelRef('content')
}
@@ -69,7 +69,7 @@ describe('the value binding', function() {
});
it('should allow you to shoot yourself in the foot', function () {
- this.view.dataBindings['#anchor input'].valueOptions = { escape: false };
+ this.view.modelBindings['#anchor input'].valueOptions = { escape: false };
expect(this.view).toHaveAnElementWithContent('#anchor input', xssPayload);
});
View
4 spec/bindings/visible.spec.js
@@ -5,7 +5,7 @@ describe('the visible binding', function () {
this.model = new AModel({isVisible: true});
this.view = new FixtureView({model: this.model});
_.extend(this.view, {
- dataBindings: {
+ modelBindings: {
'#anchor': {
visible: Backbone.outback.modelRef('isVisible')
}
@@ -31,7 +31,7 @@ describe('the visible binding', function () {
this.model = new AModel({isVisible: true});
this.view = new FixtureView({model: this.model});
_.extend(this.view, {
- dataBindings: {
+ modelBindings: {
'#anchor': {
visible: Backbone.outback.modelRef('isVisible'),
visibleOptions: { not: true }
View
64 spec/outback.spec.js
@@ -69,6 +69,7 @@ describe('outback.js declarative bindings for backbone.js', function() {
args = this.view.bindingSummary.mostRecentCall.args;
expect(args).toBeDefined();
expect(args.length).toBe(1);
+ expect(args[0].executableBindingsSkipped).toBe(0);
expect(args[0].executableBindingsInstalled).toBe(1);
});
@@ -89,6 +90,7 @@ describe('outback.js declarative bindings for backbone.js', function() {
args = this.view.bindingSummary.mostRecentCall.args;
expect(args).toBeDefined();
expect(args.length).toBe(1);
+ expect(args[0].executableBindingsSkipped).toBe(0);
expect(args[0].executableBindingsInstalled).toBe(0);
this.view.remove();
@@ -102,7 +104,7 @@ describe('outback.js declarative bindings for backbone.js', function() {
this.model = new AModel({ isVisible: false });
this.view = new TypicalView({model: this.model});
_.extend(this.view, {
- dataBindings: {
+ modelBindings: {
'#anchor': {
nop: Backbone.outback.modelRef('isVisible')
}
@@ -279,4 +281,64 @@ describe('outback.js declarative bindings for backbone.js', function() {
});
});
+ describe("supports multiple bindings contexts", function() {
+
+ beforeEach(function(){
+ this.model = new Backbone.Model({x: false});
+ this.viewModel = new Backbone.Model({y: 'value from viewmodel'});
+
+ this.view = new FixtureView({model: this.model});
+
+ _.extend(this.view, {
+ viewModel: this.viewModel,
+ innerHtml: "<input type='text' data-bind='css: { xclass: @x }' data-bind-view='value: @y'>"
+ });
+ });
+
+ it('should initialize into a stable state', function() {
+ var args, bindings;
+
+ this.view.bindingSummary = function() {};
+
+ spyOn(this.view, 'bindingSummary');
+
+ this.view.render();
+ this.el = this.view.$('#anchor input');
+
+ args = this.view.bindingSummary.mostRecentCall.args;
+ expect(args[0].executableBindingsSkipped).toBe(0);
+ expect(args[0].executableBindingsInstalled).toBe(2);
+
+ expect(this.model.get('x')).toBeFalsy();
+ expect(this.el.hasClass('xclass')).toBeFalsy();
+
+ expect(this.viewModel.get('y')).toBe('value from viewmodel');
+ expect(this.el.val()).toBe('value from viewmodel');
+
+ this.view.remove();
+ });
+
+ it("should correctly handle the optional viewModel binding context", function() {
+ spyOn(Backbone.outback.bindingHandlers.value, 'update').andCallThrough();
+
+ this.view.render();
+ this.el = this.view.$('#anchor input');
+
+ expect(Backbone.outback.bindingHandlers.value.update.callCount).toBe(1);
+
+ this.viewModel.set({y: 'hello, world'});
+
+ expect(Backbone.outback.bindingHandlers.value.update.callCount).toBe(2);
+
+ expect(this.el.val()).toBe('hello, world');
+
+ this.el.val('binding contexts rock');
+ this.el.trigger('change');
+
+ expect(this.viewModel.get("y")).toBe('binding contexts rock');
+
+ this.view.remove();
+ });
+
+ });
});
View
2 spec/support/app.js
@@ -18,7 +18,7 @@ TypicalView = Backbone.View.extend({
});
UnobtrusiveView = TypicalView.extend({
- dataBindings: {
+ modelBindings: {
'#anchor': {
visible: Backbone.outback.modelRef('isVisible')
}

0 comments on commit b13664a

Please sign in to comment.