store refactoring

This commit is contained in:
Géry Debongnie
2019-02-20 14:55:49 +01:00
parent 16dc33cc05
commit 264420a96b
9 changed files with 376 additions and 289 deletions
+12 -4
View File
@@ -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<INotification>): 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
};
+13
View File
@@ -30,3 +30,16 @@ document.addEventListener("DOMContentLoaded", async function() {
// For debugging purpose, we keep a reference to the root widget in odoo
(<any>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());
+218 -189
View File
@@ -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<ActionWidget>;
constructor(
services: Services,
menuInfo: MenuInfo,
actionRegistry: Registry<ActionWidget>
) {
super();
this.services = services;
this.menuInfo = menuInfo;
this.actionRegistry = actionRegistry;
}
update(nextState: Partial<State>) {
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<any>;
interface RequestParameters {
route: string;
params: { [key: string]: any };
}
function rpcMixin<T extends Type<BaseStore>>(Base: T) {
return class extends Base {
counter: number = 0;
async rpc(rpc: RPCQuery): Promise<any> {
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<T extends Type<BaseStore>>(Base: T) {
return class extends Base {
private generateID = idGenerator();
addNotification(notif: Partial<INotification>): 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<ActionWidget>;
constructor(
services: Services,
menuInfo: MenuInfo,
actionRegistry: Registry<ActionWidget>
) {
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<State>) {
Object.assign(this.state, nextState);
this.trigger("state_updated", this.state);
}
nextID = 1;
addNotification(notif: Partial<INotification>): 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<T extends ReturnType<typeof rpcMixin>>(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<any> {
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 };
}
};
}
+2 -2
View File
@@ -25,10 +25,10 @@ export class Navbar extends PureWidget<Props, {}> {
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);
}
}
+1 -1
View File
@@ -23,6 +23,6 @@ export class HomeMenu extends Widget<Props, {}> {
openMenu(app: MenuItem, event: MouseEvent) {
event.preventDefault();
this.env.dispatch("open_menu", app);
this.env.activateMenuItem(app.id);
}
}
-93
View File
@@ -1,93 +0,0 @@
import { makeTestStore, nextMicroTick } from "./helpers";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
function mockFetch(route: string, params: any): Promise<any> {
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);
});
+119
View File
@@ -0,0 +1,119 @@
import { makeTestStore, nextMicroTick } from "../helpers";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
function mockFetch(route: string, params: any): Promise<any> {
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);
});
});
+10
View File
@@ -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);
(<any>fixture).getElementsByClassName("o_title")[0].click();
expect(store.state.inHome).toBe(true);
});
+1
View File
@@ -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);