substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing systems.
1,370 lines (1,256 loc) • 45.2 kB
JavaScript
import { module, spy } from 'substance-test'
import { DefaultDOMElement, substanceGlobals, isEqual, Component, platform } from 'substance'
import TestComponent from './fixture/TestComponent'
import getMountPoint from './fixture/getMountPoint'
const Simple = TestComponent.Simple
// regular rendering using default DOM elements
ComponentTests()
// RenderingEngine in debug mode
ComponentTests('debug')
// in the browser do an extra run on memory DOM elements
if (platform.inBrowser) {
ComponentTests(false, 'memory')
}
function ComponentTests(debug, memory) {
const test = module('Component' + (debug ? ' [debug]' : '') + (memory ? ' [memory]' : ''), {
before: function(t) {
substanceGlobals.DEBUG_RENDERING = Boolean(debug)
if (memory) platform.inBrowser = false
t._document = DefaultDOMElement.createDocument('html')
},
after: function() {
platform._reset()
}
})
test("Throw error when render method is not returning an element", function(t) {
class MyComponent extends TestComponent {
render() {}
}
t.throws(function() {
MyComponent.render()
}, "Should throw an exception when render does not return an element")
t.end()
})
test("Mounting a component", function(t) {
// Mounting a detached element
let doc = t._document.createDocument('html')
let el = doc.createElement('div')
let comp = Simple.mount(el)
t.equal(comp.didMount.callCount, 0, "didMount must not be called when mounting to detached elements")
// Mounting an attached element
comp = Simple.mount(doc.firstChild)
t.equal(comp.didMount.callCount, 1, "didMount should have been called")
t.end()
})
test("Render an HTML element", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div')
})
t.equal(comp.el.tagName, 'div', 'Element should be a "div".')
comp = TestComponent.create(function($$) {
return $$('span')
})
t.equal(comp.el.tagName, 'span', 'Element should be a "span".')
t.end()
})
test("Render an element with attributes", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').attr('data-id', 'foo')
})
t.equal(comp.el.attr('data-id'), 'foo', 'Element should be have data-id="foo".')
t.end()
})
test("Render an element with css styles", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').css('width', '100px')
})
t.equal(comp.el.css('width'), '100px', 'Element should have a css width of 100px.')
t.end()
})
test("Render an element with classes", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').addClass('test')
})
t.ok(comp.el.hasClass('test'), 'Element should have class "test".')
t.end()
})
test("Render an element with value", function(t) {
let comp = TestComponent.create(function($$) {
return $$('input').attr('type', 'text').val('foo')
})
t.equal(comp.el.val(), 'foo', 'Value should be set.')
t.end()
})
test("Render an element with plain text", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').text('foo')
})
t.equal(comp.el.textContent, 'foo','textContent should be set.')
t.end()
})
test("Render an element with custom html", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').html('Hello <b>World</b>')
})
// ATTENTION: it is important to call find() on the element API
// not on the Component API, as Component#find will only provide
// elements which are Component instance.
let b = comp.el.find('b')
t.notNil(b, 'Element should have rendered HTML as content.')
t.equal(b.textContent, 'World','Rendered element should have right content.')
t.end()
})
test("Rendering an element with HTML attributes etc.", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div')
.addClass('foo')
.attr('data-id', 'foo')
.htmlProp('type', 'foo')
})
t.equal(comp.el.attr('data-id'), 'foo', 'Element should have data-id="foo".')
t.ok(comp.el.hasClass('foo'), 'Element should have class "foo".')
t.equal(comp.el.getProperty('type'), 'foo', 'Element should have type "foo".')
t.end()
})
test("Rendering an input element with value", function(t) {
let comp = TestComponent.create(function($$) {
return $$('input').attr('type', 'text').val('foo')
})
t.equal(comp.el.val(), 'foo', 'Input field should have value "foo".')
t.end()
})
test("Render a component", function(t) {
let comp = Simple.render()
t.equal(comp.el.tagName.toLowerCase(), 'div', 'Element should be a "div".')
t.ok(comp.el.hasClass('simple-component'), 'Element should have class "simple-component".')
t.end()
})
test("Rerender on setProps()", function(t) {
let comp = Simple.render({ foo: 'bar '})
comp.shouldRerender.reset()
comp.render.reset()
comp.setProps({ foo: 'baz' })
t.ok(comp.shouldRerender.callCount > 0, "Component should have been asked whether to rerender.")
t.ok(comp.render.callCount > 0, "Component should have been rerendered.")
t.end()
})
test("Rerendering triggers didUpdate()", function(t) {
let comp = Simple.render({ foo: 'bar '})
spy(comp, 'didUpdate')
comp.rerender()
t.ok(comp.didUpdate.callCount === 1, "didUpdate() should have been called once.")
t.end()
})
test("Setting props triggers willReceiveProps()", function(t) {
let comp = Simple.render({ foo: 'bar '})
spy(comp, 'willReceiveProps')
comp.setProps({ foo: 'baz' })
t.ok(comp.willReceiveProps.callCount === 1, "willReceiveProps() should have been called once.")
t.end()
})
test("Rerender on setState()", function(t) {
let comp = Simple.render()
comp.shouldRerender.reset()
comp.render.reset()
comp.setState({ foo: 'baz' })
t.ok(comp.shouldRerender.callCount > 0, "Component should have been asked whether to rerender.")
t.ok(comp.render.callCount > 0, "Component should have been rerendered.")
t.end()
})
test("Setting state triggers willUpdateState()", function(t) {
let comp = Simple.render()
spy(comp, 'willUpdateState')
comp.setState({ foo: 'baz' })
t.ok(comp.willUpdateState.callCount === 1, "willUpdateState() should have been called once.")
t.end()
})
test("Trigger didUpdate() when state or props have changed even with shouldRerender() = false", function(t) {
class A extends Component {
shouldRerender() {
return false
}
render($$) {
return $$('div')
}
}
let comp = A.render()
spy(comp, 'didUpdate')
// component will not rerender but still should trigger didUpdate()
comp.setProps({foo: 'bar'})
t.ok(comp.didUpdate.callCount === 1, "comp.didUpdate() should have been called once.")
comp.didUpdate.reset()
comp.setState({foo: 'bar'})
t.ok(comp.didUpdate.callCount === 1, "comp.didUpdate() should have been called once.")
t.end()
})
test("Dependency-Injection", function(t) {
class Parent extends Component {
getChildContext() {
let childContext = {}
if (this.props.name) {
childContext[this.props.name] = this.props.name
}
return childContext
}
render($$) {
let el = $$('div')
// direct child
el.append($$(Child).ref('a'))
// indirect child
el.append($$('div').append(
$$(Child).ref('b')
))
el.append(
$$(Wrapper, {
name:'bar',
// ingested grandchild
children: [
$$(Child).ref('c')
]
})
)
return el
}
}
class Child extends Component {
render($$) {
return $$('div')
}
}
class Wrapper extends Component {
render($$) {
return $$('div').append(this.props.children)
}
}
Wrapper.prototype.getChildContext = Parent.prototype.getChildContext
let comp = Parent.render({name: 'foo'})
let a = comp.refs.a
let b = comp.refs.b
let c = comp.refs.c
t.notNil(a.context.foo, "'a' should have a property 'foo' in its context")
t.isNil(a.context.bar, ".. but not 'bar'")
t.notNil(b.context.foo, "'b' should have a property 'foo' in its context")
t.isNil(b.context.bar, ".. but not 'bar'")
t.notNil(c.context.foo, "'c' should have a property 'foo' in its context")
t.notNil(c.context.bar, ".. and also 'bar'")
t.end()
})
/* ##################### Rerendering ##########################*/
test("Rerendering varying content", function(t) {
class TestComponent extends Component {
getInitialState() {
return { mode: 0 }
}
render($$) {
let el = $$('div')
if (this.state.mode === 0) {
el.append(
"Foo",
$$('br')
)
} else {
el.append(
"Bar",
$$('span'),
"Baz",
$$('br')
)
}
return el
}
}
let comp = TestComponent.render()
let childNodes = comp.el.getChildNodes()
t.equal(childNodes.length, 2, '# Component should have two children in mode 0')
t.ok(childNodes[0].isTextNode(), '__first should be a TextNode')
t.equal(childNodes[0].textContent, 'Foo', '____with proper text content')
t.equal(childNodes[1].tagName, 'br', '__and second should be a <br>')
comp.setState({ mode: 1 })
childNodes = comp.el.getChildNodes()
t.equal(childNodes.length, 4, '# Component should have 4 children in mode 1')
t.ok(childNodes[0].isTextNode(), '__first should be a TextNode')
t.equal(childNodes[0].textContent, 'Bar', '____with proper text content')
t.equal(childNodes[1].tagName, 'span', '__second should be <span>')
t.ok(childNodes[2].isTextNode(), '__third should be a TextNode')
t.equal(childNodes[2].textContent, 'Baz', '____with proper text content')
t.equal(childNodes[3].tagName, 'br', '__and last should be a <br>')
t.end()
})
// events are not supported by cheerio
test("Rendering an element with click handler", function(t) {
class ClickableComponent extends Component {
constructor(...args) {
super(...args)
this.value = 0
}
render($$) {
let el = $$('a').append('Click me')
if (this.props.method === 'instance') {
el.on('click', this.onClick)
} else if (this.props.method === 'anonymous') {
el.on('click', () => {
this.value += 10
})
}
return el
}
onClick() {
this.value += 1
}
}
// first render without a click handler
let comp = ClickableComponent.render()
comp.click()
t.equal(comp.value, 0, 'Handler should not have been triggered')
comp.value = 0
comp.setProps({method: 'instance'})
comp.click()
t.equal(comp.value, 1, 'Instance method should have been triggered')
comp.rerender()
comp.click()
t.equal(comp.value, 2, 'Rerendering should not add multiple listeners.')
comp.value = 0
comp.setProps({method: 'anonymous'})
comp.click()
t.equal(comp.value, 10, 'Anonymous handler should have been triggered')
comp.rerender()
comp.click()
t.equal(comp.value, 20, 'Rerendering should not add multiple listeners.')
t.end()
})
test("Rendering an element with once-click handler", function(t) {
class ClickableComponent extends Component {
constructor(...args) {
super(...args)
this.clicks = 0
}
render($$) {
return $$('a').append('Click me')
.on('click', this.onClick, this, { once: true })
}
onClick() {
this.clicks += 1
}
}
let comp = ClickableComponent.render()
comp.click()
t.equal(comp.clicks, 1, 'Handler should have been triggered')
comp.click()
t.equal(comp.clicks, 1, 'Handler should not have been triggered again')
t.end()
})
test("Not re-rendering an attribute should remove the attribute from the rendered element", (t) => {
class TestComponent extends Component {
getInitialState() {
return { mode: 0 }
}
render($$) {
let el = $$('div')
if (this.state.mode === 0) {
el.attr('contenteditable', true)
}
return el
}
}
let comp = TestComponent.render()
t.equal(comp.el.getAttribute('contenteditable'), 'true', 'element should be contenteditable')
comp.setState({ mode: 1})
t.isNil(comp.el.getAttribute('contenteditable'), 'the attribute should have been removed')
t.end()
})
/* ##################### Nested Elements/Components ##########################*/
test("Render children elements", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').addClass('parent')
.append($$('div').addClass('child1'))
.append($$('div').addClass('child2'))
})
t.equal(comp.el.getChildCount(), 2, 'Component should have two children.')
t.ok(comp.el.hasClass('parent'), 'Element should have class "parent".')
t.ok(comp.el.getChildAt(0).hasClass('child1'), 'First child should have class "child1".')
t.ok(comp.el.getChildAt(1).hasClass('child2'), 'Second child should have class "child2".')
t.end()
})
test("Render children components", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').append(
$$(Simple, {
children: ['a']
}),
$$(Simple, {
children: ['b']
})
)
})
t.equal(comp.getChildCount(), 2, "Component should have two children")
let first = comp.getChildAt(0)
let second = comp.getChildAt(1)
t.ok(first instanceof Simple, 'First child should be a Simple')
t.equal(first.el.textContent, 'a', '.. and should have text "a".')
t.ok(second instanceof Simple, 'Second child should be a Simple')
t.equal(second.el.textContent, 'b', '.. and should have text "b".')
t.end()
})
test("Render grandchildren elements", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').append(
$$('div').addClass('child').append(
$$('div').addClass('a'),
$$('div').addClass('b')
)
)
})
t.equal(comp.getChildCount(), 1, "Component should have 1 child")
let child = comp.getChildAt(0)
t.equal(child.getChildCount(), 2, ".. and two grandchildren")
let first = child.getChildAt(0)
let second = child.getChildAt(1)
t.ok(first.el.hasClass('a'), 'First should have class "a".')
t.ok(second.el.hasClass('b'), 'Second should have class "b".')
t.end()
})
test("Render nested elements passed via props", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').append(
$$(Simple, {
children: [
$$('div').addClass('a'),
$$('div').addClass('b')
]
})
)
})
t.equal(comp.getChildCount(), 1, "Component should have 1 child")
let child = comp.getChildAt(0)
t.equal(child.getChildCount(), 2, ".. and two grandchildren")
let first = child.getChildAt(0)
let second = child.getChildAt(1)
t.ok(first.el.hasClass('a'), 'First grandchild should have class "a".')
t.ok(second.el.hasClass('b'), 'Second grandchild should have class "b".')
t.end()
})
test("Call didMount once when mounted", function(t) {
class Parent extends TestComponent {
render($$) {
return $$('div')
.append($$(Child, {loading: true}).ref('child'))
}
didMount() {
this.refs.child.setProps({ loading: false })
}
}
class Child extends TestComponent {
render($$) {
if (this.props.loading) {
return $$('div').append('Loading...')
} else {
return $$('div').append(
$$(Simple).ref('child')
)
}
}
}
let comp = Parent.mount(getMountPoint(t))
let childComp = comp.refs.child
let grandChildComp = childComp.refs.child
t.equal(childComp.didMount.callCount, 1, "Child's didMount should have been called.")
t.notNil(grandChildComp, 'Grandchild should have been rendered')
// t.equal(grandChildComp.didMount.callCount, 1, "Grandchild's didMount should have been called too.")
comp.empty()
t.equal(childComp.dispose.callCount, 1, "Child's dispose should have been called once.")
t.equal(grandChildComp.dispose.callCount, 1, "Grandchild's dispose should have been called once.")
t.end()
})
test('Propagating properties to nested components', function(t) {
class ItemComponent extends TestComponent {
render($$) {
return $$('div').append(this.props.name)
}
}
class CompositeComponent extends TestComponent {
render($$) {
let el = $$('div').addClass('composite-component')
for (let i = 0; i < this.props.items.length; i++) {
let item = this.props.items[i]
el.append($$(ItemComponent, item))
}
return el
}
}
let comp = CompositeComponent.render({
items: [ {name: 'A'}, {name: 'B'} ]
})
t.equal(comp.getChildCount(), 2, 'Component should have two children.')
t.equal(comp.getChildAt(0).el.textContent, 'A', 'First child should have text A')
t.equal(comp.getChildAt(1).el.textContent, 'B', 'First child should have text B')
// Now we are gonna set new props
comp.setProps({
items: [ {name: 'X'}, {name: 'Y'} ]
})
t.equal(comp.getChildCount(), 2, 'Component should have two children.')
t.equal(comp.getChildAt(0).el.textContent, 'X', 'First child should have text X')
t.equal(comp.getChildAt(1).el.textContent, 'Y', 'First child should have text Y')
t.end()
})
test("Special nesting situation", function(t) {
// problem was observed in TOCPanel where components (tocEntry) are ingested via dependency-injection
// and appended to a 'div' element (tocEntries) which then was ingested into a ScrollPane.
// The order of _capturing must be determined correctly, i.e. first the ScrollPane needs to
// be captured, so that the parent of the 'div' element (tocEntries) is known.
// only then the tocEntry components can be captured.
class Parent extends TestComponent {
render($$) {
let el = $$('div')
// grandchildren wrapped into a 'div' element
let grandchildren = $$('div').append(
$$(GrandChild, { name: 'foo' }).ref('foo'),
$$(GrandChild, { name: 'bar' }).ref('bar')
)
el.append(
// grandchildren wrapper ingested into Child component
$$(Child, {
children: grandchildren
})
)
return el
}
}
class Child extends TestComponent {
render($$) {
return $$('div').append(this.props.children)
}
}
class GrandChild extends TestComponent {
render($$) {
return $$('div').append(this.props.name)
}
}
let comp = Parent.render()
let foo = comp.refs.foo
let bar = comp.refs.bar
t.notNil(foo, "Component should have a ref 'foo'.")
t.equal(foo.el.textContent, 'foo', "foo should have textContent 'foo'")
t.notNil(bar, "Component should have a ref 'bar'.")
t.equal(bar.el.textContent, 'bar', "bar should have textContent 'bar'")
t.end()
})
test("Special nesting situation II", function(t) {
class Parent extends Component {
render($$) {
return $$('div').addClass('parent').append(
$$(Child, {
children: [
$$('div').addClass('grandchild-container').append(
$$(Grandchild).ref('grandchild')
)
]
}).ref('child')
)
}
}
class Child extends Component {
render($$) {
let el = $$('div').addClass('child').append(
this.props.children
)
return el
}
}
class Grandchild extends Component {
render($$) {
return $$('div').addClass('grandchild')
}
}
let comp = Parent.render()
let child = comp.refs.child
let grandchild = comp.refs.grandchild
t.notNil(child, "Child should be referenced.")
t.notNil(grandchild, "Grandchild should be referenced.")
comp.rerender()
t.ok(child === comp.refs.child, "Child should have been retained.")
t.ok(grandchild === comp.refs.grandchild, "Grandchild should have been retained.")
t.end()
})
// TODO: this test reveals that our rendering algorithm is not able
// to preserve elements when ref'd components are passed down via props.
// In such cases, the parent already
test("Implicit retaining in 3-level nesting situation", function(t) {
class Parent extends Component {
render($$) {
// Ideally, the 'wrapper' element and Child component would be preserved automatically
// because of the ref'd component 'grandchild' passed via props.
// However, ATM the rendering algorithm does not 'know' about the existence
// of the ref'd component when rendering the top-level component.
// This would be revealed during descent when the Child component
// is rendered. A chicken egg problem: to decide to preserve the wrapper we need
// to have it rendered already. We need to rethink the rendering algorithm.
// For now, we need to ref the component which we pass the ref'd component into.
return $$('div').addClass('parent').append(
$$('div').addClass('wrapper').append(
$$(Child, {
children: [
$$(Grandchild).ref('grandchild')
]
})
// disable the next line to reveal the problem
.ref('child')
)
)
}
}
class Child extends Component {
render($$) {
let el = $$('div').addClass('child').append(
this.props.children
)
return el
}
}
class Grandchild extends Component {
render($$) {
return $$('div').addClass('grandchild')
}
}
let comp = Parent.render()
let wrapper = comp.find('.wrapper')
comp.rerender()
let wrapper2 = comp.find('.wrapper')
t.ok(wrapper.el === wrapper2.el, "wrapper element should have been retained.")
t.end()
})
test("Edge case: unused children", function(t) {
class Parent extends Component {
render($$) {
return $$('div').append(
$$(Child, {
// TODO: should this element be created at all?
children: [$$('div').ref('unused')]
})
)
}
}
class Child extends Component {
render($$) {
return $$('div')
}
}
let comp = Parent.render()
t.equal(comp.el.getChildCount(), 1, "Should have 1 child")
t.equal(comp.el.textContent, '', "textContent should be empty")
t.end()
})
test("Providing a ref'd child", function(t) {
class Parent extends Component {
render($$) {
return $$('div').append(
$$(Child, {
children: [$$(Grandchild).ref('grandchild')]
})
)
}
}
class Child extends Component {
render($$) {
return $$('div').append(this.props.children)
}
}
class Grandchild extends Component {
render($$) {
return $$('div')
}
}
let parent = Parent.render()
t.equal(parent.getChildCount(), 1, "Should have 1 child")
let child = parent.getChildAt(0)
t.equal(child.getChildCount(), 1, "Should have 1 grandchild")
let grandchild = child.getChildAt(0)
t.ok(parent.refs.grandchild === grandchild, "Grandchild should be the same as the referenced component.")
t.ok(child.props.children[0].getComponent() === grandchild, "Grandchild should be accessible via props of child.")
t.end()
})
test("Implicit retaining should not override higher-level rules", function(t) {
// If a child component has refs, itself should not be retained without
// being ref'd by the parent
class Parent extends Component {
render($$) {
// Child is not ref'd: this means the parent is not interested in keeping
// this instance on rerender
return $$('div').addClass('parent').append($$(Child))
}
}
class Child extends Component {
render($$) {
// 'foo' is ref'd, so it should be retained when rerendering on this level
let el = $$('div').addClass('child').append(
$$('div').addClass('foo').ref('foo')
)
return el
}
}
let comp = Parent.render()
let child = comp.find('.child')
t.notNil(child, "Child should exist.")
let foo = child.refs.foo
child.rerender()
t.ok(child.refs.foo === foo, "'foo' should have been retained.")
comp.rerender()
let child2 = comp.find('.child')
t.ok(child !== child2, "Child should have been renewed.")
t.ok(foo !== child2.refs.foo, "'foo' should be different as well.")
t.end()
})
test("Eventlisteners on child element", function(t) {
class Parent extends Component {
render($$) {
return $$('div').append($$(Child).ref('child'))
}
}
class Child extends Component {
constructor(...args) {
super(...args)
this.clicks = 0
}
render($$) {
return $$('a').append('Click me').on('click', this.onClick)
}
onClick() {
this.clicks++
}
}
let comp = Parent.render()
let child = comp.refs.child
child.click()
t.equal(child.clicks, 1, 'Handler should have been triggered')
comp.rerender()
child.clicks = 0
child.click()
t.equal(child.clicks, 1, 'Handler should have been triggered')
t.end()
})
/* ##################### Refs: Preserving Components ##########################*/
test("Children without a ref are not retained", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').append(
$$(Simple)
)
})
let child = comp.getChildAt(0)
comp.rerender()
let newChild = comp.getChildAt(0)
// as we did not apply a ref, the component should get rerendered from scratch
t.ok(newChild !== child, 'Child component should have been renewed.')
t.ok(newChild.el !== child.el, 'Child element should have been renewed.')
t.end()
})
test("A ref must be unique in owner scope (fail on inadvertent reuse)", function(t) {
class MyComponent extends TestComponent {
render($$) {
return $$('div')
.append($$('div').ref('foo'))
.append($$('div').ref('foo'))
}
}
t.throws(function() {
MyComponent.render()
}, "Should throw an exception when a reference id is used multiple times")
t.end()
})
test("Render a child element with ref", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').addClass('parent')
.append($$('div').addClass('child').ref('foo'))
})
t.notNil(comp.refs.foo, 'Component should have a ref "foo".')
t.ok(comp.refs.foo.hasClass('child'), 'Referenced component should have class "child".')
// check that the instance is retained after rerender
let child = comp.refs.foo
let el = child.getNativeElement()
comp.rerender()
t.ok(comp.refs.foo === child, 'Child element should have been preserved.')
t.ok(comp.refs.foo.getNativeElement() === el, 'Native element should be the same.')
t.end()
})
test("Render a child component with ref", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').append(
$$(Simple).ref('foo')
)
})
let child = comp.refs.foo
let el = child.getNativeElement()
comp.rerender()
t.ok(comp.refs.foo === child, 'Child component should have been preserved.')
t.ok(comp.refs.foo.getNativeElement() === el, 'Native element should be the same.')
t.end()
})
test("Rerendering a child component with ref triggers didUpdate()", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').append(
$$(Simple).ref('foo')
)
})
let child = comp.refs.foo
spy(child, 'didUpdate')
comp.rerender()
t.ok(child.didUpdate.callCount === 1, "child.didUpdate() should have been called once.")
t.end()
})
test("Trigger didUpdate() on children even when shouldRerender()=false", function(t) {
class Child extends Component {
shouldRerender() {
return false
}
}
let comp = TestComponent.create(function($$) {
return $$('div').append(
// change prop randomly
$$(Child, {foo: Date.now()}).ref('foo')
)
})
let child = comp.refs.foo
spy(child, 'didUpdate')
comp.rerender()
t.ok(child.didUpdate.callCount === 1, "child.didUpdate() should have been called once.")
t.end()
})
test("didUpdate() provides old props and old state", function(t) {
let oldProps = null
let oldState = null
class MyComponent extends Component {
getInitialState() {
return {
val: 1
}
}
didUpdate(_oldProps, _oldState) {
oldProps = _oldProps
oldState = _oldState
}
}
let comp = MyComponent.mount({
val: 'a'
}, getMountPoint(t))
comp.setState({ val: 2 })
t.notNil(oldProps, 'old props should have been provided')
t.equal(oldProps.val, 'a', 'old props should contain original value')
t.notNil(oldState, 'old state should have been provided')
t.equal(oldState.val, 1, 'old state should contain original value')
oldProps = null
oldState = null
comp.setProps({ val: 'b' })
t.notNil(oldProps, 'old props should have been provided')
t.equal(oldProps.val, 'a', 'old props should contain original value')
t.notNil(oldState, 'old state should have been provided')
t.equal(oldState.val, 2, 'old state should contain original value')
t.end()
})
test("Refs on grandchild elements.", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').append(
$$('div').append(
$$('div').ref(this.props.grandChildRef) // eslint-disable-line no-invalid-this
)
)
}, { grandChildRef: "foo"})
t.notNil(comp.refs.foo, "Ref 'foo' should be set.")
let foo = comp.refs.foo
comp.rerender()
t.ok(foo === comp.refs.foo, "Referenced grandchild should have been retained.")
spy(foo, 'dispose')
comp.setProps({ grandChildRef: "bar" })
t.ok(foo.dispose.callCount > 0, "Former grandchild should have been disposed.")
t.notNil(comp.refs.bar, "Ref 'bar' should be set.")
t.ok(foo !== comp.refs.bar, "Grandchild should have been recreated.")
t.end()
})
// it happened, that a grandchild component with ref was not preserved
test("Ref on grandchild component.", function(t) {
class Grandchild extends TestComponent {
render($$) {
return $$('div').append(this.props.foo)
}
}
let comp = TestComponent.create(function($$) {
let el = $$('div')
el.append(
$$('div').append(
// generating a random property making sure the grandchild gets rerendered
$$(Grandchild, { foo: String(Date.now()) }).ref('grandchild')
)
)
return el
})
t.notNil(comp.refs.grandchild, "Ref 'grandchild' should be set.")
let grandchild = comp.refs.grandchild
comp.rerender()
t.notNil(comp.refs.grandchild, "Ref 'grandchild' should be set.")
t.ok(comp.refs.grandchild === grandchild, "'grandchild' should be the same")
t.end()
})
test("Retain refs owned by parent but nested in child component.", function(t) {
// Note: the child component does not know that there is a ref
// set by the parent. Still, the component should be retained on rerender
class Child extends TestComponent {
render($$) {
return $$('div').append(
$$('div').append(this.props.children)
)
}
}
let comp = TestComponent.create(function($$) {
let el = $$('div')
el.append(
$$('div').append(
// generating a random property making sure the grandchild gets rerendered
$$(Child).ref('child').append(
$$('div').append('foo').ref('grandchild')
)
)
)
return el
})
t.notNil(comp.refs.grandchild, "Ref 'grandchild' should be set.")
let grandchild = comp.refs.grandchild
comp.rerender()
t.ok(comp.refs.grandchild === grandchild, "'grandchild' should be the same")
t.end()
})
test("Should wipe a referenced component when class changes", function(t) {
class ComponentA extends TestComponent {
render($$) {
return $$('div').addClass('component-a')
}
}
class ComponentB extends TestComponent {
render($$) {
return $$('div').addClass('component-b')
}
}
class MainComponent extends TestComponent {
render($$) {
let el = $$('div').addClass('context')
let ComponentClass
if (this.props.context ==='A') {
ComponentClass = ComponentA
} else {
ComponentClass = ComponentB
}
el.append($$(ComponentClass).ref('context'))
return el
}
}
let comp = MainComponent.render({context: 'A'})
t.ok(comp.refs.context instanceof ComponentA, 'Context should be of instance ComponentA')
comp.setProps({context: 'B'})
t.ok(comp.refs.context instanceof ComponentB, 'Context should be of instance ComponentB')
t.end()
})
test('Should store refs always on owners', function(t) {
class MyComponent extends TestComponent {
render($$) {
return $$('div').append(
$$(Simple).append(
$$('div').ref('helloComp')
).ref('simpleComp')
)
}
}
let comp = MyComponent.render(MyComponent)
t.ok(comp.refs.helloComp, 'There should stil be a ref to the helloComp element/component')
t.end()
})
test("Implicitly retain elements when grandchild elements have refs.", function(t) {
let comp = TestComponent.create(function($$) {
return $$('div').append(
$$('div').append(
$$('div').ref(this.props.grandChildRef) // eslint-disable-line no-invalid-this
)
)
}, { grandChildRef: "foo"})
let child = comp.getChildAt(0)
comp.rerender()
t.ok(child === comp.getChildAt(0), "Child should be retained.")
t.ok(child.el === comp.getChildAt(0).el, "Child element should be retained.")
t.end()
})
test("Implicitly retain elements when passing grandchild with ref.", function(t) {
class Child extends TestComponent {
render($$) {
return $$('div').append(
$$('div').append(this.props.children)
)
}
}
let comp = TestComponent.create(function($$) {
let grandchild = $$('div').ref('grandchild')
return $$('div').append(
$$(Child).append(grandchild)
)
})
let child = comp.getChildAt(0)
comp.rerender()
t.ok(child === comp.getChildAt(0), "Child should be retained.")
t.ok(child.el === comp.getChildAt(0).el, "Child element should be retained.")
t.end()
})
// In ScrollPane we provied a link into the Srollbar which accesses
// ScrollPanes ref in didUpdate()
// This is working fine when didUpdate() is called at the right time,
// i.e., when ScrollPane has been rendered already
test("Everthing should be rendered when didUpdate() is triggered.", function(t) {
let parentIsUpdated = false
class Parent extends Component {
render($$) {
return $$('div').append(
$$(Child, {parent: this}).ref('child')
)
}
}
class Child extends Component {
render($$) {
return $$('div')
}
didUpdate() {
if (this.props.parent.el) {
parentIsUpdated = true
}
}
}
let comp = Parent.render()
// didUpdate() should not have been called (no rerender)
t.notOk(parentIsUpdated, 'Initially child.didUpdate() should not have been called.')
comp.rerender()
t.ok(parentIsUpdated, 'After rerender child.didUpdate() should have access to parent.el')
t.end()
})
/* ##################### Integration tests / Issues ##########################*/
test('Preserve components when ref matches and rerender when props changed', function(t) {
class ItemComponent extends TestComponent {
shouldRerender(nextProps) {
return !isEqual(nextProps, this.props)
}
render($$) {
return $$('div').append(this.props.name)
}
}
class CompositeComponent extends TestComponent {
render($$) {
let el = $$('div').addClass('composite-component')
this.props.items.forEach(function(item) {
el.append($$(ItemComponent, {name: item.name}).ref(item.ref))
})
return el
}
}
// Initial mount
let comp = CompositeComponent.render({
items: [
{ref: 'a', name: 'A'},
{ref: 'b', name: 'B'},
{ref: 'c', name: 'C'}
]
})
let a = comp.refs.a
let b = comp.refs.b
let c = comp.refs.c
let childNodes = comp.childNodes
t.equal(childNodes.length, 3, 'Component should have 3 children.')
t.equal(childNodes[0].textContent, 'A', '.. first child should have text A')
t.equal(childNodes[1].textContent, 'B', '.. second child should have text B')
t.equal(childNodes[2].textContent, 'C', '.. third child should have text C')
comp.refs.a.render.reset()
comp.refs.b.render.reset()
// Props update that preserves some of our components, drops some others
// and adds some new
comp.setProps({
items: [
{ref: 'a', name: 'X'}, // preserved (props changed)
{ref: 'd', name: 'Y'}, // new
{ref: 'b', name: 'B'}, // preserved (same props)
{ref: 'e', name: 'Z'} // new
]
})
childNodes = comp.childNodes
t.equal(childNodes.length, 4, 'Component should now have 4 children.')
// a and b should have been preserved
t.equal(a, comp.refs.a, '.. a should be the same instance')
t.equal(b, comp.refs.b, '.. b should be the same component instance')
// c should be gone
t.equal(c.dispose.callCount, 1, '.. c should have been unmounted')
// a should have been rerendered (different props) while b should not (same props)
t.ok(a.render.callCount > 0, '.. Component a should have been rerendered')
t.equal(b.render.callCount, 0, '.. Component b should not have been rerendered')
// check content
t.equal(childNodes[0].textContent, 'X', '.. first child should have text X')
t.equal(childNodes[1].textContent, 'Y', '.. second child should have text Y')
t.equal(childNodes[2].textContent, 'B', '.. third child should have text Y')
t.equal(childNodes[3].textContent, 'Z', '.. fourth child should have text Z')
t.end()
})
// Note: this is more of an integration test, but I did not manage to isolate the error
// maybe the solution gets us closer to what actually went wrong.
// TODO: try to split into useful smaller pieces.
test("Unspecific integration test: ref'd component must be retained", function(t) {
class ComponentWithRefs extends Component {
getInitialState() {
return {contextId: 'hello'}
}
render($$) {
let el = $$('div').addClass('lc-lens lc-writer sc-controller')
let workspace = $$('div').ref('workspace').addClass('le-workspace')
workspace.append(
// Main (left column)
$$('div').ref('main').addClass("le-main").append(
$$(Simple).ref('toolbar').append($$(Simple)),
$$(Simple).ref('contentPanel').append(
$$(Simple).ref('coverEditor'),
// The full fledged document (ContainerEditor)
$$("div").ref('content').addClass('document-content').append(
$$(Simple, {
}).ref('mainEditor')
),
$$(Simple).ref('bib')
)
)
)
// Context section (right column)
workspace.append(
$$(Simple, {
}).ref(this.state.contextId)
)
el.append(workspace)
// Status bar
el.append(
$$(Simple, {}).ref('statusBar')
)
return el
}
}
let comp = ComponentWithRefs.render()
t.ok(comp.refs.contentPanel, 'There should be a ref to the contentPanel component')
comp.setState({contextId: 'foo'})
t.ok(comp.refs.contentPanel, 'There should stil be a ref to the contentPanel component')
comp.setState({contextId: 'bar'})
t.ok(comp.refs.contentPanel, 'There should stil be a ref to the contentPanel component')
comp.setState({contextId: 'baz'})
t.ok(comp.refs.contentPanel, 'There should stil be a ref to the contentPanel component')
t.end()
})
test("#312: refs should be bound to the owner, not to the parent.", function(t) {
class Child extends TestComponent {
render($$) {
return $$('div').append(this.props.children)
}
}
class Parent extends TestComponent {
render($$) {
let el = $$('div')
el.append(
$$(Child).append(
$$('div').ref('foo').append('foo')
)
)
return el
}
}
let comp = Parent.render()
t.notNil(comp.refs.foo, 'Ref should be bound to owner.')
t.equal(comp.refs.foo.text(), 'foo', 'Ref should point to the right component.')
t.end()
})
test('#635: Relocating a preserved component', function(t) {
class Parent extends TestComponent {
render($$) {
let el = $$('div')
el.append('X')
if (this.props.nested) {
el.append(
$$('div').append(
$$(Simple).ref('foo').append('Y')
)
)
} else {
el.append(
$$(Simple).ref('foo').append('Y')
)
}
el.append('Z')
return el
}
}
let comp = Parent.render()
t.equal(comp.refs.foo.getParent(), comp, "First 'foo' should be direct child.")
t.equal(comp.el.textContent, 'XYZ', "... and content should be correct.")
comp.setProps({ nested: true })
t.equal(comp.refs.foo.getParent().getParent(), comp, "Then 'foo' should be grand-child.")
t.equal(comp.el.textContent, 'XYZ', "... and content should be correct.")
comp.setProps({})
t.equal(comp.refs.foo.getParent(), comp, "At last 'foo' should be direct child again.")
t.equal(comp.el.textContent, 'XYZ', "... and content should be correct.")
t.end()
})
test("Combine props and children via append", function(t) {
class Toolbar extends TestComponent {
render($$) {
let el = $$('div').append(
$$(Simple, this.props.strong).append('Strong')
)
return el
}
}
let toolState = {strong: {active: true}}
Toolbar.render(toolState)
// the original toolState object should not have been changed
t.ok(isEqual(toolState, {strong: {active: true}}), "props object should not have been touched")
t.end()
})
test("Pass-through props and add children via append", function(t) {
class MyComponent extends TestComponent {
render($$) {
let el = $$('div').append(
$$(Simple, this.props).append('Child 1')
)
return el
}
}
let props = {foo: 'bar'}
let comp = MyComponent.render(props)
let simple = comp.getChildAt(0)
t.notNil(simple, 'Should have a child component.')
t.equal(simple.props.foo, props.foo, '.. with props past through')
t.equal(simple.props.children.length, 1, '.. with props.children having one element')
t.equal(simple.textContent, 'Child 1', '.. with correct text content')
t.end()
})
test("#1070 Disposing nested components", (t) => {
let registry = {}
class Surface extends TestComponent {
didMount() {
registry[this.props.id] = this
}
dispose() {
delete registry[this.props.id]
}
render($$) {
return $$('div').addClass('surface')
}
}
class Parent extends TestComponent {
render($$) {
let el = $$('div')
this.props.ids.forEach((id) => {
el.append($$('div').append(
$$(Surface, { id }).ref(id)
))
})
return el
}
}
let comp = Parent.mount({ ids: ['foo', 'bar'] }, getMountPoint(t))
t.equal(Object.keys(registry).length, 2, 'There should be 2 surfaces registered.')
comp.setProps({ ids: ['bar'] })
t.isNil(registry['foo'], 'Surface "foo" should have been disposed.')
t.ok(registry['bar'], '.. and surface "bar" should still be there.')
t.end()
})
}