packages/web-components/fast-element/src/templating/render.spec.ts
import { expect } from "chai";
import { customElement, FASTElement } from "../components/fast-element.js";
import { observable } from "../observation/observable.js";
import { Updates } from "../observation/update-queue.js";
import { Fake } from "../testing/fakes.js";
import { uniqueElementName } from "../testing/fixture.js";
import { toHTML } from "../__test__/helpers.js";
import type { AddViewBehaviorFactory, ViewBehaviorFactory, ViewBehaviorTargets, ViewController } from "./html-directive.js";
import { Markup } from "./markup.js";
import { NodeTemplate, render, RenderBehavior, RenderDirective, RenderInstruction, renderWith } from "./render.js";
import { html, ViewTemplate } from "./template.js";
import type { SyntheticView } from "./view.js";
import type { ElementCreateOptions } from "./render.js";
import { ref } from "./ref.js";
import { children } from "./children.js";
import { elements } from "./node-observation.js";
describe("The render", () => {
const childTemplate = html`Child Template`;
const childEditTemplate = html`Child Edit Template`;
const parentTemplate = html`Parent Template`;
context("template function", () => {
class TestChild {
name = "FAST";
}
class TestParent {
child = new TestChild();
}
RenderInstruction.register({
type: TestChild,
template: childTemplate
});
RenderInstruction.register({
type: TestChild,
template: childEditTemplate,
name: "edit"
});
RenderInstruction.register({
type: TestParent,
template: parentTemplate
});
it("returns a RenderDirective", () => {
const directive = render();
expect(directive).to.be.instanceOf(RenderDirective);
});
it("creates a data binding that points to the source when no data binding is provided", () => {
const source = new TestParent();
const directive = render() as RenderDirective;
const data = directive.dataBinding.evaluate(source, Fake.executionContext());
expect(data).to.equal(source);
});
it("creates a data binding that evaluates the provided binding", () => {
const source = new TestParent();
const directive = render<TestParent>(x => x.child) as RenderDirective;
const data = directive.dataBinding.evaluate(source, Fake.executionContext());
expect(data).to.equal(source.child);
});
it("creates a data binding that evaluates to a provided node", () => {
const source = new TestParent();
const node = document.createElement("div");
const directive = render(node) as RenderDirective;
const data = directive.dataBinding.evaluate(source, Fake.executionContext());
expect(data).to.equal(node);
});
it("creates a data binding that evaluates to a provided object", () => {
const source = new TestParent();
const obj = {};
const directive = render(obj) as RenderDirective;
const data = directive.dataBinding.evaluate(source, Fake.executionContext());
expect(data).to.equal(obj);
});
it("creates a template binding when a template is provided", () => {
const source = new TestParent();
const directive = render<TestParent>(x => x.child, childEditTemplate) as RenderDirective;
const template = directive.templateBinding.evaluate(source, Fake.executionContext());
expect(template).to.equal(childEditTemplate);
});
context("creates a template binding based on the data binding when no template binding is provided", () => {
it("for no binding", () => {
const source = new TestParent();
const directive = render() as RenderDirective;
const template = directive.templateBinding.evaluate(source, Fake.executionContext());
expect(template).to.equal(parentTemplate);
});
it("for normal binding", () => {
const source = new TestParent();
const directive = render<TestParent>(x => x.child) as RenderDirective;
const template = directive.templateBinding.evaluate(source, Fake.executionContext());
expect(template).to.equal(childTemplate);
});
it("for node binding", () => {
const source = new TestParent();
const node = document.createElement("div");
const directive = render<TestParent>(() => node) as RenderDirective;
const template = directive.templateBinding.evaluate(source, Fake.executionContext()) as NodeTemplate;
expect(template).to.be.instanceOf(NodeTemplate);
expect(template.node).equals(node);
});
});
context("creates a template using the template binding that was provided", () => {
it("when the template binding returns a string", () => {
const source = new TestParent();
const directive = render<TestParent>(x => x.child, () => "edit") as RenderDirective;
const template = directive.templateBinding.evaluate(source, Fake.executionContext());
expect(template).to.equal(childEditTemplate);
});
it("when the template binding returns a node", () => {
const source = new TestParent();
const node = document.createElement("div");
const directive = render<TestParent>(x => x.child, () => node) as RenderDirective;
const template = directive.templateBinding.evaluate(source, Fake.executionContext()) as NodeTemplate;
expect(template).to.be.instanceOf(NodeTemplate);
expect(template.node).equals(node);
});
it("when the template binding returns a template", () => {
const source = new TestParent();
const directive = render<TestParent>(x => x.child, () => childEditTemplate) as RenderDirective;
const template = directive.templateBinding.evaluate(source, Fake.executionContext());
expect(template).equal(childEditTemplate);
});
});
context("creates a template when a view name was specified", () => {
it("when the data binding returns a node", () => {
const source = new TestParent();
const node = document.createElement("div");
const directive = render<TestParent>(() => node, "edit") as RenderDirective;
const template = directive.templateBinding.evaluate(source, Fake.executionContext()) as NodeTemplate;
expect(template).to.be.instanceOf(NodeTemplate);
expect(template.node).equals(node);
});
it("when the data binding returns a value", () => {
const source = new TestParent();
const directive = render<TestParent>(x => x.child, "edit") as RenderDirective;
const template = directive.templateBinding.evaluate(source, Fake.executionContext());
expect(template).equal(childEditTemplate);
});
});
});
context("instruction gateway", () => {
const operations = ["create", "register"];
for (const operation of operations) {
it(`can ${operation} an instruction from type and template`, () => {
class TestClass {};
const instruction = RenderInstruction[operation]({
type: TestClass,
template: parentTemplate
});
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(instruction.template).equal(parentTemplate);
});
it(`can ${operation} an instruction from type, template, and name`, () => {
class TestClass {};
const instruction = RenderInstruction[operation]({
type: TestClass,
template: parentTemplate,
name: "test"
});
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(instruction.template).equal(parentTemplate);
expect(instruction.name).equal("test");
});
it(`can ${operation} an instruction from type and element`, () => {
class TestClass {};
const tagName = uniqueElementName();
@customElement(tagName)
class TestElement extends FASTElement {}
const instruction = RenderInstruction[operation]({
element: TestElement,
type: TestClass
});
const template = instruction.template as ViewTemplate;
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(template).instanceOf(ViewTemplate);
expect(template.html).to.include(`</${tagName}>`);
});
it(`can ${operation} an instruction from type, element, and name`, () => {
class TestClass {};
const tagName = uniqueElementName();
@customElement(tagName)
class TestElement extends FASTElement {}
const instruction = RenderInstruction[operation]({
element: TestElement,
type: TestClass,
name: "test"
});
const template = instruction.template as ViewTemplate;
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(template).instanceOf(ViewTemplate);
expect(template.html).to.include(`</${tagName}>`);
expect(instruction.name).equal("test");
});
it(`can ${operation} an instruction from type, element, and content`, () => {
class TestClass {};
const tagName = uniqueElementName();
const content = "Hello World!";
@customElement(tagName)
class TestElement extends FASTElement {}
const instruction = RenderInstruction[operation]({
element: TestElement,
type: TestClass,
content
});
const template = instruction.template as ViewTemplate;
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(template).instanceOf(ViewTemplate);
expect(template.html).to.include(`${content}</${tagName}>`);
});
it(`can ${operation} an instruction from type, element, content, and attributes`, () => {
class TestClass {};
const tagName = uniqueElementName();
const content = "Hello World!";
@customElement(tagName)
class TestElement extends FASTElement {}
const instruction = RenderInstruction[operation]({
element: TestElement,
type: TestClass,
content,
attributes: {
"foo": "bar",
"baz": "qux"
}
});
const template = instruction.template as ViewTemplate;
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(template).instanceOf(ViewTemplate);
expect(template.html).to.include(`${content}</${tagName}>`);
expect(template.html).to.include(`foo="`);
expect(template.html).to.include(`baz="`);
});
it(`can ${operation} an instruction from type and tagName`, () => {
class TestClass {};
const tagName = uniqueElementName();
const instruction = RenderInstruction[operation]({
tagName,
type: TestClass
});
const template = instruction.template as ViewTemplate;
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(template).instanceOf(ViewTemplate);
expect(template.html).to.include(`</${tagName}>`);
});
it(`can ${operation} an instruction from type, tagName, and name`, () => {
class TestClass {};
const tagName = uniqueElementName();
const instruction = RenderInstruction[operation]({
tagName,
type: TestClass,
name: "test"
});
const template = instruction.template as ViewTemplate;
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(template).instanceOf(ViewTemplate);
expect(template.html).to.include(`</${tagName}>`);
expect(instruction.name).equal("test");
});
it(`can ${operation} an instruction from type, tagName, and content`, () => {
class TestClass {};
const tagName = uniqueElementName();
const content = "Hello World!";
const instruction = RenderInstruction[operation]({
tagName,
type: TestClass,
content
});
const template = instruction.template as ViewTemplate;
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(template).instanceOf(ViewTemplate);
expect(template.html).to.include(`${content}</${tagName}>`);
});
it(`can ${operation} an instruction from type, tagName, content, and attributes`, () => {
class TestClass {};
const tagName = uniqueElementName();
const content = "Hello World!";
const instruction = RenderInstruction[operation]({
tagName,
type: TestClass,
content,
attributes: {
"foo": "bar",
"baz": "qux"
}
});
const template = instruction.template as ViewTemplate;
expect(RenderInstruction.instanceOf(instruction)).to.be.true;
expect(instruction.type).equal(TestClass);
expect(template).instanceOf(ViewTemplate);
expect(template.html).to.include(`${content}</${tagName}>`);
expect(template.html).to.include(`foo="`);
expect(template.html).to.include(`baz="`);
});
}
it(`can register an existing instruction`, () => {
class TestClass {};
const instruction = RenderInstruction.create({
type: TestClass,
template: parentTemplate
});
const result = RenderInstruction.register(instruction);
expect(result).equal(instruction);
});
it(`can get an instruction for an instance`, () => {
class TestClass {};
const instruction = RenderInstruction.register({
type: TestClass,
template: parentTemplate
});
const result = RenderInstruction.getForInstance(new TestClass());
expect(result).equal(instruction);
});
it(`can get an instruction for a type`, () => {
class TestClass {};
const instruction = RenderInstruction.register({
type: TestClass,
template: parentTemplate
});
const result = RenderInstruction.getByType(TestClass);
expect(result).equal(instruction);
});
});
context("node template", () => {
it("can add a node", () => {
const parent = document.createElement("div");
const location = document.createComment("");
parent.appendChild(location);
const child = document.createElement("div");
const template = new NodeTemplate(child);
const view = template.create();
view.insertBefore(location);
expect(child.parentElement).equal(parent);
expect(child.nextSibling).equal(location);
});
it("can remove a node", () => {
const parent = document.createElement("div");
const child = document.createElement("div");
parent.appendChild(child);
const template = new NodeTemplate(child);
const view = template.create();
view.remove();
expect(child.parentElement).equal(null);
expect(child.nextSibling).equal(null);
});
});
context("directive", () => {
it("adds itself to a template with a comment placeholder", () => {
const directive = render() as RenderDirective;
const id = "12345";
let captured;
const addViewBehaviorFactory: AddViewBehaviorFactory = (factory: ViewBehaviorFactory) => {
captured = factory;
return id;
};
const html = directive.createHTML(addViewBehaviorFactory);
expect(html).equals(Markup.comment(id));
expect(captured).equals(directive);
});
it("creates a behavior", () => {
const directive = render() as RenderDirective;
const behavior = directive.createBehavior();
expect(behavior).instanceOf(RenderBehavior);
});
it("can be interpolated in html", () => {
const template = html`hello${render()}world`;
const keys = Object.keys(template.factories);
const directive = template.factories[keys[0]];
expect(directive).instanceOf(RenderDirective);
});
});
context("decorator", () => {
it("registers with tagName options", () => {
const tagName = uniqueElementName();
@renderWith({ tagName })
class Model {
}
const instruction = RenderInstruction.getByType(Model)!;
expect(instruction.type).equals(Model);
expect((instruction.template as ViewTemplate).html).contains(`</${tagName}>`);
});
it("registers with element options", () => {
const tagName = uniqueElementName();
@customElement(tagName)
class TestElement extends FASTElement {}
@renderWith({ element: TestElement })
class Model {
}
const instruction = RenderInstruction.getByType(Model)!;
expect(instruction.type).equals(Model);
expect((instruction.template as ViewTemplate).html).contains(`</${tagName}>`);
});
it("registers with template options", () => {
const template = html`hello world`;
@renderWith({ template })
class Model {
}
const instruction = RenderInstruction.getByType(Model)!;
expect(instruction.type).equals(Model);
expect((instruction.template as ViewTemplate).html).contains(`hello world`);
});
it("registers with element", () => {
const tagName = uniqueElementName();
@customElement(tagName)
class TestElement extends FASTElement {}
@renderWith(TestElement)
class Model {
}
const instruction = RenderInstruction.getByType(Model)!;
expect(instruction.type).equals(Model);
expect((instruction.template as ViewTemplate).html).contains(`</${tagName}>`);
});
it("registers with element and name", () => {
const tagName = uniqueElementName();
@customElement(tagName)
class TestElement extends FASTElement {}
@renderWith(TestElement, "test")
class Model {
}
const instruction = RenderInstruction.getByType(Model, "test")!;
expect(instruction.type).equals(Model);
expect((instruction.template as ViewTemplate).html).contains(`</${tagName}>`);
expect(instruction.name).equals("test");
});
it("registers with template", () => {
const template = html`hello world`;
@renderWith(template)
class Model {
}
const instruction = RenderInstruction.getByType(Model)!;
expect(instruction.type).equals(Model);
expect((instruction.template as ViewTemplate).html).contains(`hello world`);
});
it("registers with template and name", () => {
const template = html`hello world`;
@renderWith(template, "test")
class Model {
}
const instruction = RenderInstruction.getByType(Model, "test")!;
expect(instruction.type).equals(Model);
expect((instruction.template as ViewTemplate).html).contains(`hello world`);
expect(instruction.name).equals("test");
});
});
context("behavior", () => {
const childTemplate = html<Child>`This is a template. ${x => x.knownValue}`;
class Child {
@observable knownValue = "value";
}
class Parent {
@observable child = new Child();
@observable trigger = 0;
@observable innerTemplate = childTemplate;
get template() {
const value = this.trigger;
return this.innerTemplate;
}
forceComputedUpdate() {
this.trigger++;
}
}
function renderBehavior() {
const directive = render<Parent>(x => x.child, x => x.template) as RenderDirective;
directive.targetNodeId = 'r';
const node = document.createComment("");
const targets = { r: node };
const behavior = directive.createBehavior();
const parentNode = document.createElement("div");
parentNode.appendChild(node);
return { directive, behavior, node, parentNode, targets };
}
function createController(source: any, targets: ViewBehaviorTargets) {
const unbindables: { unbind(controller: ViewController) }[] = [];
return {
context: Fake.executionContext(),
onUnbind(object) {
unbindables.push(object);
},
source,
targets,
isBound: false,
unbind() {
unbindables.forEach(x => x.unbind(this))
}
};
}
it("initially inserts a view based on the template", () => {
const { behavior, parentNode, targets } = renderBehavior();
const model = new Parent();
const controller = createController(model, targets);
behavior.bind(controller);
expect(toHTML(parentNode)).to.equal(`This is a template. value`);
});
it("updates an inserted view when the value changes to a new template", async () => {
const { behavior, parentNode, targets } = renderBehavior();
const model = new Parent();
const controller = createController(model, targets);
behavior.bind(controller);
expect(toHTML(parentNode)).to.equal(`This is a template. value`);
model.innerTemplate = html<Child>`This is a new template. ${x => x.knownValue}`;
await Updates.next();
expect(toHTML(parentNode)).to.equal(`This is a new template. value`);
});
it("doesn't compose an already composed view", async () => {
const { behavior, parentNode, node, targets } = renderBehavior();
const model = new Parent();
const controller = createController(model, targets);
behavior.bind(controller);;
const inserted = node.previousSibling;
expect(toHTML(parentNode)).to.equal(`This is a template. value`);
model.forceComputedUpdate();
await Updates.next();
expect(toHTML(parentNode)).to.equal(`This is a template. value`);
expect(node.previousSibling).equal(inserted);
});
it("unbinds a composed view", () => {
const { behavior, parentNode, targets } = renderBehavior();
const model = new Parent();
const controller = createController(model, targets);
behavior.bind(controller);
const view = (behavior as any).view as SyntheticView;
expect(view.source).equal(model.child);
expect(toHTML(parentNode)).to.equal(`This is a template. value`);
controller.unbind();
expect(view.source).equal(null);
});
it("rebinds a previously unbound composed view", () => {
const { behavior, parentNode, targets } = renderBehavior();
const model = new Parent();
const controller = createController(model, targets);
behavior.bind(controller);
const view = (behavior as any).view as SyntheticView;
expect(view.source).to.equal(model.child);
expect(toHTML(parentNode)).to.equal(`This is a template. value`);
behavior.unbind(controller);
expect(view.source).to.equal(null);
behavior.bind(controller);
const newView = (behavior as any).view as SyntheticView;
expect(newView.source).to.equal(model.child);
expect(newView).equal(view);
expect(toHTML(parentNode)).to.equal(`This is a template. value`);
});
});
context("createElementTemplate function", () => {
const sourceTemplate = html<RenderSource>`This is a template. ${x => x.knownValue}`;
const templateAttributeOptions: ElementCreateOptions = {
attributes: { id: x => x.id },
}
const templateStaticViewOptions: ElementCreateOptions = {
content: "foo"
}
class RenderSource {
id = 'child-1';
@observable knownValue: string = "value";
@observable ref: HTMLElement;
@observable childElements: Array<HTMLElement>;
}
it(`creates a template from a tag name`, () => {
const template = RenderInstruction.createElementTemplate("button");
expect(template.html).to.equal(`<button></button>`);
});
it(`creates a template with attributes`, () => {
const template = RenderInstruction.createElementTemplate(
"button",
templateAttributeOptions
);
const targetNode = document.createElement("div");
const source = new RenderSource();
const view = template.create();
view.bind(source);
view.appendTo(targetNode);
expect(view.source).to.equal(source);
expect(toHTML(targetNode)).to.equal(`<button id="child-1"></button>`);
});
it(`creates a template with static content`, () => {
const template = RenderInstruction.createElementTemplate("button", templateStaticViewOptions);
const targetNode = document.createElement("div");
const view = template.create();
view.appendTo(targetNode);
expect(view.source).to.equal(null);
expect(toHTML(targetNode.firstElementChild!)).to.equal("foo");
});
it(`creates a template with attributes and content ViewTemplate`, async () => {
const template = RenderInstruction.createElementTemplate(
"button",
{
...templateAttributeOptions,
content: sourceTemplate
}
);
const targetNode = document.createElement("div");
const source = new RenderSource();
const view = template.create();
view.bind(source);
view.appendTo(targetNode);
expect(view.source).to.equal(source);
expect(toHTML(targetNode.firstElementChild!)).to.equal("This is a template. value")
});
it(`creates a template with content binding that can change when the source value changes`, async () => {
const template = RenderInstruction.createElementTemplate(
"button",
{
...templateAttributeOptions,
content: sourceTemplate
}
);
const targetNode = document.createElement("div");
const source = new RenderSource();
const view = template.create();
view.bind(source);
view.appendTo(targetNode);
expect(view.source).to.equal(source);
expect(toHTML(targetNode.firstElementChild!)).to.equal("This is a template. value");
source.knownValue = "new-value";
await Updates.next();
expect(toHTML(targetNode.firstElementChild!)).to.equal("This is a template. new-value");
});
it(`creates a template with a ref directive on the host tag.`, async () => {
const template = RenderInstruction.createElementTemplate(
"button",
{
directives: [ref("ref")],
...templateStaticViewOptions
}
);
const targetNode = document.createElement("div");
const source = new RenderSource();
const view = template.create();
view.bind(source);
view.appendTo(targetNode);
expect(view.source).to.equal(source);
await Updates.next();
expect(source.ref).to.be.instanceof(HTMLElement);
});
it(`creates a template with ref and children directives on the host tag`, async () => {
const template = RenderInstruction.createElementTemplate(
"ul",
{
directives: [ref("ref"), children({ property: "childElements", filter: elements() })],
content: html`
<li>item-1</li>
<li>item-1</li>
<li>item-1</li>
`
}
);
const targetNode = document.createElement("div");
const source = new RenderSource();
const view = template.create();
view.bind(source);
view.appendTo(targetNode);
expect(view.source).to.equal(source);
await Updates.next();
expect(source.ref).to.be.instanceof(HTMLElement);
expect(source.childElements).to.have.lengthOf(3);
});
});
});