Skip to content

Commit

Permalink
Support top-level observable view models using notification from the …
Browse files Browse the repository at this point in the history
…binding context
  • Loading branch information
mbest committed Jun 14, 2013
1 parent 33b9290 commit 2273c72
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 38 deletions.
128 changes: 126 additions & 2 deletions spec/bindingDependencyBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,9 @@ describe('Binding dependencies', function() {
expect(lastBoundValueUpdate).toEqual("third value");

// update view model with brand-new property
/* TODO: fix observable view models
vm({ myProp: function() {return "fourth value"; }});
expect(lastBoundValueInit).toEqual("fourth value");
expect(lastBoundValueUpdate).toEqual("fourth value");*/
expect(lastBoundValueUpdate).toEqual("fourth value");
});

it('Should not update sibling bindings if a binding is updated', function() {
Expand Down Expand Up @@ -222,6 +221,131 @@ describe('Binding dependencies', function() {
expect(latestValue).toEqual(2);
});

describe('Observable view models', function() {
it('Should update bindings (including callbacks)', function() {
var vm = ko.observable(), clickedVM;
function checkVM(data) {
clickedVM = data;
}
testNode.innerHTML = "<div><input data-bind='value:someProp' /><input type='button' data-bind='click: checkVM' /></div>";
vm({ someProp: 'My prop value', checkVM: checkVM });
ko.applyBindings(vm, testNode);
expect(vm.getSubscriptionsCount()).toEqual(1);

expect(testNode.childNodes[0].childNodes[0].value).toEqual("My prop value");

// a change to the input value should be written to the model
testNode.childNodes[0].childNodes[0].value = "some user-entered value";
ko.utils.triggerEvent(testNode.childNodes[0].childNodes[0], "change");
expect(vm().someProp).toEqual("some user-entered value");
// a click should use correct view model
ko.utils.triggerEvent(testNode.childNodes[0].childNodes[1], "click");
expect(clickedVM).toEqual(vm());

// set the view-model to a new object
vm({ someProp: ko.observable('My new prop value'), checkVM: checkVM });
expect(testNode.childNodes[0].childNodes[0].value).toEqual("My new prop value");

// a change to the input value should be written to the new model
testNode.childNodes[0].childNodes[0].value = "some new user-entered value";
ko.utils.triggerEvent(testNode.childNodes[0].childNodes[0], "change");
expect(vm().someProp()).toEqual("some new user-entered value");
// a click should use correct view model
ko.utils.triggerEvent(testNode.childNodes[0].childNodes[1], "click");
expect(clickedVM).toEqual(vm());

// clear the element and the view-model (shouldn't be any errors) and the subscription should be cleared
ko.removeNode(testNode);
vm(null);
expect(vm.getSubscriptionsCount()).toEqual(0);
});

it('Should update all child contexts (including values copied from the parent)', function() {
ko.bindingHandlers.setChildContext = {
init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
ko.applyBindingsToDescendants(
bindingContext.createChildContext(function() { return ko.utils.unwrapObservable(valueAccessor()) }),
element);
return { controlsDescendantBindings : true };
}
};

testNode.innerHTML = "<div data-bind='setChildContext:obj1'><span data-bind='text:prop1'></span><span data-bind='text:$root.prop2'></span></div>";
var vm = ko.observable({obj1: {prop1: "First "}, prop2: "view model"});
ko.applyBindings(vm, testNode);
expect(testNode).toContainText("First view model");

// change view model to new object
vm({obj1: {prop1: "Second view "}, prop2: "model"});
expect(testNode).toContainText("Second view model");

// change it again
vm({obj1: {prop1: "Third view model"}, prop2: ""});
expect(testNode).toContainText("Third view model");

// clear the element and the view-model (shouldn't be any errors) and the subscription should be cleared
ko.removeNode(testNode);
vm(null);
expect(vm.getSubscriptionsCount()).toEqual(0);
});

it('Should update all extended contexts (including values copied from the parent)', function() {
ko.bindingHandlers.withProperties = {
init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
var innerBindingContext = bindingContext.extend(valueAccessor);
ko.applyBindingsToDescendants(innerBindingContext, element);
return { controlsDescendantBindings : true };
}
};

testNode.innerHTML = "<div data-bind='withProperties: obj1'><span data-bind='text:prop1'></span><span data-bind='text:prop2'></span></div>";
var vm = ko.observable({obj1: {prop1: "First "}, prop2: "view model"});
ko.applyBindings(vm, testNode);
expect(testNode).toContainText("First view model");

// ch ange view model to new object
vm({obj1: {prop1: "Second view "}, prop2: "model"});
expect(testNode).toContainText("Second view model");

// change it again
vm({obj1: {prop1: "Third view model"}, prop2: ""});
expect(testNode).toContainText("Third view model");

// clear the element and the view-model (shouldn't be any errors) and the subscription should be cleared
ko.removeNode(testNode);
vm(null);
expect(vm.getSubscriptionsCount()).toEqual(0);
});

it('Should update an extended child context', function() {
ko.bindingHandlers.withProperties = {
init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
var childBindingContext = bindingContext.createChildContext(null).extend(valueAccessor);
ko.applyBindingsToDescendants(childBindingContext, element);
return { controlsDescendantBindings: true };
}
};

testNode.innerHTML = "<div data-bind='withProperties: obj1'><span data-bind='text:prop1'></span><span data-bind='text:$parent.prop2'></span></div>";
var vm = ko.observable({obj1: {prop1: "First "}, prop2: "view model"});
ko.applyBindings(vm, testNode);
expect(testNode).toContainText("First view model");

// ch ange view model to new object
vm({obj1: {prop1: "Second view "}, prop2: "model"});
expect(testNode).toContainText("Second view model");

// change it again
vm({obj1: {prop1: "Third view model"}, prop2: ""});
expect(testNode).toContainText("Third view model");

// clear the element and the view-model (shouldn't be any errors) and the subscription should be cleared
ko.removeNode(testNode);
vm(null);
expect(vm.getSubscriptionsCount()).toEqual(0);
});
});

