import { Component, Env } from "../../src/component/component"; import { ConnectedComponent } from "../../src/store/connected_component"; import { Store } from "../../src/store/store"; import { makeTestEnv, makeTestFixture, nextTick, makeDeferred } from "../helpers"; describe("connecting a component to store", () => { let fixture: HTMLElement; let env: Env; beforeEach(() => { fixture = makeTestFixture(); env = makeTestEnv(); }); afterEach(() => { fixture.remove(); }); test("connecting a component works", async () => { env.qweb.addTemplates(`
`); class Todo extends Component {} class App extends ConnectedComponent { static components = { Todo }; static mapStoreToProps(s) { return { todos: s.todos }; } } const state = { todos: [] }; const actions = { addTodo({ state }, msg) { state.todos.push({ msg }); } }; const store = new Store({ state, actions }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toMatchSnapshot(); store.dispatch("addTodo", "hello"); await nextTick(); expect(fixture.innerHTML).toMatchSnapshot(); }); test("deep and shallow connecting a component", async () => { env.qweb.addTemplates(`
`); const state = { todos: [{ title: "Kasteel" }] }; const actions = { edit({ state }, title) { state.todos[0].title = title; } }; const store = new Store({ state, actions }); class App extends ConnectedComponent { static mapStoreToProps(s) { return { todos: s.todos }; } } class DeepTodoApp extends App { deep = true; } class ShallowTodoApp extends App { deep = false; } (env).store = store; const deepTodoApp = new DeepTodoApp(env); const shallowTodoApp = new ShallowTodoApp(env); await deepTodoApp.mount(fixture); const shallowFix = makeTestFixture(); await shallowTodoApp.mount(shallowFix); expect(fixture.innerHTML).toMatchSnapshot(); expect(shallowFix.innerHTML).toMatchSnapshot(); store.dispatch("edit", "Bertinchamps"); await nextTick(); expect(fixture.innerHTML).toMatchSnapshot(); expect(shallowFix.innerHTML).toMatchSnapshot(); }); test("connecting a component to a local store", async () => { env.qweb.addTemplates(`
`); class Todo extends Component {} const store = new Store({ state: { todos: [] }, actions: { addTodo({ state }, msg) { state.todos.push({ msg }); } } }); class App extends ConnectedComponent { static components = { Todo }; static mapStoreToProps(s) { return { todos: s.todos }; } getStore() { return store; } } const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toMatchSnapshot(); (app.__owl__).store.dispatch("addTodo", "hello"); await nextTick(); expect(fixture.innerHTML).toMatchSnapshot(); }); test("can dispatch actions from a connected component", async () => { env.qweb.addTemplates(`
`); const store = new Store({ state: { value: 1 }, actions: { inc({ state }) { state.value++; } } }); (env).store = store; class App extends ConnectedComponent { static mapStoreToProps(s) { return { value: s.value }; } } const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
1
"); const button = (app.el).getElementsByTagName("button")[0]; await button.click(); await nextTick(); expect(fixture.innerHTML).toBe("
2
"); }); test("connected child components with custom hooks", async () => { let steps: any = []; env.qweb.addTemplates(`
`); class Child extends ConnectedComponent { static mapStoreToProps(s) { return s; } mounted() { steps.push("child:mounted"); } willUnmount() { steps.push("child:willUnmount"); } } class Parent extends Component { static components = { Child }; constructor(env: Env) { super(env); this.state = { child: true }; } } const store = new Store({ state: {} }); (env).store = store; const parent = new Parent(env); await parent.mount(fixture); expect(steps).toEqual(["child:mounted"]); parent.state.child = false; await nextTick(); expect(steps).toEqual(["child:mounted", "child:willUnmount"]); }); test("mapStoreToProps receives ownprops as second argument", async () => { const state = { todos: [{ id: 1, text: "jupiler" }] }; let nextId = 2; const actions = { addTodo({ state }, text) { state.todos.push({ text, id: nextId++ }); } }; const store = new Store({ state, actions }); env.qweb.addTemplates(`
`); class TodoItem extends ConnectedComponent { static mapStoreToProps(state, props) { const todo = state.todos.find(t => t.id === props.id); return todo; } } class TodoList extends ConnectedComponent { static components = { TodoItem }; static mapStoreToProps(state) { return { todos: state.todos }; } } (env).store = store; const app = new TodoList(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
jupiler
"); store.dispatch("addTodo", "hoegaarden"); await nextTick(); expect(fixture.innerHTML).toBe("
jupilerhoegaarden
"); }); test("mapStoreToProps receives store getters as third argument", async () => { const state = { importantID: 1, todos: [{ id: 1, text: "jupiler" }, { id: 2, text: "bertinchamps" }] }; const getters = { importantTodoText({ state }) { return state.todos.find(todo => todo.id === state.importantID).text; }, text({ state }, id) { return state.todos.find(todo => todo.id === id).text; } }; const store = new Store({ state, getters }); env.qweb.addTemplates(`
`); class TodoItem extends ConnectedComponent { static mapStoreToProps(state, props, getters) { const todo = state.todos.find(t => t.id === props.id); return { activeTodoText: getters.text(todo.id), importantTodoText: getters.importantTodoText() }; } } class TodoList extends ConnectedComponent { static components = { TodoItem }; static mapStoreToProps(state) { return { todos: state.todos }; } } (env).store = store; const app = new TodoList(env); await app.mount(fixture); expect(fixture.innerHTML).toBe( "
jupilerjupiler
bertinchampsjupiler
" ); }); test("connected component is updated when props are updated", async () => { env.qweb.addTemplates(`
`); class Beer extends ConnectedComponent { static mapStoreToProps(state, props) { return state.beers[props.id]; } } class App extends Component { static components = { Beer }; state = { beerId: 1 }; } const state = { beers: { 1: { name: "jupiler" }, 2: { name: "kwak" } } }; const store = new Store({ state }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
jupiler
"); app.state.beerId = 2; await nextTick(); expect(fixture.innerHTML).toBe("
kwak
"); }); test("connected component is updated when store is changed", async () => { env.qweb.addTemplates(`
`); class App extends ConnectedComponent { static mapStoreToProps(state) { return { beers: state.beers, otherKey: 1 }; } } const actions = { addBeer({ state }, name) { state.beers.push({ name }); } }; const state = { beers: [{ name: "jupiler" }] }; const store = new Store({ state, actions }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
jupiler
"); store.dispatch("addBeer", "kwak"); await nextTick(); expect(fixture.innerHTML).toBe("
jupilerkwak
"); }); test("connected component with undefined, null and string props", async () => { env.qweb.addTemplates(`
taster: selected: consumed:
`); class Beer extends ConnectedComponent { static mapStoreToProps(state, props) { return { selected: state.beers[props.id], consumed: state.beers[state.consumedID] || null, taster: state.taster }; } } class App extends Component { static components = { Beer }; state = { beerId: 0 }; } const actions = { consume({ state }, beerId) { state.consumedID = beerId; } }; const state = { beers: { 1: { name: "jupiler" } }, consumedID: null, taster: "aaron" }; const store = new Store({ state, actions }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
taster:aaron
"); app.state.beerId = 1; await nextTick(); expect(fixture.innerHTML).toBe( "
taster:aaronselected:jupiler
" ); store.dispatch("consume", 1); await nextTick(); expect(fixture.innerHTML).toBe( "
taster:aaronselected:jupilerconsumed:jupiler
" ); app.state.beerId = 0; await nextTick(); expect(fixture.innerHTML).toBe( "
taster:aaronconsumed:jupiler
" ); }); test("connected component deeply reactive with undefined, null and string props", async () => { env.qweb.addTemplates(`
taster: selected: consumed:
`); class Beer extends ConnectedComponent { static mapStoreToProps(storeState, props) { return { selected: storeState.beers[props.id], consumed: storeState.beers[storeState.consumedID] || null, taster: storeState.taster }; } } class App extends Component { static components = { Beer }; state = { beerId: 0 }; } const actions = { changeTaster({ state }, newTaster) { state.taster = newTaster; }, consume({ state }, beerId) { state.consumedID = beerId; }, renameBeer({ state }, { beerId, name }) { state.beers[beerId].name = name; } }; const state = { beers: { 1: { name: "jupiler" } }, consumedID: null, taster: "aaron" }; const store = new Store({ state, actions }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
taster:aaron
"); app.state.beerId = 1; await nextTick(); expect(fixture.innerHTML).toBe( "
taster:aaronselected:jupiler
" ); store.dispatch("renameBeer", { beerId: 1, name: "kwak" }); await nextTick(); expect(fixture.innerHTML).toBe( "
taster:aaronselected:kwak
" ); store.dispatch("consume", 1); await nextTick(); expect(fixture.innerHTML).toBe( "
taster:aaronselected:kwakconsumed:kwak
" ); app.state.beerId = 0; await nextTick(); expect(fixture.innerHTML).toBe( "
taster:aaronconsumed:kwak
" ); store.dispatch("renameBeer", { beerId: 1, name: "jupiler" }); await nextTick(); expect(fixture.innerHTML).toBe( "
taster:aaronconsumed:jupiler
" ); store.dispatch("changeTaster", "matthieu"); await nextTick(); expect(fixture.innerHTML).toBe( "
taster:matthieuconsumed:jupiler
" ); }); test("correct update order when parent/children are connected", async () => { const steps: string[] = []; env.qweb.addTemplates(`
`); class Child extends ConnectedComponent { static mapStoreToProps(s, props) { steps.push("child"); return { msg: s.msg[props.key] }; } } class Parent extends ConnectedComponent { static components = { Child }; static mapStoreToProps(s) { steps.push("parent"); return { current: s.current, isvisible: s.isvisible }; } } const state = { current: "a", msg: { a: "a", b: "b" } }; const actions = { setCurrent({ state }, c) { state.current = c; } }; const store = new Store({ state, actions }); (env).store = store; const app = new Parent(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
a
"); expect(steps).toEqual(["parent", "child"]); store.dispatch("setCurrent", "b"); await nextTick(); expect(fixture.innerHTML).toBe("
b
"); expect(steps).toEqual(["parent", "child", "parent", "child"]); }); test("correct update order when parent/children are connectedddd", async () => { const steps: string[] = []; let def = makeDeferred(); def.resolve(); env.qweb.addTemplates(`
`); class Child extends ConnectedComponent { static mapStoreToProps(s, props) { steps.push("child"); return { msg: s.messages[props.someId] }; } } class Parent extends ConnectedComponent { static components = { Child }; static mapStoreToProps(s) { steps.push("parent"); return { flag: s.flag, someId: s.someId }; } async render(force) { await def; return super.render(force); } } const state = { someId: 1, flag: true, messages: { 1: "abc" } }; const actions = { setFlagToFalse({ state }) { state.flag = false; } }; const store = new Store({ state, actions }); (env).store = store; const app = new Parent(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
abc
"); expect(steps).toEqual(["parent", "child"]); def = makeDeferred(); store.dispatch("setFlagToFalse"); await nextTick(); expect(fixture.innerHTML).toBe("
abc
"); expect(steps).toEqual(["parent", "child", "parent"]); def.resolve(); await nextTick(); expect(steps).toEqual(["parent", "child", "parent"]); expect(fixture.innerHTML).toBe("
"); }); test("connected parent/children: no double rendering", async () => { const actions = { editTodo({ state }) { state.todos[1].title = "abc"; } }; const todos = { 1: { id: 1, title: "kikoou" } }; const state = { todos }; const store = new Store({ state, actions }); env.qweb.addTemplates(`
`); let renderCount = 0; let fCount = 0; class TodoItem extends ConnectedComponent { state = { isEditing: false }; static mapStoreToProps(state, ownProps) { fCount++; return { todo: state.todos[ownProps.id] }; } editTodo() { this.env.store.dispatch("editTodo"); } __render(...args) { renderCount++; return super.__render(...args); } } class TodoApp extends ConnectedComponent { static components = { TodoItem }; static mapStoreToProps(state) { return { todos: state.todos }; } } (env).store = store; const app = new TodoApp(env); await app.mount(fixture); expect(fixture.innerHTML).toBe( '
kikoou
' ); expect(renderCount).toBe(1); expect(fCount).toBe(1); fixture.querySelector("button")!.click(); await nextTick(); expect(renderCount).toBe(2); expect(fCount).toBe(2); expect(fixture.innerHTML).toBe( '
abc
' ); }); test("connected parent/children: no rendering if child is destroyed", async () => { const actions = { removeTodo({ state }) { delete state.todos[1]; } }; const todos = { 1: { id: 1, title: "kikoou" } }; const state = { todos }; const store = new Store({ state, actions }); env.qweb.addTemplates(`
`); let renderCount = 0; let fCount = 0; class TodoItem extends ConnectedComponent { state = { isEditing: false }; static mapStoreToProps(state, ownProps) { fCount++; return { todo: state.todos[ownProps.id] }; } removeTodo() { this.env.store.dispatch("removeTodo"); } __render(...args) { renderCount++; return super.__render(...args); } } class TodoApp extends ConnectedComponent { static components = { TodoItem }; static mapStoreToProps(state) { return { todos: state.todos }; } } (env).store = store; const app = new TodoApp(env); await app.mount(fixture); expect(fixture.innerHTML).toBe( '
kikoou
' ); expect(renderCount).toBe(1); expect(fCount).toBe(1); fixture.querySelector("button")!.click(); await nextTick(); expect(renderCount).toBe(1); expect(fCount).toBe(1); expect(fixture.innerHTML).toBe('
'); }); test("connected component willpatch/patch hooks are called on store updates", async () => { const steps: string[] = []; env.qweb.addTemplates(`
`); class App extends ConnectedComponent { static mapStoreToProps(s) { return { msg: s.msg }; } willPatch() { steps.push("willpatch"); } patched() { steps.push("patched"); } } const state = { msg: "a" }; const actions = { setMsg({ state }, c) { state.msg = c; } }; const store = new Store({ state, actions }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
a
"); store.dispatch("setMsg", "b"); await nextTick(); expect(fixture.innerHTML).toBe("
b
"); expect(steps).toEqual(["willpatch", "patched"]); }); }); describe("connected components and default values", () => { let fixture: HTMLElement; let env: Env; beforeEach(() => { fixture = makeTestFixture(); env = makeTestEnv(); }); afterEach(() => { fixture.remove(); }); test("can set default values", async () => { env.qweb.addTemplates(`
Hello,
`); class Greeter extends ConnectedComponent { static defaultProps = { recipient: "John" }; } class App extends Component { static components = { Greeter }; } const store = new Store({ state: {} }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
Hello, John
"); }); test("can set default values", async () => { env.qweb.addTemplates(`
Hello,
`); class Greeter extends ConnectedComponent { static defaultProps = { recipient: "John" }; } class App extends Component { static components = { Greeter }; } const store = new Store({ state: {} }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
Hello, John
"); await app.__updateProps({ initialRecipient: "James" }, true); await app.render(); expect(fixture.innerHTML).toBe("
Hello, James
"); await app.__updateProps({ initialRecipient: undefined }, true); await app.render(); expect(fixture.innerHTML).toBe("
Hello, John
"); }); test("can set default values (v2)", async () => { env.qweb.addTemplates(`
`); class Message extends ConnectedComponent { static defaultProps = { showId: true }; static mapStoreToProps = function(state, ownProps) { return { message: state.messages[ownProps.messageId] }; }; } class Thread extends ConnectedComponent { static components = { Message }; static defaultProps = { showMessages: true }; static mapStoreToProps = function(state, ownProps) { const thread = state.threads[ownProps.threadId]; return { thread }; }; } class App extends Component { static components = { Thread }; static defaultProps = { threadId: 1 }; } const state = { threads: { 1: { messages: [100, 101] }, 2: { messages: [200] } }, messages: { 100: { content: "Message100" }, 101: { content: "Message101" }, 200: { content: "Message200" } } }; const actions = { changeMessageContent({ state }, messageId, newContent) { state.messages[messageId].content = newContent; } }; const store = new Store({ state, actions }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe( "
100Message100
101Message101
" ); await app.__updateProps({ threadId: 2 }, true); await app.render(); expect(fixture.innerHTML).toBe("
200Message200
"); store.dispatch("changeMessageContent", 200, "UpdatedMessage200"); await nextTick(); expect(fixture.innerHTML).toBe("
200UpdatedMessage200
"); }); test("connected child components stop listening to store when destroyed", async () => { let steps: any = []; env.qweb.addTemplates(`
`); class Child extends ConnectedComponent { static mapStoreToProps(s) { return s; } } class Parent extends Component { static components = { Child }; state = { child: true }; } class TestStore extends Store { on(eventType, owner, callback) { steps.push(`on:${eventType}`); super.on(eventType, owner, callback); } off(eventType, owner) { steps.push(`off:${eventType}`); super.off(eventType, owner); } } const store = new TestStore({ state: { val: 1 } }); (env).store = store; const parent = new Parent(env); await parent.mount(fixture); expect(steps).toEqual(["on:update"]); expect(fixture.innerHTML).toBe("
1
"); parent.state.child = false; await nextTick(); expect(fixture.innerHTML).toBe("
"); expect(steps).toEqual(["on:update", "off:update"]); }); test("dispatch an action", async () => { env.qweb.addTemplates(`
`); class App extends ConnectedComponent { static mapStoreToProps = function(state) { return { counter: state.counter }; }; } const state = { counter: 0 }; const actions = { inc({ state }) { return ++state.counter; } }; const store = new Store({ state, actions }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
0
"); const res = app.dispatch("inc"); expect(res).toBe(1); await nextTick(); expect(fixture.innerHTML).toBe("
1
"); }); }); describe("various scenarios", () => { let fixture: HTMLElement; let env: Env; beforeEach(() => { fixture = makeTestFixture(); env = makeTestEnv(); }); afterEach(() => { fixture.remove(); }); test("scenarios with async store updates and some components events", async () => { const actions = { async deleteAttachment({ state }) { await Promise.resolve(); delete state.attachments[100]; state.messages[10].attachmentIds = []; } }; const state = { attachments: { 100: { id: 100, name: "text.txt" } }, messages: { 10: { attachmentIds: [100], id: 10 } } }; const store = new Store({ actions, state }); env.qweb.addTemplates(`
Attachment Name:
`); class Attachment extends ConnectedComponent { static mapStoreToProps(state, ownProps) { return { name: state.attachments[ownProps.id].name }; } } class Message extends ConnectedComponent { static mapStoreToProps(state) { return { attachmentIds: state.messages[10].attachmentIds }; } static components = { Attachment }; state = { isAttachmentDeleted: false }; doStuff() { this.dispatch("deleteAttachment", 100); this.state.isAttachmentDeleted = true; } } (env).store = store; const message = new Message(env); await message.mount(fixture); expect(fixture.innerHTML).toMatchSnapshot(); fixture.querySelector("button")!.click(); await nextTick(); expect(fixture.innerHTML).toMatchSnapshot(); }); });