Skip to content

Commit

Permalink
first stab at a light-weight with binding (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
mbest committed Jan 6, 2012
1 parent 8aa8d07 commit 109b8e7
Show file tree
Hide file tree
Showing 3 changed files with 180 additions and 46 deletions.
110 changes: 110 additions & 0 deletions spec/defaultBindingsBehaviors.js
Expand Up @@ -1117,6 +1117,116 @@ describe('Binding: Ifnot', {
}
});

describe('Binding: With Light', {
before_each: prepareTestNode,

'Should bind descendant nodes in the context of the supplied value': function() {
testNode.innerHTML = "<div data-bind='withlight: someItem'><span data-bind='text: existentChildProp'></span></div>";
value_of(testNode.childNodes.length).should_be(1);
ko.applyBindings({ someItem: { existentChildProp: 'Child prop value' } }, testNode);
value_of(testNode.childNodes[0].childNodes.length).should_be(1);
value_of(testNode.childNodes[0].childNodes[0]).should_contain_text("Child prop value");
},

'Should not bind the same elements more than once even if the supplied value notifies a change': function() {
var countedClicks = 0;
var someItem = ko.observable({
childProp: ko.observable('Hello'),
handleClick: function() { countedClicks++ }
});

testNode.innerHTML = "<div data-bind='withlight: someItem'><span data-bind='text: childProp, click: handleClick'></span></div>";
ko.applyBindings({ someItem: someItem }, testNode);

// Initial state is one subscriber, one click handler
value_of(testNode.childNodes[0].childNodes[0]).should_contain_text("Hello");
value_of(someItem().childProp.getSubscriptionsCount()).should_be(1);
ko.utils.triggerEvent(testNode.childNodes[0].childNodes[0], "click");
value_of(countedClicks).should_be(1);

// Force "update" binding handler to fire, then check we still have one subscriber...
someItem.valueHasMutated();
value_of(someItem().childProp.getSubscriptionsCount()).should_be(1);

// ... and one click handler
countedClicks = 0;
ko.utils.triggerEvent(testNode.childNodes[0].childNodes[0], "click");
value_of(countedClicks).should_be(1);
},

'Should be able to access parent binding context via $parent': function() {
testNode.innerHTML = "<div data-bind='withlight: someItem'><span data-bind='text: $parent.parentProp'></span></div>";
ko.applyBindings({ someItem: { }, parentProp: 'Parent prop value' }, testNode);
value_of(testNode.childNodes[0].childNodes[0]).should_contain_text("Parent prop value");
},

'Should be able to access all parent binding contexts via $parents, and root context via $root': function() {
testNode.innerHTML = "<div data-bind='withlight: topItem'>" +
"<div data-bind='withlight: middleItem'>" +
"<div data-bind='withlight: bottomItem'>" +
"<span data-bind='text: name'></span>" +
"<span data-bind='text: $parent.name'></span>" +
"<span data-bind='text: $parents[1].name'></span>" +
"<span data-bind='text: $parents[2].name'></span>" +
"<span data-bind='text: $root.name'></span>" +
"</div>" +
"</div>" +
"</div>";
ko.applyBindings({
name: 'outer',
topItem: {
name: 'top',
middleItem: {
name: 'middle',
bottomItem: {
name: "bottom"
}
}
}
}, testNode);
var finalContainer = testNode.childNodes[0].childNodes[0].childNodes[0];
value_of(finalContainer.childNodes[0]).should_contain_text("bottom");
value_of(finalContainer.childNodes[1]).should_contain_text("middle");
value_of(finalContainer.childNodes[2]).should_contain_text("top");
value_of(finalContainer.childNodes[3]).should_contain_text("outer");
value_of(finalContainer.childNodes[4]).should_contain_text("outer");

// Also check that, when we later retrieve the binding contexts, we get consistent results
value_of(ko.contextFor(testNode).$data.name).should_be("outer");
value_of(ko.contextFor(testNode.childNodes[0]).$data.name).should_be("outer");
value_of(ko.contextFor(testNode.childNodes[0].childNodes[0]).$data.name).should_be("top");
value_of(ko.contextFor(testNode.childNodes[0].childNodes[0].childNodes[0]).$data.name).should_be("middle");
value_of(ko.contextFor(testNode.childNodes[0].childNodes[0].childNodes[0].childNodes[0]).$data.name).should_be("bottom");
var firstSpan = testNode.childNodes[0].childNodes[0].childNodes[0].childNodes[0];
value_of(firstSpan.tagName).should_be("SPAN");
value_of(ko.contextFor(firstSpan).$data.name).should_be("bottom");
value_of(ko.contextFor(firstSpan).$root.name).should_be("outer");
value_of(ko.contextFor(firstSpan).$parents[1].name).should_be("top");
},

'Should be able to nest a containerless template within withlight': function() {
testNode.innerHTML = "<div data-bind='withlight: someitem'>text" +
"<!-- ko if: childprop --><span data-bind='text: childprop'></span><!-- /ko --></div>";

var childprop = ko.observable(undefined);
var someitem = ko.observable({childprop: childprop});
var viewModel = {someitem: someitem};
ko.applyBindings(viewModel, testNode);

// First it's not there
var container = testNode.childNodes[0];
value_of(container).should_contain_html("text<!-- ko if: childprop --><!-- /ko -->");

// Then it's there
childprop('me');
value_of(container).should_contain_html("text<!-- ko if: childprop --><span data-bind=\"text: childprop\">me</span><!-- /ko -->");

// Then it changes
someitem({childprop: 'notme'});
value_of(container).should_contain_html("text<!-- ko if: childprop --><span data-bind=\"text: childprop\">notme</span><!-- /ko -->");
}
});