describe('Order', function() {
var bindingOrder;
beforeEach(function() {
Expand Down
128 changes: 98 additions & 30 deletions src/binding/bindingAttributeSyntax.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,83 @@
(function () {
(function () {
ko.bindingHandlers = {};

// Use an overridable method for retrieving binding handlers so that a plugins may support dynamically created handlers
ko['getBindingHandler'] = function(bindingKey) {
return ko.bindingHandlers[bindingKey];
};

ko.bindingContext = function(dataItem, parentBindingContext, dataItemAlias) {
if (parentBindingContext) {
ko.utils.extend(this, parentBindingContext); // Inherit $root and any custom properties
this['$parentContext'] = parentBindingContext;
this['$parent'] = parentBindingContext['$data'];
this['$parents'] = (parentBindingContext['$parents'] || []).slice(0);
this['$parents'].unshift(this['$parent']);
ko.bindingContext = function(dataItemOrAccessor, parentContext, dataItemAlias, extendCallback) {
function updateContext() {
var dataItem = isFunc ? dataItemOrAccessor() : dataItemOrAccessor;
if (parentContext) {
// Register a dependency on the parent context
if (parentContext._subscribable)
parentContext._subscribable();
// Inherit $root and any custom properties
ko.utils.extend(self, parentContext);
// Update our properties
if (subscribable) {
self['$dataFn'] = self._subscribable = subscribable;
}
} else {
self['$parents'] = [];
self['$root'] = dataItem;
// Export 'ko' in the binding context so it will be available in bindings and templates
// even if 'ko' isn't exported as a global, such as when using an AMD loader.
// See https://github.com/SteveSanderson/knockout/issues/490
self['ko'] = ko;
}
self['$data'] = dataItem;
if (dataItemAlias)
self[dataItemAlias] = dataItem;
if (extendCallback)
extendCallback(self, parentContext, dataItem);
return self['$data'];
}
function disposeWhen() {
return !ko.utils.anyDomNodeIsAttachedToDocument(nodes);
}

var self = this,
isFunc = typeof(dataItemOrAccessor) == "function",
nodes = [],
subscribable = ko.dependentObservable(updateContext, null, { disposeWhen: disposeWhen });

if (subscribable.isActive()) {
self['$dataFn'] = self._subscribable = subscribable;

// Add properties to *subscribable* instead of *self* because any properties added to *self* may be overwritten on updates
subscribable._addNode = function(node) {
nodes.push(node);
ko.utils.domNodeDisposal.addDisposeCallback(node, function(node) {
ko.utils.arrayRemoveItem(nodes, node);
if (!nodes.length) {
subscribable.dispose();
self._subscribable = subscribable = undefined;
}
});
// Make sure that our parent context is watching at least one node
if (parentContext && parentContext._subscribable && !parentContext._subscribable._nodes.length) {
parentContext._subscribable._addNode(node);
}
};
subscribable._nodes = nodes;
} else {
this['$parents'] = [];
this['$root'] = dataItem;
// Export 'ko' in the binding context so it will be available in bindings and templates
// even if 'ko' isn't exported as a global, such as when using an AMD loader.
// See https://github.com/SteveSanderson/knockout/issues/490
this['ko'] = ko;
self['$dataFn'] = function() { return self['$data']; }
}
this['$data'] = dataItem;
if (dataItemAlias)
this[dataItemAlias] = dataItem;
}
ko.bindingContext.prototype['createChildContext'] = function (dataItem, dataItemAlias) {
return new ko.bindingContext(dataItem, this, dataItemAlias);
ko.bindingContext.prototype['createChildContext'] = function (dataItemOrAccessor, dataItemAlias) {
return new ko.bindingContext(dataItemOrAccessor, this, dataItemAlias, function(self, parentContext) {
self['$parentContext'] = parentContext;
self['$parent'] = parentContext['$data'];
self['$parents'] = (parentContext['$parents'] || []).slice(0);
self['$parents'].unshift(self['$parent']);
});
};
ko.bindingContext.prototype['extend'] = function(properties) {
var clone = ko.utils.extend(new ko.bindingContext(), this);
return ko.utils.extend(clone, properties);
return new ko.bindingContext(this['$dataFn'], this, null, function(self) {
ko.utils.extend(self, typeof(properties) == "function" ? properties() : properties);
});
};

// Returns the valueAccesor function for a binding value
Expand Down Expand Up @@ -170,18 +217,36 @@
if (!bindings) {
var provider = ko.bindingProvider['instance'],
getBindings = provider['getBindingAccessors'] || getBindingsAndMakeAccessors;
bindings = ko.dependencyDetection.ignore(getBindings, provider, [node, bindingContext]);

var bindingsUpdater = ko.dependentObservable(
function() {
return (bindings = getBindings.call(provider, node, bindingContext));
},
null, { disposeWhenNodeIsRemoved: node }
);

if (!bindings || !bindingsUpdater.isActive())
bindingsUpdater = null;
}

var bindingHandlerThatControlsDescendantBindings;
if (bindings) {
var getValueAccessor = bindingsUpdater
? function(bindingKey) {
return function() {
return evaluateValueAccessor(bindingsUpdater()[bindingKey]);
};
} : function(bindingKey) {
return bindings[bindingKey];
};

// Use of allBindings as a function is maintained for backwards compatibility, but its use is deprecated
function allBindings() {
return ko.utils.objectMap(bindings, evaluateValueAccessor);
return ko.utils.objectMap(bindingsUpdater ? bindingsUpdater() : bindings, evaluateValueAccessor);
}
// The following is the 3.x allBindings API
allBindings['get'] = function(key) {
return bindings[key] && evaluateValueAccessor(bindings[key]);
return bindings[key] && evaluateValueAccessor(getValueAccessor(key));
};
allBindings['has'] = function(key) {
return key in bindings;
Expand All @@ -202,7 +267,7 @@
ko.dependencyDetection.ignore(function() {
var handlerInitFn = bindingHandler["init"];
if (typeof handlerInitFn == "function") {
var initResult = handlerInitFn(node, bindings[bindingKey], allBindings, bindingContext['$data'], bindingContext);
var initResult = handlerInitFn(node, getValueAccessor(bindingKey), allBindings, bindingContext['$data'], bindingContext);

// If this binding handler claims to control descendant bindings, make a note of this
if (initResult && initResult['controlsDescendantBindings']) {
Expand All @@ -218,7 +283,7 @@
function() {
var handlerUpdateFn = bindingHandler["update"];
if (typeof handlerUpdateFn == "function") {
handlerUpdateFn(node, bindings[bindingKey], allBindings, bindingContext['$data'], bindingContext);
handlerUpdateFn(node, getValueAccessor(bindingKey), allBindings, bindingContext['$data'], bindingContext);
}
},
null,
Expand All @@ -234,16 +299,19 @@

var storedBindingContextDomDataKey = "__ko_bindingContext__";
ko.storedBindingContextForNode = function (node, bindingContext) {
if (arguments.length == 2)
if (arguments.length == 2) {
ko.utils.domData.set(node, storedBindingContextDomDataKey, bindingContext);
else
if (bindingContext._subscribable)
bindingContext._subscribable._addNode(node);
} else {
return ko.utils.domData.get(node, storedBindingContextDomDataKey);
}
}

function getBindingContext(viewModelOrBindingContext, options) {
function getBindingContext(viewModelOrBindingContext) {
return viewModelOrBindingContext && (viewModelOrBindingContext instanceof ko.bindingContext)
? viewModelOrBindingContext
: new ko.bindingContext(ko.utils.peekObservable(viewModelOrBindingContext));
: new ko.bindingContext(viewModelOrBindingContext);
}

ko.applyBindingAccessorsToNode = function (node, bindings, viewModelOrBindingContext) {
Expand Down
2 changes: 1 addition & 1 deletion src/binding/bindingProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
// For each scope variable, add an extra level of "with" nesting
// Example result: with(sc1) { with(sc0) { return (expression) } }
var rewrittenBindings = ko.expressionRewriting.preProcessBindings(bindingsString, options),
functionBody = "with($context){with($data||{}){return{" + rewrittenBindings + "}}}";
functionBody = "with($context){with($dataFn()||{}){return{" + rewrittenBindings + "}}}";
return new Function("$context", "$element", functionBody);
}
})();
Expand Down
7 changes: 4 additions & 3 deletions src/binding/defaultBindings/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@
// e.g. click:handler instead of the usual full-length event:{click:handler}
function makeEventHandlerShortcut(eventName) {
ko.bindingHandlers[eventName] = {
'init': function(element, valueAccessor, allBindings, viewModel) {
'init': function(element, valueAccessor, allBindings, viewModel, bindingContext) {
var newValueAccessor = function () {
var result = {};
result[eventName] = valueAccessor();
return result;
};
return ko.bindingHandlers['event']['init'].call(this, element, newValueAccessor, allBindings, viewModel);
return ko.bindingHandlers['event']['init'].call(this, element, newValueAccessor, allBindings, viewModel, bindingContext);
}
}
}

ko.bindingHandlers['event'] = {
'init' : function (element, valueAccessor, allBindings, viewModel) {
'init' : function (element, valueAccessor, allBindings, viewModel, bindingContext) {
var eventsToHandle = valueAccessor() || {};
ko.utils.objectForEach(eventsToHandle, function(eventName) {
if (typeof eventName == "string") {
Expand All @@ -27,6 +27,7 @@ ko.bindingHandlers['event'] = {
try {
// Take all the event args, and prefix with the viewmodel
var argsForHandler = ko.utils.makeArray(arguments);
viewModel = bindingContext['$data'];
argsForHandler.unshift(viewModel);
handlerReturnValue = handlerFunction.apply(viewModel, argsForHandler);
} finally {
Expand Down
4 changes: 2 additions & 2 deletions src/binding/defaultBindings/submit.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
ko.bindingHandlers['submit'] = {
'init': function (element, valueAccessor, allBindings, viewModel) {
'init': function (element, valueAccessor, allBindings, viewModel, bindingContext) {
if (typeof valueAccessor() != "function")
throw new Error("The value for a submit binding must be a function");
ko.utils.registerEventHandler(element, "submit", function (event) {
var handlerReturnValue;
var value = valueAccessor();
try { handlerReturnValue = value.call(viewModel, element); }
try { handlerReturnValue = value.call(bindingContext['$data'], element); }
finally {
if (handlerReturnValue !== true) { // Normally we want to prevent default action. Developer can override this be explicitly returning true.
if (event.preventDefault)
Expand Down

0 comments on commit 2273c72

Please sign in to comment.