From 264420a96b2ba5c2f7fe598e486594d1fc19dd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Wed, 20 Feb 2019 14:55:49 +0100 Subject: [PATCH] store refactoring --- web/static/src/ts/env.ts | 16 +- web/static/src/ts/main.ts | 13 + web/static/src/ts/store.ts | 407 +++++++++++++----------- web/static/src/ts/widgets/Navbar.ts | 4 +- web/static/src/ts/widgets/home_menu.ts | 2 +- web/static/tests/store.test.ts | 93 ------ web/static/tests/store/store.test.ts | 119 +++++++ web/static/tests/widgets/navbar.test.ts | 10 + web/static/tests/widgets/root.test.ts | 1 + 9 files changed, 376 insertions(+), 289 deletions(-) delete mode 100644 web/static/tests/store.test.ts create mode 100644 web/static/tests/store/store.test.ts diff --git a/web/static/src/ts/env.ts b/web/static/src/ts/env.ts index a4012bed..fdb64f52 100644 --- a/web/static/src/ts/env.ts +++ b/web/static/src/ts/env.ts @@ -10,10 +10,13 @@ import { INotification, RPC, Services, Store } from "./store"; export interface Env extends WEnv { services: Services; - // helpers - dispatch(action: string, param?: any): void; + // commands + activateMenuItem(menuId: number): void; addNotification(notif: Partial): number; - closeNotification(id: number); + closeNotification(id: number): void; + toggleHomeMenu(): void; + + // helpers rpc: RPC; // configuration @@ -33,11 +36,16 @@ export function makeEnv(store: Store, templates: string): Env { const env: Env = { qweb, getID: idGenerator(), + services: store.services, - dispatch: store.dispatch.bind(store), + + activateMenuItem: store.activateMenuItem.bind(store), addNotification: store.addNotification.bind(store), closeNotification: store.closeNotification.bind(store), + toggleHomeMenu: store.toggleHomeMenu.bind(store), + rpc: store.rpc.bind(store), + debug: false, isMobile: window.innerWidth <= 768 }; diff --git a/web/static/src/ts/main.ts b/web/static/src/ts/main.ts index 77b58247..cb5711cc 100644 --- a/web/static/src/ts/main.ts +++ b/web/static/src/ts/main.ts @@ -30,3 +30,16 @@ document.addEventListener("DOMContentLoaded", async function() { // For debugging purpose, we keep a reference to the root widget in odoo (window).odoo.rootWidget = rootWidget; }); + +let testMixin = Base => + class extends Base { + calc() { + return 32; + } + }; + +class Foo {} + +class Bar extends testMixin(Foo) {} + +console.log(new Bar().calc()); diff --git a/web/static/src/ts/store.ts b/web/static/src/ts/store.ts index e555ede6..c9401c45 100644 --- a/web/static/src/ts/store.ts +++ b/web/static/src/ts/store.ts @@ -4,6 +4,7 @@ import { Registry } from "./core/registry"; import { RPC } from "./services/ajax"; import { IRouter, Query } from "./services/router"; import { Widget } from "./widgets/widget"; +import { idGenerator } from "./core/utils"; //------------------------------------------------------------------------------ // Types @@ -73,6 +74,51 @@ export interface ActionDescription { export type ActionInfo = ClientActionInfo | ActWindowInfo; export type ActionStack = ActionInfo[]; +//------------------------------------------------------------------------------ +// Store +//------------------------------------------------------------------------------ +class BaseStore extends EventBus { + state: State = { + stack: [], + inHome: false, + currentApp: null + }; + menuInfo: MenuInfo; + services: Services; + actionRegistry: Registry; + + constructor( + services: Services, + menuInfo: MenuInfo, + actionRegistry: Registry + ) { + super(); + this.services = services; + this.menuInfo = menuInfo; + this.actionRegistry = actionRegistry; + } + + update(nextState: Partial) { + Object.assign(this.state, nextState); + this.trigger("state_updated", this.state); + } + + toggleHomeMenu() { + if (this.state.inHome && !this.state.currentApp) { + return; + } + this.update({ inHome: !this.state.inHome }); + } +} + +export class Store extends actionManagerMixin( + rpcMixin(notificationMixin(BaseStore)) +) {} + +//------------------------------------------------------------------------------ +// RPC Mixin +//------------------------------------------------------------------------------ + export interface RPCModelQuery { model: string; method: string; @@ -90,6 +136,56 @@ export type RPCQuery = RPCModelQuery | RPCControllerQuery; export type RPC = (rpc: RPCQuery) => Promise; +interface RequestParameters { + route: string; + params: { [key: string]: any }; +} + +function rpcMixin>(Base: T) { + return class extends Base { + counter: number = 0; + + async rpc(rpc: RPCQuery): Promise { + const request = this.prepareRequest(rpc); + if (this.counter === 0) { + this.trigger("rpc_status", "loading"); + } + this.counter++; + const result = await this.services.rpc(request.route, request.params); + this.counter--; + if (this.counter === 0) { + this.trigger("rpc_status", "notloading"); + } + return result; + } + + private prepareRequest(query: RPCQuery): RequestParameters { + let route: string; + let params = "params" in query ? query.params : {}; + if ("route" in query) { + route = query.route; + } else if ("model" in query && "method" in query) { + route = `/web/dataset/call_kw/${query.model}/${query.method}`; + params.args = query.args || []; + params.model = query.model; + params.method = query.method; + params.kwargs = Object.assign(params.kwargs || {}, query.kwargs); + params.kwargs.context = + query.context || params.context || params.kwargs.context; + } else { + throw new Error("Invalid Query"); + } + + // doing this remove empty keys, and undefined stuff + const sanitizedParams = JSON.parse(JSON.stringify(params)); + return { route, params: sanitizedParams }; + } + }; +} + +//------------------------------------------------------------------------------ +// Notifications Mixin +//------------------------------------------------------------------------------ export interface INotification { id: number; title: string; @@ -98,207 +194,140 @@ export interface INotification { sticky: boolean; } -interface RequestParameters { - route: string; - params: { [key: string]: any }; +function notificationMixin>(Base: T) { + return class extends Base { + private generateID = idGenerator(); + + addNotification(notif: Partial): number { + const id = this.generateID(); + const defaultVals = { + title: "", + message: "", + type: "notification", + sticky: false + }; + const notification = Object.assign(defaultVals, notif, { id }); + this.trigger("notification_added", notification); + if (!notification.sticky) { + setTimeout(() => this.closeNotification(id), 2500); + } + return id; + } + + closeNotification(id: number) { + this.trigger("notification_closed", id); + } + }; } //------------------------------------------------------------------------------ -// Store +// Action Manager Mixin //------------------------------------------------------------------------------ -export class Store extends EventBus { - state: State; - menuInfo: MenuInfo; - services: Services; - actionRegistry: Registry; - - constructor( - services: Services, - menuInfo: MenuInfo, - actionRegistry: Registry - ) { - super(); - this.services = services; - this.menuInfo = menuInfo; - this.actionRegistry = actionRegistry; - this.state = { - stack: [], - inHome: false, - currentApp: null - }; - const query = this.services.router.getQuery(); - let { app, actionId } = this.getAppAndAction(query); - this.state.currentApp = app; - if (!actionId) { - this.state.inHome = true; - } - - this.services.router.on("query_changed", this, this.updateAction); - this.updateAction(this.services.router.getQuery()); - } - - private updateAction(query: Query) { - let { app, actionId } = this.getAppAndAction(query); - this.updateAppState(app, actionId); - } - - dispatch(action: string, params?: any) { - switch (action) { - case "open_menu": - this.updateAppState(params.app, params.actionId); - break; - case "toggle_home_menu": - this.updateState({ inHome: !this.state.inHome }); - break; - case "add_notification": - this.addNotification(params); - break; - case "close_notification": - this.closeNotification(params); - break; - } - } - - updateState(nextState: Partial) { - Object.assign(this.state, nextState); - this.trigger("state_updated", this.state); - } - - nextID = 1; - - addNotification(notif: Partial): number { - const id = this.nextID++; - const defaultVals = { - title: "", - message: "", - type: "notification", - sticky: false - }; - const notification = Object.assign(defaultVals, notif, { id }); - this.trigger("notification_added", notification); - if (!notification.sticky) { - setTimeout(() => this.closeNotification(id), 2500); - } - return id; - } - closeNotification(id: number) { - this.trigger("notification_closed", id); - } - - private updateAppState(app: MenuItem | null, actionId: number | null) { - const newApp = app || this.state.currentApp; - if (actionId) { - const query: Query = { action_id: String(actionId) }; - const menuId = newApp ? newApp.app.id : false; - if (menuId) { - query.menu_id = String(menuId); +function actionManagerMixin>(Base: T) { + return class extends Base { + constructor(...args) { + super(...args); + const query = this.services.router.getQuery(); + let { app, actionId } = this.getAppAndAction(query); + this.state.currentApp = app; + if (!actionId) { + this.state.inHome = true; } - if (app) { - this.updateState({ currentApp: app }); - } - this.services.router.navigate(query); - this.doAction(actionId); - } else { - this.updateState({ inHome: true, currentApp: newApp }); - } - } - private getAppAndAction( - query: Query - ): { app: MenuItem | null; actionId: number | null } { - const menuInfo = this.menuInfo; - let app: MenuItem | null = null; - let actionId: number | null = null; - if ("action_id" in query) { - actionId = parseInt(query.action_id, 10); - if (menuInfo.actionMap[actionId]) { - const menu = menuInfo.actionMap[actionId]!; - app = menu.app; + this.services.router.on("query_changed", this, this.updateAction); + this.updateAction(this.services.router.getQuery()); + } + + private updateAction(query: Query) { + let { app, actionId } = this.getAppAndAction(query); + this.updateAppState(app, actionId); + } + + activateMenuItem(menuId: number) { + const menu = this.menuInfo.menus[menuId]; + if (!menu) { + throw new Error("Invalid menu id"); + } + this.updateAppState(menu.app, menu.actionId); + } + + private updateAppState(app: MenuItem | null, actionId: number | null) { + const newApp = app || this.state.currentApp; + if (actionId) { + const query: Query = { action_id: String(actionId) }; + const menuId = newApp ? newApp.app.id : false; + if (menuId) { + query.menu_id = String(menuId); + } + if (app) { + this.update({ currentApp: app }); + } + this.services.router.navigate(query); + this.doAction(actionId); + } else { + this.update({ inHome: true, currentApp: newApp }); } } - if ("menu_id" in query) { - const menuId = parseInt(query.menu_id, 10); - const menu = menuInfo.menus[menuId]; - if (menu) { - app = menu.app; - if (!actionId) { - actionId = menu.actionId; + + private getAppAndAction( + query: Query + ): { app: MenuItem | null; actionId: number | null } { + const menuInfo = this.menuInfo; + let app: MenuItem | null = null; + let actionId: number | null = null; + if ("action_id" in query) { + actionId = parseInt(query.action_id, 10); + if (menuInfo.actionMap[actionId]) { + const menu = menuInfo.actionMap[actionId]!; + app = menu.app; } } - } - return { app, actionId }; - } - - doAction(request: ActionRequest) { - if (typeof request === "number") { - this.loadAction(request); - // this is an action ID - let name = request === 131 ? "discuss" : "crm"; - let title = - request === 131 ? "Discuss" : request === 250 ? "Notes" : "CRM"; - let Widget = this.actionRegistry.get(name); - this.updateState({ - inHome: false, - stack: [ - { - id: 1, - context: {}, - target: "current", - type: "client", - name, - title, - Widget: Widget + if ("menu_id" in query) { + const menuId = parseInt(query.menu_id, 10); + const menu = menuInfo.menus[menuId]; + if (menu) { + app = menu.app; + if (!actionId) { + actionId = menu.actionId; } - ] + } + } + return { app, actionId }; + } + + doAction(request: ActionRequest) { + if (typeof request === "number") { + this.loadAction(request); + // this is an action ID + let name = request === 131 ? "discuss" : "crm"; + let title = + request === 131 ? "Discuss" : request === 250 ? "Notes" : "CRM"; + let Widget = this.actionRegistry.get(name); + this.update({ + inHome: false, + stack: [ + { + id: 1, + context: {}, + target: "current", + type: "client", + name, + title, + Widget: Widget + } + ] + }); + } + } + + private loadAction(id: number) { + return this.rpc({ + route: "web/action/load", + params: { + action_id: id + } }); } - } - - private loadAction(id: number) { - this.rpc({ - route: "web/action/load", - params: { - action_id: id - } - }); - } - - counter: number = 0; - - async rpc(rpc: RPCQuery): Promise { - const request = this.prepareRequest(rpc); - if (this.counter === 0) { - this.trigger("rpc_status", "loading"); - } - this.counter++; - const result = await this.services.rpc(request.route, request.params); - this.counter--; - if (this.counter === 0) { - this.trigger("rpc_status", "notloading"); - } - return result; - } - - private prepareRequest(query: RPCQuery): RequestParameters { - let route: string; - let params = "params" in query ? query.params : {}; - if ("route" in query) { - route = query.route; - } else if ("model" in query && "method" in query) { - route = `/web/dataset/call_kw/${query.model}/${query.method}`; - params.args = query.args || []; - params.model = query.model; - params.method = query.method; - params.kwargs = Object.assign(params.kwargs || {}, query.kwargs); - params.kwargs.context = - query.context || params.context || params.kwargs.context; - } else { - throw new Error("Invalid Query"); - } - - // doing this remove empty keys, and undefined stuff - const sanitizedParams = JSON.parse(JSON.stringify(params)); - return { route, params: sanitizedParams }; - } + }; } diff --git a/web/static/src/ts/widgets/Navbar.ts b/web/static/src/ts/widgets/Navbar.ts index bc0af8d7..ef1ab483 100644 --- a/web/static/src/ts/widgets/Navbar.ts +++ b/web/static/src/ts/widgets/Navbar.ts @@ -25,10 +25,10 @@ export class Navbar extends PureWidget { toggleHome(ev: MouseEvent) { ev.preventDefault(); - this.env.dispatch("toggle_home_menu"); + this.env.toggleHomeMenu(); } openMenu(menu: MenuItem) { - this.env.dispatch("open_menu", menu); + this.env.activateMenuItem(menu.id); } } diff --git a/web/static/src/ts/widgets/home_menu.ts b/web/static/src/ts/widgets/home_menu.ts index 31cf3ea5..1fcda647 100644 --- a/web/static/src/ts/widgets/home_menu.ts +++ b/web/static/src/ts/widgets/home_menu.ts @@ -23,6 +23,6 @@ export class HomeMenu extends Widget { openMenu(app: MenuItem, event: MouseEvent) { event.preventDefault(); - this.env.dispatch("open_menu", app); + this.env.activateMenuItem(app.id); } } diff --git a/web/static/tests/store.test.ts b/web/static/tests/store.test.ts deleted file mode 100644 index eefa7b1d..00000000 --- a/web/static/tests/store.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { makeTestStore, nextMicroTick } from "./helpers"; - -//------------------------------------------------------------------------------ -// Setup and helpers -//------------------------------------------------------------------------------ - -function mockFetch(route: string, params: any): Promise { - return Promise.resolve(`${route}`); -} - -//------------------------------------------------------------------------------ -// Tests -//------------------------------------------------------------------------------ - -describe("rpc", () => { - test("properly translate query in route", async () => { - const store = makeTestStore({ rpc: mockFetch }); - const result = await store.rpc({ model: "test", method: "hey" }); - expect(result).toBe("/web/dataset/call_kw/test/hey"); - }); - - test("trigger proper events", async () => { - const store = makeTestStore({ rpc: mockFetch }); - const events: string[] = []; - store.on("rpc_status", null, s => { - events.push(s); - }); - expect(events).toEqual([]); - store.rpc({ model: "test", method: "hey" }); - expect(events).toEqual(["loading"]); - await nextMicroTick(); - expect(events).toEqual(["loading", "notloading"]); - }); -}); - -describe("notifications", () => { - test("can subscribe and add notification", () => { - let n = 0; - const store = makeTestStore(); - store.on("notification_added", null, () => n++); - store.on("notification_closed", null, () => n--); - expect(n).toBe(0); - const id = store.addNotification({ - title: "test", - message: "message" - }); - expect(n).toBe(1); - expect(id).toBeDefined(); - }); -}); - -test("can close a notification", () => { - let n = 0; - const store = makeTestStore(); - store.on("notification_added", null, () => n++); - store.on("notification_closed", null, () => n--); - - const id = store.addNotification({ title: "test", message: "message" }); - expect(n).toBe(1); - - store.closeNotification(id); - expect(n).toBe(0); -}); - -test("notifications closes themselves after a while", () => { - jest.useFakeTimers(); - let n = 0; - const store = makeTestStore(); - store.on("notification_added", null, () => n++); - store.on("notification_closed", null, () => n--); - - store.addNotification({ title: "test", message: "message" }); - - expect(setTimeout).toHaveBeenCalledTimes(1); - expect(n).toBe(1); - jest.runAllTimers(); - expect(n).toBe(0); -}); - -test("sticky notifications do not close themselves after a while", () => { - jest.useFakeTimers(); - let n = 0; - const store = makeTestStore(); - store.on("notification_added", null, () => n++); - store.on("notification_closed", null, () => n--); - - store.addNotification({ title: "test", message: "message", sticky: true }); - - expect(setTimeout).toHaveBeenCalledTimes(0); - expect(n).toBe(1); - jest.runAllTimers(); - expect(n).toBe(1); -}); diff --git a/web/static/tests/store/store.test.ts b/web/static/tests/store/store.test.ts new file mode 100644 index 00000000..5a24c012 --- /dev/null +++ b/web/static/tests/store/store.test.ts @@ -0,0 +1,119 @@ +import { makeTestStore, nextMicroTick } from "../helpers"; + +//------------------------------------------------------------------------------ +// Setup and helpers +//------------------------------------------------------------------------------ + +function mockFetch(route: string, params: any): Promise { + return Promise.resolve(`${route}`); +} + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +describe("rpc", () => { + test("properly translate query in route", async () => { + const store = makeTestStore({ rpc: mockFetch }); + const result = await store.rpc({ model: "test", method: "hey" }); + expect(result).toBe("/web/dataset/call_kw/test/hey"); + }); + + test("trigger proper events", async () => { + const store = makeTestStore({ rpc: mockFetch }); + const events: string[] = []; + store.on("rpc_status", null, s => { + events.push(s); + }); + expect(events).toEqual([]); + store.rpc({ model: "test", method: "hey" }); + expect(events).toEqual(["loading"]); + await nextMicroTick(); + expect(events).toEqual(["loading", "notloading"]); + }); +}); + +describe("notifications", () => { + test("can subscribe and add notification", () => { + let n = 0; + const store = makeTestStore(); + store.on("notification_added", null, () => n++); + store.on("notification_closed", null, () => n--); + expect(n).toBe(0); + const id = store.addNotification({ + title: "test", + message: "message" + }); + expect(n).toBe(1); + expect(id).toBeDefined(); + }); + + test("can close a notification", () => { + let n = 0; + const store = makeTestStore(); + store.on("notification_added", null, () => n++); + store.on("notification_closed", null, () => n--); + + const id = store.addNotification({ + title: "test", + message: "message" + }); + expect(n).toBe(1); + + store.closeNotification(id); + expect(n).toBe(0); + }); + + test("notifications closes themselves after a while", () => { + jest.useFakeTimers(); + let n = 0; + const store = makeTestStore(); + store.on("notification_added", null, () => n++); + store.on("notification_closed", null, () => n--); + + store.addNotification({ title: "test", message: "message" }); + + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(n).toBe(1); + jest.runAllTimers(); + expect(n).toBe(0); + }); + + test("sticky notifications do not close themselves after a while", () => { + jest.useFakeTimers(); + let n = 0; + const store = makeTestStore(); + store.on("notification_added", null, () => n++); + store.on("notification_closed", null, () => n--); + + store.addNotification({ + title: "test", + message: "message", + sticky: true + }); + + expect(setTimeout).toHaveBeenCalledTimes(0); + expect(n).toBe(1); + jest.runAllTimers(); + expect(n).toBe(1); + }); +}); + +describe("state transitions", () => { + test("toggle menu", async () => { + const store = makeTestStore(); + expect(store.state.inHome).toBe(true); + store.toggleHomeMenu(); + + // should still be in home menu since no app is currently active + expect(store.state.inHome).toBe(true); + + store.activateMenuItem(96); + expect(store.state.inHome).toBe(false); + + store.toggleHomeMenu(); + expect(store.state.inHome).toBe(true); + store.toggleHomeMenu(); + expect(store.state.inHome).toBe(false); + }); +}); diff --git a/web/static/tests/widgets/navbar.test.ts b/web/static/tests/widgets/navbar.test.ts index a76fcbea..c7d06cc4 100644 --- a/web/static/tests/widgets/navbar.test.ts +++ b/web/static/tests/widgets/navbar.test.ts @@ -62,3 +62,13 @@ test("mobile mode: navbar is different", async () => { await navbar.mount(fixture); expect(fixture.innerHTML).toMatchSnapshot(); }); + +test("clicking on left icon toggle home menu ", async () => { + props.app = menuInfo.menus[96]!; + store.state.inHome = false; + const navbar = new Navbar(env, props); + await navbar.mount(fixture); + expect(store.state.inHome).toBe(false); + (fixture).getElementsByClassName("o_title")[0].click(); + expect(store.state.inHome).toBe(true); +}); diff --git a/web/static/tests/widgets/root.test.ts b/web/static/tests/widgets/root.test.ts index e4d3e172..bcc8b504 100644 --- a/web/static/tests/widgets/root.test.ts +++ b/web/static/tests/widgets/root.test.ts @@ -41,6 +41,7 @@ test("if url has action_id, will render action and navigate to proper menu_id", const router = new helpers.MockRouter({ action_id: "595" }); store = helpers.makeTestStore({ router }); env = makeEnv(store, templates); + await helpers.nextTick(); const root = new Root(env, store); await root.mount(fixture);