describe('Binding: With', {
before_each: prepareTestNode,

Expand Down
30 changes: 30 additions & 0 deletions src/binding/defaultBindings.js
Expand Up @@ -543,6 +543,10 @@ ko.bindingHandlers['repeat'] = {
o.repeatUpdate(); // for dependency tracking
return ko.utils.unwrapObservable(o.repeatArray[index]);
}; })(i);
/*newContext[repeatData] = (function(index) { return ko.dependentObservable(function() {
o.repeatUpdate(); // for dependency tracking
return ko.utils.unwrapObservable(o.repeatArray[index]);
}, null, {'deferEvaluation': true, 'disposeWhenNodeIsRemoved': allRepeatNodes[index]}); })(i);*/
}
var shouldBindDescendants = true;
if (o.repeatBind) {
Expand All @@ -561,6 +565,32 @@ ko.bindingHandlers['repeat'] = {
}
};

var withInitializedDomDataKey = "__ko_withlightInit__";
ko.bindingHandlers['withlight'] = {
'init': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
return { 'controlsDescendantBindings': true };
},
'update': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
var bindingValue = ko.utils.unwrapObservable(valueAccessor());
if (typeof bindingValue != 'object' || bindingValue === null)
throw new Error('withlight must be used with an object');
if (!element[withInitializedDomDataKey]) {
element[withInitializedDomDataKey] = element.innerHTML;
} else {
while (element.firstChild)
ko.removeNode(element.firstChild);
element.innerHTML = element[withInitializedDomDataKey];
}
var innerContext = bindingContext['createChildContext'](bindingValue),
currentChild, nextInQueue = element.childNodes[0];
while (currentChild = nextInQueue) {
nextInQueue = ko.virtualElements.nextSibling(currentChild);
if ((currentChild.nodeType === 1) || (currentChild.nodeType === 8))
ko.applyBindings(innerContext, currentChild);
}
}
};

