import { Component, Env } from "../../src/component/component"; import { ConnectedComponent } from "../../src/store/connected_component"; import { Store } from "../../src/store/store"; import { makeTestEnv, makeTestFixture, nextTick } 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 App extends ConnectedComponent { components = { Todo }; static mapStoreToProps(s) { return { todos: s.todos }; } } class Todo extends Component {} const state = { todos: [] }; const mutations = { addTodo({ state }, msg) { state.todos.push({ msg }); } }; const store = new Store({ state, mutations }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toMatchSnapshot(); store.commit("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 mutations = { edit({ state }, title) { state.todos[0].title = title; } }; const store = new Store({ state, mutations }); 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.commit("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 {} (env).store = new Store({}); const store = new Store({ state: { todos: [] }, mutations: { addTodo({ state }, msg) { state.todos.push({ msg }); } } }); class App extends ConnectedComponent { 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.commit("addTodo", "hello"); await nextTick(); expect(fixture.innerHTML).toMatchSnapshot(); }); 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 { 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 mutations = { addTodo({ state }, text) { state.todos.push({ text, id: nextId++ }); } }; const store = new Store({ state, mutations }); 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 { 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.commit("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 { 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( "jupilerjupilerbertinchampsjupiler" ); }); 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 { 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 mutations = { addBeer({ state }, name) { state.beers.push({ name }); } }; const state = { beers: [{ name: "jupiler" }] }; const store = new Store({ state, mutations }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("jupiler"); store.commit("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 { components = { Beer }; state = { beerId: 0 }; } const mutations = { consume({ state }, beerId) { state.consumedID = beerId; } }; const state = { beers: { 1: { name: "jupiler" } }, consumedID: null, taster: "aaron" }; const store = new Store({ state, mutations }); (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.commit("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 { components = { Beer }; state = { beerId: 0 }; } const mutations = { 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, mutations }); (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.commit("renameBeer", { beerId: 1, name: "kwak" }); await nextTick(); expect(fixture.innerHTML).toBe( "taster:aaronselected:kwak" ); store.commit("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.commit("renameBeer", { beerId: 1, name: "jupiler" }); await nextTick(); expect(fixture.innerHTML).toBe( "taster:aaronconsumed:jupiler" ); store.commit("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 Parent extends ConnectedComponent { components = { Child }; static mapStoreToProps(s) { steps.push("parent"); return { current: s.current, isvisible: s.isvisible }; } } class Child extends ConnectedComponent { static mapStoreToProps(s, props) { steps.push("child"); return { msg: s.msg[props.key] }; } } const state = { current: "a", msg: { a: "a", b: "b" } }; const mutations = { setCurrent({ state }, c) { state.current = c; } }; const store = new Store({ state, mutations }); (env).store = store; const app = new Parent(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("a"); expect(steps).toEqual(["parent", "child"]); store.commit("setCurrent", "b"); await nextTick(); expect(fixture.innerHTML).toBe("b"); expect(steps).toEqual(["parent", "child", "parent", "child"]); }); test("connected parent/children: no double rendering", async () => { const mutations = { editTodo({ state }) { state.todos[1].title = "abc"; } }; const todos = { 1: { id: 1, title: "kikoou" } }; const state = { todos }; const store = new Store({ state, mutations }); env.qweb.addTemplates(` x `); class TodoApp extends ConnectedComponent { components = { TodoItem }; static mapStoreToProps(state) { return { todos: state.todos }; } } 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.commit("editTodo"); } __render(...args) { renderCount++; return super.__render(...args); } } (env).store = store; const app = new TodoApp(env); await app.mount(fixture); expect(fixture.innerHTML).toBe( 'kikooux' ); 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( 'abcx' ); }); test("connected parent/children: no rendering if child is destroyed", async () => { const mutations = { removeTodo({ state }) { delete state.todos[1]; } }; const todos = { 1: { id: 1, title: "kikoou" } }; const state = { todos }; const store = new Store({ state, mutations }); env.qweb.addTemplates(` x `); class TodoApp extends ConnectedComponent { components = { TodoItem }; static mapStoreToProps(state) { return { todos: state.todos }; } } 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.commit("removeTodo"); } __render(...args) { renderCount++; return super.__render(...args); } } (env).store = store; const app = new TodoApp(env); await app.mount(fixture); expect(fixture.innerHTML).toBe( 'kikooux' ); 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 mutations = { setMsg({ state }, c) { state.msg = c; } }; const store = new Store({ state, mutations }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("a"); store.commit("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 { 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 { 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); expect(fixture.innerHTML).toBe("Hello, James"); await app.__updateProps({ initialRecipient: undefined }, true); 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 { components = { Message }; static defaultProps = { showMessages: true }; static mapStoreToProps = function(state, ownProps) { const thread = state.threads[ownProps.threadId]; return { thread }; }; } class App extends Component { 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 mutations = { changeMessageContent({ state }, messageId, newContent) { state.messages[messageId].content = newContent; } }; const store = new Store({ state, mutations }); (env).store = store; const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe( "100Message100101Message101" ); await app.__updateProps({ threadId: 2 }, true); expect(fixture.innerHTML).toBe("200Message200"); store.commit("changeMessageContent", 200, "UpdatedMessage200"); await nextTick(); expect(fixture.innerHTML).toBe("200UpdatedMessage200"); }); });