import { Component, Env } from "../src/component/component"; import { Store, useStore, useDispatch, useGetters } from "../src/store"; import { useState } from "../src/hooks"; import { xml } from "../src/tags"; import { shallowEqual } from "../src/utils"; import { makeTestEnv, makeTestFixture, nextTick, makeDeferred } from "./helpers"; describe("connecting a component to store", () => { let fixture: HTMLElement; let env: Env; beforeEach(() => { fixture = makeTestFixture(); env = makeTestEnv(); Component.env = env; }); afterEach(() => { fixture.remove(); }); test("connecting a component works, with useStore", async () => { let nextId = 1; const state = { todos: [] }; const actions = { addTodo({ state }, msg) { state.todos.push({ msg, id: nextId++ }); } }; const store = new Store({ state, actions }); class App extends Component { static template = xml`
`; todos = useStore(state => state.todos); } (env).store = store; const app = new App(); await app.mount(fixture); expect(fixture.innerHTML).toBe("
"); await store.dispatch("addTodo", "hello"); await nextTick(); expect(fixture.innerHTML).toBe("
hello
"); }); test("can use useStore twice in a component", async () => { const state = { a: 1, b: 2 }; const actions = { doSomething({ state }) { state.a = 2; state.b = 3; } }; const store = new Store({ state, actions }); class App extends Component { static template = xml`
`; a = useStore(state => ({ value: state.a })); b = useStore(state => ({ value: state.b })); } App.prototype.__render = jest.fn(App.prototype.__render); (env).store = store; const app = new App(); await app.mount(fixture); expect(fixture.innerHTML).toBe("
12
"); expect(App.prototype.__render).toBeCalledTimes(1); await store.dispatch("doSomething"); await nextTick(); expect(fixture.innerHTML).toBe("
23
"); expect(App.prototype.__render).toBeCalledTimes(2); }); test("useStore: do not re-render if not changed", async () => { let nextId = 1; const state = { todos: [], a: 1 }; const actions = { addTodo({ state }, msg) { state.todos.push({ msg, id: nextId++ }); } }; const store = new Store({ state, actions }); class App extends Component { static template = xml`
`; todos = useStore(state => state.todos); } App.prototype.__render = jest.fn(App.prototype.__render); (env).store = store; const app = new App(); await app.mount(fixture); expect(fixture.innerHTML).toBe("
"); expect(App.prototype.__render).toBeCalledTimes(1); store.state.todos.push({ id: 3, msg: "hello" }); await nextTick(); expect(fixture.innerHTML).toBe("
hello
"); expect(App.prototype.__render).toBeCalledTimes(2); store.state.a = 2; await nextTick(); expect(fixture.innerHTML).toBe("
hello
"); expect(App.prototype.__render).toBeCalledTimes(2); }); test("connecting a component with useStore returning number", async () => { let nextId = 1; const state = { todos: [] }; const actions = { addTodo({ state }, msg) { state.todos.push({ msg, id: nextId++ }); } }; const store = new Store({ state, actions }); class App extends Component { static template = xml`
`; nbrTodos = useStore(state => ({ value: state.todos.length })); } (env).store = store; const app = new App(); await app.mount(fixture); expect(fixture.innerHTML).toBe("
0
"); await store.dispatch("addTodo", "hello"); await nextTick(); expect(fixture.innerHTML).toBe("
1
"); }); test("connecting a component with useStore returning number", async () => { let nextId = 1; const state = { todos: [], a: 1 }; const actions = { addTodo({ state }, msg) { state.todos.push({ msg, id: nextId++ }); }, incrementA({ state }) { state.a++; } }; const store = new Store({ state, actions }); class App extends Component { static template = xml`
`; nbrTodos = useStore(state => ({ value: state.todos.length }), { isEqual: shallowEqual }); } App.prototype.__render = jest.fn(App.prototype.__render); (env).store = store; const app = new App(); await app.mount(fixture); expect(fixture.innerHTML).toBe("
0
"); expect(App.prototype.__render).toBeCalledTimes(1); await store.dispatch("addTodo", "hello"); await nextTick(); expect(fixture.innerHTML).toBe("
1
"); expect(App.prototype.__render).toBeCalledTimes(2); await store.dispatch("incrementA"); await nextTick(); expect(fixture.innerHTML).toBe("
1
"); expect(App.prototype.__render).toBeCalledTimes(2); }); test("connecting a component to a local store", async () => { let nextId = 1; const state = { todos: [] }; const actions = { addTodo({ state }, msg) { state.todos.push({ msg, id: nextId++ }); } }; const store = new Store({ state, actions }); class App extends Component { static template = xml`
`; todos = useStore(state => state.todos, { store }); } const app = new App(); await app.mount(fixture); expect(fixture.innerHTML).toBe("
"); await store.dispatch("addTodo", "hello"); await nextTick(); expect(fixture.innerHTML).toBe("
hello
"); }); test("can dispatch actions from a connected component", async () => { const store = new Store({ state: { value: 1 }, actions: { inc({ state }) { state.value++; } } }); (env).store = store; class App extends Component { static template = xml`
`; storeState = useStore(state => state); dispatch = useDispatch(); } const app = new App(); await app.mount(fixture); expect(fixture.innerHTML).toBe("
1
"); fixture.querySelector("button")!.click(); await nextTick(); expect(fixture.innerHTML).toBe("
2
"); }); test("useStore can use props", async () => { const state = { todos: [{ id: 1, text: "jupiler" }, { id: 2, text: "chimay" }] }; const store = new Store({ state, actions: {} }); class TodoItem extends Component { static template = xml``; todo = useStore((state, props) => { return state.todos.find(t => t.id === props.todoId); }); } class App extends Component { static template = xml`
`; static components = { TodoItem }; state = useState({ currentId: 1 }); } (env).store = store; const app = new App(); await app.mount(fixture); expect(fixture.innerHTML).toBe("
jupiler
"); app.state.currentId = 2; await nextTick(); expect(fixture.innerHTML).toBe("
chimay
"); }); test("useStore receives props 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 }); class TodoItem extends Component { static template = xml``; todo = useStore((state, props) => { return state.todos.find(t => t.id === props.id); }); } class TodoList extends Component { static template = xml`
`; static components = { TodoItem }; todos = useStore(state => state.todos); } (env).store = store; const app = new TodoList(); await app.mount(fixture); expect(fixture.innerHTML).toBe("
jupiler
"); store.dispatch("addTodo", "hoegaarden"); await nextTick(); expect(fixture.innerHTML).toBe("
jupilerhoegaarden
"); }); test("can call useGetters to receive store getters", 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 }); class TodoItem extends Component { static template = xml`
`; getters = useGetters(); storeProps = useStore((state, props) => { const todo = state.todos.find(t => t.id === props.id); return { activeTodoText: this.getters.text(todo.id), importantTodoText: this.getters.importantTodoText() }; }); } class TodoList extends Component { static components = { TodoItem }; static template = xml`
`; todos = useStore(state => state.todos); } (env).store = store; const app = new TodoList(); await app.mount(fixture); expect(fixture.innerHTML).toBe( "
jupilerjupiler
bertinchampsjupiler
" ); }); test("connected component is updated when props are updated", async () => { class Beer extends Component { static template = xml``; beer = useStore((state, props) => state.beers[props.id]); } class App extends Component { static template = xml`
`; static components = { Beer }; state = useState({ beerId: 1 }); } const state = { beers: { 1: { name: "jupiler" }, 2: { name: "kwak" } } }; const store = new Store({ state }); (env).store = store; const app = new App(); 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 () => { class App extends Component { static template = xml`
`; // we have here a new object data = useStore(state => ({ 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(); 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 () => { class Beer extends Component { static template = xml`
taster: selected: consumed:
`; data = useStore((state, props) => ({ selected: state.beers[props.id], consumed: state.beers[state.consumedID] || null, taster: state.taster })); } class App extends Component { static template = xml`
`; static components = { Beer }; state = useState({ 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(); 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 () => { class Beer extends Component { static template = xml`
taster: selected: consumed:
`; info = useStore(function(state, props) { return { selected: state.beers[props.id], consumed: state.beers[state.consumedID] || null, taster: state.taster }; }); } class App extends Component { static template = xml`
`; static components = { Beer }; state = useState({ 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(); 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[] = []; class Child extends Component { static template = xml``; state = useStore((state, props) => { steps.push("child"); return { msg: state.msg[props.key] }; }); } class Parent extends Component { static template = xml`
`; static components = { Child }; state = useStore(state => { steps.push("parent"); return { current: state.current, isvisible: state.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(); 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 connected, part 2", async () => { const steps: string[] = []; let def = makeDeferred(); def.resolve(); class Child extends Component { static template = xml``; state = useStore((s, props) => { steps.push("child"); return { msg: s.messages[props.someId] }; }); } class Parent extends Component { static template = xml`
`; static components = { Child }; state = useStore(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(); 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 () => { let steps: string[] = []; 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 }); class TodoItem extends Component { static template = xml`
`; state = useStore((state, props) => { steps.push("item:usestore"); return { todo: state.todos[props.id] }; }); editTodo() { this.env.store.dispatch("editTodo"); } __render(f) { steps.push("item:render"); return super.__render(f); } } class TodoApp extends Component { static template = xml`
`; static components = { TodoItem }; state = useStore(state => { steps.push("app:usestore"); return { todos: state.todos }; }); __render(f) { steps.push("app:render"); return super.__render(f); } } (env).store = store; const app = new TodoApp(); await app.mount(fixture); expect(fixture.innerHTML).toBe( '
kikoou
' ); expect(steps).toEqual(["app:usestore", "app:render", "item:usestore", "item:render"]); steps = []; fixture.querySelector("button")!.click(); await nextTick(); expect(steps).toEqual(["app:usestore", "app:render", "item:usestore", "item:render"]); expect(fixture.innerHTML).toBe( '
abc
' ); }); test("connected parent/children: no rendering if child is destroyed", async () => { let steps: string[] = []; const actions = { removeTodo({ state }) { delete state.todos[1]; } }; const todos = { 1: { id: 1, title: "kikoou" } }; const state = { todos }; const store = new Store({ state, actions }); class TodoItem extends Component { static template = xml`
`; state = useStore((state, props) => { steps.push("item:usestore"); return { todo: state.todos[props.id] }; }); removeTodo() { this.env.store.dispatch("removeTodo"); } __render(f) { steps.push("item:render"); return super.__render(f); } } class TodoApp extends Component { static template = xml`
`; static components = { TodoItem }; state = useStore(state => { steps.push("app:usestore"); return { todos: state.todos }; }); __render(f) { steps.push("app:render"); return super.__render(f); } } (env).store = store; const app = new TodoApp(); await app.mount(fixture); expect(fixture.innerHTML).toBe( '
kikoou
' ); expect(steps).toEqual(["app:usestore", "app:render", "item:usestore", "item:render"]); fixture.querySelector("button")!.click(); await nextTick(); expect(steps).toEqual([ "app:usestore", "app:render", "item:usestore", "item:render", "app:usestore", "app:render" ]); expect(fixture.innerHTML).toBe('
'); }); test("connected component willpatch/patch hooks are called on store updates", async () => { const steps: string[] = []; class App extends Component { static template = xml`
`; store = useStore(s => ({ 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(); 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"]); }); test("connected child components stop listening to store when destroyed", async () => { let steps: any = []; class Child extends Component { static template = xml`
`; store = useStore(s => s); } class Parent extends Component { static template = xml`
`; static components = { Child }; state = useState({ 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(); 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 () => { class App extends Component { static template = xml`
`; store = useStore(state => state); dispatch = useDispatch(); } const state = { counter: 0 }; const actions = { inc({ state }) { return ++state.counter; } }; const store = new Store({ state, actions }); (env).store = store; const app = new App(); 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(); Component.env = env; }); 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 }); class Attachment extends Component { static template = xml`
Attachment Name:
`; attachment = useStore((state, props) => ({ name: state.attachments[props.id].name })); } class Message extends Component { static template = xml`
`; store = useStore(state => ({ attachmentIds: state.messages[10].attachmentIds })); static components = { Attachment }; state = { isAttachmentDeleted: false }; dispatch = useDispatch(); doStuff() { this.dispatch("deleteAttachment", 100); this.state.isAttachmentDeleted = true; } } (env).store = store; const message = new Message(); await message.mount(fixture); expect(fixture.innerHTML).toMatchSnapshot(); fixture.querySelector("button")!.click(); await nextTick(); expect(fixture.innerHTML).toMatchSnapshot(); }); });