Skip to content

Commit

Permalink
Merge pull request knockout#1463 from knockout/1463-templated-components
Browse files Browse the repository at this point in the history
Request feature: runtime composition of ko 3.2 component
  • Loading branch information
SteveSanderson committed Oct 17, 2014
2 parents c6cdb3e + 58dec53 commit 96aa912
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 6 deletions.
19 changes: 18 additions & 1 deletion spec/components/componentBindingBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,18 @@ describe('Components: Component binding', function() {
expect(testNode.childNodes[0].childNodes[0]).not.toBe(testTemplate[0]);
});

it('Passes params and componentInfo (with prepopulated element) to the component\'s viewmodel factory', function() {
it('Passes params and componentInfo (with prepopulated element and templateNodes) to the component\'s viewmodel factory', function() {
var componentConfig = {
template: '<div data-bind="text: 123">I have been prepopulated and not bound yet</div>',
viewModel: {
createViewModel: function(params, componentInfo) {
expect(componentInfo.element).toContainText('I have been prepopulated and not bound yet');
expect(params).toBe(testComponentParams);
expect(componentInfo.templateNodes.length).toEqual(3);
expect(componentInfo.templateNodes[0]).toContainText('Here are some ');
expect(componentInfo.templateNodes[1]).toContainText('template');
expect(componentInfo.templateNodes[2]).toContainText(' nodes');
expect(componentInfo.templateNodes[1].tagName.toLowerCase()).toEqual('em');

//verify that createViewModel is the same function and was called with the component definition as the context
expect(this.createViewModel).toBe(componentConfig.viewModel.createViewModel);
Expand All @@ -89,6 +94,7 @@ describe('Components: Component binding', function() {
}
}
};
testNode.innerHTML = '<div data-bind="component: testComponentBindingValue">Here are some <em>template</em> nodes</div>';

ko.components.register(testComponentName, componentConfig);
ko.applyBindings(outerViewModel, testNode);
Expand Down Expand Up @@ -165,6 +171,17 @@ describe('Components: Component binding', function() {
expect(testNode.childNodes[0]).toContainText('Parent is outer view model: true');
});

it('Creates a binding context with $componentTemplateNodes giving the original child nodes', function() {
ko.components.register(testComponentName, {
template: 'Start<span data-bind="template: { nodes: $componentTemplateNodes }"></span>End'
});
testNode.innerHTML = '<div data-bind="component: testComponentBindingValue"><em>original</em> child nodes</div>';
ko.applyBindings(outerViewModel, testNode);
jasmine.Clock.tick(1);

expect(testNode.childNodes[0]).toContainHtml('start<span data-bind="template: { nodes: $componenttemplatenodes }"><em>original</em> child nodes</span>end');
});

it('Passes nonobservable params to the component', function() {
// Set up a component that logs its constructor params
var receivedParams = [];
Expand Down
56 changes: 56 additions & 0 deletions spec/components/customElementBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,60 @@ describe('Components: Custom elements', function() {
throw ex;
}
});

it('Is possible to set up components that receive, inject, and bind templates supplied by the user of the component (sometimes called "templated components" or "transclusion")', function() {
// This spec repeats assertions made in other specs elsewhere, but is useful to prove the end-to-end technique

this.after(function() {
ko.components.unregister('special-list');
});

// First define a reusable 'special-list' component that produces a <ul> in which the <li>s have special CSS classes
// It also injects and binds a supplied template for each list item
ko.components.register('special-list', {
template: '<ul class="my-special-list" data-bind="foreach: specialListItems">'
+ '<li class="special-list-item" data-bind="template: { nodes: $parent.suppliedItemTemplate }">'
+ '</li>'
+ '</ul>',
viewModel: {
createViewModel: function(params, componentInfo) {
return {
specialListItems: params.items,
suppliedItemTemplate: componentInfo.templateNodes
};
}
}
});

// Now make some view markup that uses <special-list> and supplies a template to be used inside each list item
testNode.innerHTML = '<h1>Cheeses</h1>'
+ '<special-list params="items: cheeses">'
+ '<em data-bind="text: name"></em> has quality <em data-bind="text: quality"></em>'
+ '</special-list>';

// Finally, bind it all to some data
ko.applyBindings({
cheeses: [
{ name: 'brie', quality: 7 },
{ name: 'cheddar', quality: 9 },
{ name: 'roquefort', quality: 3 }
]
}, testNode);

jasmine.Clock.tick(1);
expect(testNode.childNodes[0]).toContainText('Cheeses');
expect(testNode.childNodes[1].childNodes[0].tagName.toLowerCase()).toEqual('ul');
expect(testNode.childNodes[1].childNodes[0].className).toEqual('my-special-list');
expect(testNode.childNodes[1].childNodes[0]).toContainHtml(
'<li class="special-list-item" data-bind="template: { nodes: $parent.supplieditemtemplate }">'
+ '<em data-bind="text: name">brie</em> has quality <em data-bind="text: quality">7</em>'
+ '</li>'
+ '<li class="special-list-item" data-bind="template: { nodes: $parent.supplieditemtemplate }">'
+ '<em data-bind="text: name">cheddar</em> has quality <em data-bind="text: quality">9</em>'
+ '</li>'
+ '<li class="special-list-item" data-bind="template: { nodes: $parent.supplieditemtemplate }">'
+ '<em data-bind="text: name">roquefort</em> has quality <em data-bind="text: quality">3</em>'
+ '</li>'
);
});
});
72 changes: 72 additions & 0 deletions spec/templatingBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,78 @@ describe('Templating', function() {
expect(testNode.childNodes[0]).toContainHtml("outer <div>inner via inline binding: <span>1</span>inner via external binding: <em>2</em></div>");
});

it('Should accept a "nodes" option that gives the template nodes', function() {
// This is an alternative to specifying a named template, and is useful in conjunction with components
ko.setTemplateEngine(new dummyTemplateEngine({
innerTemplate: "the name is [js: name()]" // See that custom template engines are applied to the injected nodes
}));

testNode.innerHTML = "<div data-bind='template: { nodes: testNodes, data: testData, bypassDomNodeWrap: true }'></div>";
var model = {
testNodes: [
document.createTextNode("begin"),
document.createElement("div"),
document.createTextNode("end")
],
testData: { name: ko.observable("alpha") }
};
model.testNodes[1].setAttribute("data-bind", "template: 'innerTemplate'"); // See that bindings are applied to the injected nodes

ko.applyBindings(model, testNode);
expect(testNode.childNodes[0]).toContainHtml("begin<div>the name is alpha</div>end");

// The injected bindings update to match model changes as usual
model.testData.name("beta");
expect(testNode.childNodes[0]).toContainHtml("begin<div>the name is beta</div>end");
});

it('Should accept a "nodes" option that gives the template nodes, and it can be used in conjunction with "foreach"', function() {
testNode.innerHTML = "<div data-bind='template: { nodes: testNodes, foreach: testData, bypassDomNodeWrap: true }'></div>";

// This time we'll check that the nodes array doesn't have to be a real array - it can be the .childNodes
// property of a DOM element, which is subtly different.
var templateContainer = document.createElement("div");
templateContainer.innerHTML = "[<div data-bind='text: name'></div>]";
var model = {
testNodes: templateContainer.childNodes,
testData: ko.observableArray([{ name: ko.observable("alpha") }, { name: "beta" }, { name: "gamma" }])
};
model.testNodes[1].setAttribute("data-bind", "text: name");

ko.applyBindings(model, testNode);
expect(testNode.childNodes[0]).toContainText("[alpha][beta][gamma]");

// The injected bindings update to match model changes as usual
model.testData.splice(1, 1);
expect(testNode.childNodes[0]).toContainText("[alpha][gamma]");

// Changing the nodes array does *not* affect subsequent output from the template.
// This behavior may be subject to change. I'm adding this assertion just to record what
// the current behavior is, even if we might want to alter it in the future. We don't need
// to document or make any guarantees about what happens if you do this - it's just not
// a supported thing to do.
templateContainer.innerHTML = "[Modified, but will not appear in template output because the nodes were already cloned]";
model.testData.splice(1, 0, { name: "delta" });
expect(testNode.childNodes[0]).toContainText("[alpha][delta][gamma]");
});

it('Should interpret "nodes: anyFalseyValue" as being equivalent to supplying an empty node array', function() {
// This behavior helps to avoid inconsistency if you're programmatically supplying a node array
// but sometimes you might not have any nodes - you don't want the template binding to dynamically
// switch over to "inline template" mode just because your 'nodes' value is null, for example.
testNode.innerHTML = "<div data-bind='template: { nodes: null, bypassDomNodeWrap: true }'>Should not use this inline template</div>";
ko.applyBindings(null, testNode);
expect(testNode.childNodes[0]).toContainHtml('');
});

it('Should not allow "nodes: someObservableArray"', function() {
// See comment in implementation for reasoning
testNode.innerHTML = "<div data-bind='template: { nodes: myNodes, bypassDomNodeWrap: true }'>Should not use this inline template</div>";
expect(function() {
ko.applyBindings({ myNodes: ko.observableArray() }, testNode);
}).toThrowContaining("The \"nodes\" option must be a plain, non-observable array");
});

describe('Data binding \'foreach\' option', function() {
it('Should remove existing content', function () {
ko.setTemplateEngine(new dummyTemplateEngine({ itemTemplate: "<span>template content</span>" }));
Expand Down
13 changes: 8 additions & 5 deletions src/components/componentBinding.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

// Any in-flight loading operation is no longer relevant, so make sure we ignore its completion
currentLoadingOperationId = null;
};
},
originalChildNodes = ko.utils.makeArray(ko.virtualElements.childNodes(element));

ko.utils.domNodeDisposal.addDisposeCallback(element, disposeAssociatedComponentViewModel);

Expand Down Expand Up @@ -48,8 +49,10 @@
throw new Error('Unknown component \'' + componentName + '\'');
}
cloneTemplateIntoElement(componentName, componentDefinition, element);
var componentViewModel = createViewModel(componentDefinition, element, componentParams),
childBindingContext = bindingContext['createChildContext'](componentViewModel);
var componentViewModel = createViewModel(componentDefinition, element, originalChildNodes, componentParams),
childBindingContext = bindingContext['createChildContext'](componentViewModel, /* dataItemAlias */ undefined, function(ctx) {
ctx['$componentTemplateNodes'] = originalChildNodes;
});
currentViewModel = componentViewModel;
ko.applyBindingsToDescendants(childBindingContext, element);
});
Expand All @@ -71,10 +74,10 @@
ko.virtualElements.setDomNodeChildren(element, clonedNodesArray);
}

function createViewModel(componentDefinition, element, componentParams) {
function createViewModel(componentDefinition, element, originalChildNodes, componentParams) {
var componentViewModelFactory = componentDefinition['createViewModel'];
return componentViewModelFactory
? componentViewModelFactory.call(componentDefinition, componentParams, { element: element })
? componentViewModelFactory.call(componentDefinition, componentParams, { 'element': element, 'templateNodes': originalChildNodes })
: componentParams; // Template-only component
}

Expand Down
11 changes: 11 additions & 0 deletions src/templating/templating.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,17 @@
if (typeof bindingValue == "string" || bindingValue['name']) {
// It's a named template - clear the element
ko.virtualElements.emptyNode(element);
} else if ('nodes' in bindingValue) {
// We've been given an array of DOM nodes. Save them as the template source.
// There is no known use case for the node array being an observable array (if the output
// varies, put that behavior *into* your template - that's what templates are for), and
// the implementation would be a mess, so assert that it's not observable.
var nodes = bindingValue['nodes'] || [];
if (ko.isObservable(nodes)) {
throw new Error('The "nodes" option must be a plain, non-observable array.');
}
var container = ko.utils.moveCleanedNodesToContainerElement(nodes); // This also removes the nodes from their current parent
new ko.templateSources.anonymousTemplate(element)['nodes'](container);
} else {
// It's an anonymous template - store the element contents, then clear the element
var templateNodes = ko.virtualElements.childNodes(element),
Expand Down

0 comments on commit 96aa912

Please sign in to comment.