// "with: someExpression" is equivalent to "template: { if: someExpression, data: someExpression }"
ko.bindingHandlers['with'] = {
makeTemplateValueAccessor: function(valueAccessor) {
Expand Down
86 changes: 40 additions & 46 deletions src/subscribables/dependentObservable.js
@@ -1,49 +1,51 @@
function prepareOptions(evaluatorFunctionOrOptions, evaluatorFunctionTarget, options) {
if (evaluatorFunctionOrOptions && typeof evaluatorFunctionOrOptions == "object") {
ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunctionTarget, options) {
var _latestValue,
_hasBeenEvaluated = false,
readFunction = evaluatorFunctionOrOptions;

if (readFunction && typeof readFunction == "object") {
// Single-parameter syntax - everything is on this "options" param
options = evaluatorFunctionOrOptions;
options = readFunction;
readFunction = options["read"];
} else {
// Multi-parameter syntax - construct the options according to the params passed
options = options || {};
options["read"] = evaluatorFunctionOrOptions || options["read"];
if (!readFunction)
readFunction = options["read"];
}
// By here, "options" is always non-null

if (typeof options["read"] != "function")
if (typeof readFunction != "function")
throw "Pass a function that returns the value of the dependentObservable";

options["owner"] = evaluatorFunctionTarget || options["owner"];

return options;
}

ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunctionTarget, options) {
var _latestValue,
_hasBeenEvaluated = false,
options = prepareOptions(evaluatorFunctionOrOptions, evaluatorFunctionTarget, options);
var writeFunction = options["write"];
if (!evaluatorFunctionTarget)
evaluatorFunctionTarget = options["owner"];

// Build "disposeWhenNodeIsRemoved" and "disposeWhenNodeIsRemovedCallback" option values
// (Note: "disposeWhenNodeIsRemoved" option both proactively disposes as soon as the node is removed using ko.removeNode(),
// plus adds a "disposeWhen" callback that, on each evaluation, disposes if the node was removed by some other means.)
var disposeWhenNodeIsRemoved = (typeof options["disposeWhenNodeIsRemoved"] == "object") ? options["disposeWhenNodeIsRemoved"] : null;
var disposeWhenNodeIsRemovedCallback = null;
if (disposeWhenNodeIsRemoved) {
disposeWhenNodeIsRemovedCallback = function() { dependentObservable.dispose() };
ko.utils.domNodeDisposal.addDisposeCallback(disposeWhenNodeIsRemoved, disposeWhenNodeIsRemovedCallback);
var existingDisposeWhenFunction = options["disposeWhen"];
options["disposeWhen"] = function () {
return (!ko.utils.domNodeIsAttachedToDocument(disposeWhenNodeIsRemoved))
|| ((typeof existingDisposeWhenFunction == "function") && existingDisposeWhenFunction());
}
}

var _subscriptionsToDependencies = [];
function disposeAllSubscriptionsToDependencies() {
ko.utils.arrayForEach(_subscriptionsToDependencies, function (subscription) {
subscription.dispose();
});
_subscriptionsToDependencies = [];
}
var dispose = disposeAllSubscriptionsToDependencies;

// Build "disposeWhenNodeIsRemoved" and "disposeWhenNodeIsRemovedCallback" option values
// (Note: "disposeWhenNodeIsRemoved" option both proactively disposes as soon as the node is removed using ko.removeNode(),
// plus adds a "disposeWhen" callback that, on each evaluation, disposes if the node was removed by some other means.)
var disposeWhenNodeIsRemoved = (typeof options["disposeWhenNodeIsRemoved"] == "object") ? options["disposeWhenNodeIsRemoved"] : null;
var disposeWhen = options["disposeWhen"] || function() { return false; };
if (disposeWhenNodeIsRemoved) {
dispose = function() {
ko.utils.domNodeDisposal.removeDisposeCallback(disposeWhenNodeIsRemoved, arguments.callee);
disposeAllSubscriptionsToDependencies();
};
ko.utils.domNodeDisposal.addDisposeCallback(disposeWhenNodeIsRemoved, dispose);
var existingDisposeWhenFunction = disposeWhen;
disposeWhen = function () {
return !ko.utils.domNodeIsAttachedToDocument(disposeWhenNodeIsRemoved) || existingDisposeWhenFunction();
}
}

var evaluationTimeoutInstance = null;
function evaluatePossiblyAsync() {
Expand All @@ -59,11 +61,9 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction
// Don't dispose on first evaluation, because the "disposeWhen" callback might
// e.g., dispose when the associated DOM element isn't in the doc, and it's not
// going to be in the doc until *after* the first evaluation
if ((_hasBeenEvaluated) && typeof options["disposeWhen"] == "function") {
if (options["disposeWhen"]()) {
dependentObservable.dispose();
return;
}
if (_hasBeenEvaluated && disposeWhen()) {
dispose();
return;
}

try {
Expand All @@ -76,8 +76,7 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction
_subscriptionsToDependencies.push(subscribable.subscribe(evaluatePossiblyAsync));
});

var valueForThis = options["owner"]; // If undefined, it will default to "window" by convention. This might change in the future.
var newValue = options["read"].call(valueForThis);
var newValue = readFunction.call(evaluatorFunctionTarget);

for (var oldPos = 0, i = 0, len = oldSubscriptions.length; i < len; i++) {
if (oldSubscriptions[i])
Expand Down Expand Up @@ -105,10 +104,9 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction
}

dependentObservable.set = function() {
if (typeof options["write"] === "function") {
if (typeof writeFunction === "function") {
// Writing a value
var valueForThis = options["owner"]; // If undefined, it will default to "window" by convention. This might change in the future.
options["write"].apply(valueForThis, arguments);
writeFunction.apply(evaluatorFunctionTarget, arguments);
} else {
throw "Cannot write a value to a dependentObservable unless you specify a 'write' option. If you wish to read the current value, don't pass any parameters.";
}
Expand All @@ -124,13 +122,9 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction
return _latestValue;
}

dependentObservable.getDependenciesCount = function () { return _subscriptionsToDependencies.length; }
dependentObservable.getDependenciesCount = function () { return _subscriptionsToDependencies.length; };
dependentObservable.hasWriteFunction = typeof options["write"] === "function";
dependentObservable.dispose = function () {
if (disposeWhenNodeIsRemoved)
ko.utils.domNodeDisposal.removeDisposeCallback(disposeWhenNodeIsRemoved, disposeWhenNodeIsRemovedCallback);
disposeAllSubscriptionsToDependencies();
};
dependentObservable.dispose = function () { dispose(); };

ko.subscribable.call(dependentObservable);
ko.utils.extend(dependentObservable, ko.dependentObservable['fn']);
Expand Down

2 comments on commit 109b8e7

@dotnetwise
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any plans for a beta/RC/RTM ?

@mbest
Copy link
Owner Author

@mbest mbest commented on 109b8e7 Apr 13, 2012

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The withlight included in https://github.com/mbest/knockout is basically final. It's not a full with replacement. I had thought to rename with to withif and withlight to with, but since that would be a breaking change, I haven't done so.

Please sign in to comment.