diff --git a/web/static/src/ts/core/Widget.ts b/web/static/src/ts/core/Widget.ts index 9c370b5c..9164f9ee 100644 --- a/web/static/src/ts/core/Widget.ts +++ b/web/static/src/ts/core/Widget.ts @@ -7,6 +7,7 @@ import { QWeb } from "./qweb_vdom"; //------------------------------------------------------------------------------ // Types/helpers //------------------------------------------------------------------------------ + export interface WEnv { qweb: QWeb; getID(): number; @@ -35,6 +36,7 @@ const patch = init([sdListeners, sdAttrs]); //------------------------------------------------------------------------------ // Widget //------------------------------------------------------------------------------ + export class Widget { __widget__: Meta; name: string = "widget"; @@ -53,7 +55,7 @@ export class Widget { // Lifecycle //-------------------------------------------------------------------------- - constructor(parent: Widget | T, props?: Props) { + constructor(parent: Widget | T, props?: Props) { wl.push(this); // is this a good idea? // Pro: if props is empty, we can create easily a widget diff --git a/web/static/src/ts/core/event_bus.ts b/web/static/src/ts/core/event_bus.ts index 341f27f8..ceac65a3 100644 --- a/web/static/src/ts/core/event_bus.ts +++ b/web/static/src/ts/core/event_bus.ts @@ -20,6 +20,7 @@ export interface Subscription { //------------------------------------------------------------------------------ // EventBus //------------------------------------------------------------------------------ + export class EventBus { private subscriptions: { [eventType: string]: Subscription[] } = {}; diff --git a/web/static/src/ts/core/qweb_vdom.ts b/web/static/src/ts/core/qweb_vdom.ts index ab0cc440..11b371bd 100644 --- a/web/static/src/ts/core/qweb_vdom.ts +++ b/web/static/src/ts/core/qweb_vdom.ts @@ -17,6 +17,7 @@ const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in //------------------------------------------------------------------------------ // Compilation Context //------------------------------------------------------------------------------ + export class Context { nextID: number = 1; code: string[] = []; @@ -98,6 +99,7 @@ export class Context { //------------------------------------------------------------------------------ // QWeb rendering engine //------------------------------------------------------------------------------ + export class QWeb { rawTemplates: { [name: string]: RawTemplate } = {}; parsedTemplates: { [name: string]: ParsedTemplate } = {}; @@ -461,6 +463,7 @@ export class QWeb { //------------------------------------------------------------------------------ // QWeb Directives //------------------------------------------------------------------------------ + interface CompilationInfo { nodeID?: number; node: Element; diff --git a/web/static/src/ts/main.ts b/web/static/src/ts/main.ts index 11fd908c..ddfe2c47 100644 --- a/web/static/src/ts/main.ts +++ b/web/static/src/ts/main.ts @@ -1,19 +1,23 @@ /// +import { debounce } from "./core/utils"; import { makeEnvironment } from "./env"; import { registry } from "./registry"; -import { Discuss } from "./widgets/discuss"; import { Root } from "./root"; -import { debounce } from "./core/utils"; +import { CRM } from "./widgets/crm"; +import { Discuss } from "./widgets/discuss"; //------------------------------------------------------------------------------ // Prepare application registry //------------------------------------------------------------------------------ + registry.add("action", "discuss", Discuss); +registry.add("action", "crm", CRM); //------------------------------------------------------------------------------ // Application bootstrapping //------------------------------------------------------------------------------ + document.addEventListener("DOMContentLoaded", async function() { const env = makeEnvironment(); const rootWidget = new Root(env); diff --git a/web/static/src/ts/registry.ts b/web/static/src/ts/registry.ts index 8fa771e7..d379df0f 100644 --- a/web/static/src/ts/registry.ts +++ b/web/static/src/ts/registry.ts @@ -23,6 +23,10 @@ export class Registry { this.registries[type][name] = action; return this; } + + getAction(name: string): ActionWidget { + return this.registries.action[name]; + } } //------------------------------------------------------------------------------ diff --git a/web/static/src/ts/root.ts b/web/static/src/ts/root.ts index 70ce1c22..885d7649 100644 --- a/web/static/src/ts/root.ts +++ b/web/static/src/ts/root.ts @@ -1,80 +1,70 @@ import { Widget } from "./core/widget"; import { Env } from "./env"; +import { ActionStack } from "./services/action_manager"; import { INotification } from "./services/notifications"; import { Action } from "./widgets/action"; import { HomeMenu } from "./widgets/home_menu"; import { Navbar } from "./widgets/navbar"; import { Notification } from "./widgets/notification"; -const template = ` -
- - - - - - - -
- - - -
-
-`; +//------------------------------------------------------------------------------ +// Types +//------------------------------------------------------------------------------ interface State { notifications: INotification[]; + stack: ActionStack; inMenu: boolean; } +//------------------------------------------------------------------------------ +// Root Widget +//------------------------------------------------------------------------------ + +const template = ` +
+ + + + + + + +
+ + + +
+
+`; + export class Root extends Widget { name = "root"; template = template; widgets = { Navbar, Notification, HomeMenu, Action }; - content: Widget | null = null; state: State = { notifications: [], + stack: [], inMenu: false }; constructor(env: Env) { super(env); - this.toggleHomeMenu = this.toggleHomeMenu.bind(this); + this.toggleHome = this.toggleHome.bind(this); } mounted() { - // this.env.actionManager.on("action_ready", this, this.setContentWidget); - this.env.notifications.on("notification_added", this, this.addNotif); - this.env.notifications.on("notification_removed", this, this.removeNotif); - - // const actionWidget = this.env.actionManager.getCurrentAction(); - // if (actionWidget) { - // this.setContentWidget(actionWidget); - // } + this.env.notifications.on("notifications_updated", this, notifs => + this.updateState({ notifications: notifs }) + ); + this.env.actionManager.on("action_stack_updated", this, stack => + this.updateState({ stack }) + ); + this.env.actionManager.activate(); } - // async setContentWidget(actionWidget: ActionWidget) { - // const currentWidget = this.content; - // const newWidget = new actionWidget.Widget(this, actionWidget.props); - // await newWidget.mount(this.refs.content); - // if (currentWidget) { - // currentWidget.destroy(); - // } - // this.content = newWidget; - // } - - addNotif(notif: INotification) { - const notifications = this.state.notifications.concat(notif); - this.updateState({ notifications }); - } - removeNotif(notif: INotification) { - const notifs = this.state.notifications.filter(f => f.id !== notif.id); - this.updateState({ notifications: notifs }); - } - - toggleHomeMenu() { + toggleHome() { this.updateState({ inMenu: !this.state.inMenu }); } } diff --git a/web/static/src/ts/services/action_manager.ts b/web/static/src/ts/services/action_manager.ts index 6fa5595f..26ca679a 100644 --- a/web/static/src/ts/services/action_manager.ts +++ b/web/static/src/ts/services/action_manager.ts @@ -1,47 +1,50 @@ -import { Callback, EventBus } from "../core/event_bus"; +import { EventBus } from "../core/event_bus"; import { Widget } from "../core/widget"; import { Env } from "../env"; import { Registry } from "../registry"; import { Type } from "../types"; -import { CRM } from "../widgets/crm"; -import { Discuss } from "../widgets/discuss"; import { IRouter, Query } from "./router"; //------------------------------------------------------------------------------ // Types //------------------------------------------------------------------------------ -export interface ClientAction { +export type ActionRequest = string | number; + +export type Context = { [key: string]: any }; + +export interface CommonActionInfo { + id: number; + context: Context; + title: string; + target: "current" | "new"; +} + +export interface ClientActionInfo extends CommonActionInfo { type: "client"; name: string; + Widget: Type>; } -export interface ActWindowAction { +export interface ActWindowInfo extends CommonActionInfo { type: "act_window"; - views: string[]; + view: string; } -export type ActionEvent = "action_ready"; +export type ActionInfo = ClientActionInfo | ActWindowInfo; +export type ActionStack = ActionInfo[]; + +export type ActionEvent = "action_stack_updated"; + +type Callback = (stack: ActionStack) => void; export interface IActionManager { - doAction(actionID: number): void; + activate(): void; + doAction(request: ActionRequest): void; on(event: ActionEvent, owner: any, callback: Callback): void; - getCurrentAction(): ActionWidget | null; + getStack(): ActionStack; } -export type Action = ClientAction | ActWindowAction; - -export interface ActionWidget { - id: number; - Widget: Type>; - props: any; -} - -const actions: any[] = [ - { id: 1, title: "Discuss", Widget: Discuss, default: true }, - { id: 2, title: "CRM", Widget: CRM } -]; - //------------------------------------------------------------------------------ // Action Manager //------------------------------------------------------------------------------ @@ -49,49 +52,47 @@ const actions: any[] = [ export class ActionManager extends EventBus implements IActionManager { router: IRouter; registry: Registry; - currentAction: ActionWidget | null = null; + stack: ActionStack; constructor(router: IRouter, registry: Registry) { super(); this.router = router; this.registry = registry; - const query = this.router.getQuery(); - this.update(query); + this.stack = []; + } + + activate() { this.router.on("query_changed", this, this.update); - if (!this.currentAction) { - const action = actions.find(a => a.default); - if (action) { - this.doAction(action.id); - } + this.update(this.router.getQuery()); + } + doAction(request: ActionRequest) { + if (typeof request === "number") { + // this is an action ID + let name = request === 1 ? 'discuss' : 'crm'; + let title = request === 1 ? 'Discuss': 'CRM'; + let Widget = this.registry.getAction(name); + this.stack = [ + { + id: 1, + context: {}, + target: "current", + type: "client", + name, + title, + Widget: Widget + } + ]; + this.trigger("action_stack_updated", this.stack); } } + getStack(): ActionStack { + return []; + } + update(query: Query) { - const initialAction = this.currentAction; if ("action_id" in query) { const actionID = parseInt(query.action_id); this.doAction(actionID); } - if (this.currentAction && initialAction !== this.currentAction) { - } } - - doAction(actionID: number) { - const action = actions.find(a => a.id === actionID); - if (action) { - this.currentAction = { - id: action.id, - Widget: action.Widget, - props: {} - }; - } - this.trigger("action_ready", this.currentAction); - this.router.navigate({ action_id: String(actionID) }); - } - - registerAction(action: Action) {} - - getCurrentAction(): ActionWidget | null { - return this.currentAction; - } -} diff --git a/web/static/src/ts/services/ajax.ts b/web/static/src/ts/services/ajax.ts index db888e42..c1fee209 100644 --- a/web/static/src/ts/services/ajax.ts +++ b/web/static/src/ts/services/ajax.ts @@ -1,3 +1,7 @@ +//------------------------------------------------------------------------------ +// Types +//------------------------------------------------------------------------------ + export interface RPCQuery { model: string; method: string; @@ -7,6 +11,10 @@ export interface IAjax { rpc(rpc: RPCQuery): Promise; } +//------------------------------------------------------------------------------ +// Ajax +//------------------------------------------------------------------------------ + export class Ajax implements IAjax { rpc(rpc: RPCQuery): Promise { return Promise.resolve(1); diff --git a/web/static/src/ts/services/notifications.ts b/web/static/src/ts/services/notifications.ts index 24da7d91..8650bce6 100644 --- a/web/static/src/ts/services/notifications.ts +++ b/web/static/src/ts/services/notifications.ts @@ -12,9 +12,9 @@ export interface INotification { sticky: boolean; } -export type NotificationEvent = "notification_added" | "notification_removed"; +export type NotificationEvent = "notifications_updated"; -export type Callback = (notif: INotification) => void; +export type Callback = (notifs: INotification[]) => void; export interface INotificationManager { add(notif: Partial): number; @@ -25,9 +25,10 @@ export interface INotificationManager { //------------------------------------------------------------------------------ // Notification Manager //------------------------------------------------------------------------------ + export class NotificationManager extends Bus implements INotificationManager { nextID = 1; - notifications: { [key: number]: INotification } = {}; + notifications: INotification[] = []; add(notif: Partial): number { const id = this.nextID++; @@ -38,18 +39,15 @@ export class NotificationManager extends Bus implements INotificationManager { sticky: false }; const notification = Object.assign(defaultVals, notif, { id }); - this.notifications[id] = notification; - this.trigger("notification_added", notification); + this.notifications.push(notification); + this.trigger("notifications_updated", this.notifications); if (!notification.sticky) { setTimeout(() => this.close(id), 2500); } return id; } close(id: number) { - let notification = this.notifications[id]; - if (notification) { - delete this.notifications[id]; - this.trigger("notification_removed", notification); - } + this.notifications = this.notifications.filter(n => n.id !== id); + this.trigger("notifications_updated", this.notifications); } } diff --git a/web/static/src/ts/services/router.ts b/web/static/src/ts/services/router.ts index eb9674a4..d4b00d2a 100644 --- a/web/static/src/ts/services/router.ts +++ b/web/static/src/ts/services/router.ts @@ -3,6 +3,7 @@ import { EventBus, Callback } from "../core/event_bus"; //------------------------------------------------------------------------------ // Types and helpers //------------------------------------------------------------------------------ + export type Query = { [key: string]: string }; function clearSlashes(s: string): string { @@ -21,6 +22,7 @@ export interface IRouter { //------------------------------------------------------------------------------ // Router //------------------------------------------------------------------------------ + export class Router extends EventBus implements IRouter { currentQuery: Query; diff --git a/web/static/src/ts/widgets/Action.ts b/web/static/src/ts/widgets/Action.ts index a63da5b2..a278ede5 100644 --- a/web/static/src/ts/widgets/Action.ts +++ b/web/static/src/ts/widgets/Action.ts @@ -1,14 +1,36 @@ import { Widget } from "../core/widget"; import { Env } from "../env"; +import { ActionStack } from "../services/action_manager"; -const template = ` -
- MAIN ACTION - -
-`; - -export class Action extends Widget { - name = "action"; - template = template; +export interface Props { + stack: ActionStack; +} + +export class Action extends Widget { + name = "action"; + template = `
`; + currentWidget: any; + + mounted() { + this.setContentWidget(); + } + + shouldUpdate(nextProps: Props) { + this.props = nextProps; + this.setContentWidget(); + return false; + } + + async setContentWidget() { + const info = this.props.stack[this.props.stack.length - 1]; + if (info && info.type === "client") { + const Widget = info.Widget; + let widget = new Widget(this, {}); + await widget.mount(this.el!); + if (this.currentWidget) { + this.currentWidget.destroy(); + } + this.currentWidget = widget; + } + } } diff --git a/web/static/src/ts/widgets/Navbar.ts b/web/static/src/ts/widgets/Navbar.ts index 19f95ed0..3d366bf0 100644 --- a/web/static/src/ts/widgets/Navbar.ts +++ b/web/static/src/ts/widgets/Navbar.ts @@ -3,19 +3,20 @@ import { Env, Menu } from "../env"; const template = `
- +
`; export interface Props { - toggleHomeMenu: () => void; + toggleHome: () => void; inMenu: boolean; } @@ -28,8 +29,8 @@ export class Navbar extends Widget { return this.env.router.formatURL("", { action_id }); } - toggleHomeMenu(ev: MouseEvent) { + toggleHome(ev: MouseEvent) { ev.preventDefault(); - this.props.toggleHomeMenu(); + this.props.toggleHome(); } } diff --git a/web/static/tests/helpers.ts b/web/static/tests/helpers.ts index 4b3e79fc..8ba3252f 100644 --- a/web/static/tests/helpers.ts +++ b/web/static/tests/helpers.ts @@ -5,11 +5,7 @@ import { Env } from "../src/ts/env"; import { IAjax, RPCQuery } from "../src/ts/services/ajax"; import { NotificationManager } from "../src/ts/services/notifications"; -import { - IActionManager, - ActionEvent, - ActionWidget -} from "../src/ts/services/action_manager"; +import { IActionManager, ActionEvent } from "../src/ts/services/action_manager"; import { Callback } from "../src/ts/core/event_bus"; import { IRouter, Query, RouterEvent } from "../src/ts/services/router"; @@ -55,8 +51,9 @@ class MockAjax implements IAjax { class MockActionManager implements IActionManager { doAction(actionID: number) {} on(event: ActionEvent, owner: any, callback: Callback) {} - getCurrentAction(): ActionWidget | null { - return null; + activate() {} + getStack() { + return []; } } diff --git a/web/static/tests/services/notifications.test.ts b/web/static/tests/services/notifications.test.ts index 45353fd4..7dbd9c8b 100644 --- a/web/static/tests/services/notifications.test.ts +++ b/web/static/tests/services/notifications.test.ts @@ -1,51 +1,54 @@ -import { NotificationManager } from "../../src/ts/services/notifications"; +import { + NotificationManager, + INotification +} from "../../src/ts/services/notifications"; test("can subscribe and add notification", () => { - let notified = false; + let notifs: INotification[] = []; const notifications = new NotificationManager(); - notifications.on("notification_added", {}, () => (notified = true)); - expect(notified).toBe(false); + notifications.on("notifications_updated", {}, n => (notifs = n)); + expect(notifs.length).toBe(0); const id = notifications.add({ title: "test", message: "message" }); - expect(notified).toBe(true); + expect(notifs.length).toBe(1); expect(id).toBeDefined(); }); test("can close a notification", () => { + let notifs: INotification[] = []; const notifications = new NotificationManager(); - let notified = false; - notifications.on("notification_removed", {}, () => (notified = true)); + notifications.on("notifications_updated", {}, n => (notifs = n)); const id = notifications.add({ title: "test", message: "message" }); - expect(notified).toBe(false); + expect(notifs.length).toBe(1); notifications.close(id); - expect(notified).toBe(true); + expect(notifs.length).toBe(0); }); test("notifications closes themselves after a while", () => { jest.useFakeTimers(); + let notifs: INotification[] = []; const notifications = new NotificationManager(); - let removed = false; - notifications.on("notification_removed", {}, () => (removed = true)); + notifications.on("notifications_updated", {}, n => (notifs = n)); notifications.add({ title: "test", message: "message" }); expect(setTimeout).toHaveBeenCalledTimes(1); - expect(removed).toBe(false); + expect(notifs.length).toBe(1); jest.runAllTimers(); - expect(removed).toBe(true); + expect(notifs.length).toBe(0); }); test("sticky notifications do not close themselves after a while", () => { jest.useFakeTimers(); + let notifs: INotification[] = []; const notifications = new NotificationManager(); - let removed = false; - notifications.on("notification_removed", {}, () => (removed = true)); + notifications.on("notifications_updated", {}, n => (notifs = n)); notifications.add({ title: "test", message: "message", sticky: true }); expect(setTimeout).toHaveBeenCalledTimes(0); - expect(removed).toBe(false); + expect(notifs.length).toBe(1); jest.runAllTimers(); - expect(removed).toBe(false); + expect(notifs.length).toBe(1); }); diff --git a/web/static/tests/widgets/__snapshots__/navbar.test.ts.snap b/web/static/tests/widgets/__snapshots__/navbar.test.ts.snap index 6ffe7d35..2cdf565f 100644 --- a/web/static/tests/widgets/__snapshots__/navbar.test.ts.snap +++ b/web/static/tests/widgets/__snapshots__/navbar.test.ts.snap @@ -5,6 +5,7 @@ exports[`can be rendered 1`] = `
    +
" `; @@ -18,6 +19,7 @@ exports[`can render one menu item 1`] = ` menu + " `; @@ -27,6 +29,7 @@ exports[`mobile mode: navbar is different 1`] = `
    +
  • MOBILEMODE
" `; diff --git a/web/static/tests/widgets/navbar.test.ts b/web/static/tests/widgets/navbar.test.ts index 6a95d77a..f9d15c1e 100644 --- a/web/static/tests/widgets/navbar.test.ts +++ b/web/static/tests/widgets/navbar.test.ts @@ -13,7 +13,7 @@ let props: Props; beforeEach(() => { fixture = makeTestFixture(); env = makeTestEnv(); - props = { inMenu: false, toggleHomeMenu: () => {} }; + props = { inMenu: false, toggleHome: () => {} }; }); afterEach(() => { diff --git a/web/static/tests/widgets/notification.test.ts b/web/static/tests/widgets/notification.test.ts index 26d9b313..f130bb69 100644 --- a/web/static/tests/widgets/notification.test.ts +++ b/web/static/tests/widgets/notification.test.ts @@ -42,19 +42,21 @@ test("can be rendered", async () => { }); test("can be closed by clicking on it (if sticky)", async () => { - let notif: INotification; - let removed = false; - env.notifications.on("notification_added", null, _notif => (notif = _notif)); + let notifs: INotification[] = []; + env.notifications.on( + "notifications_updated", + null, + _notifs => (notifs = _notifs) + ); env.notifications.add({ title: "title", message: "message", sticky: true }); - env.notifications.on("notification_removed", null, () => (removed = true)); - const navbar = new Notification(env, notif!); + const navbar = new Notification(env, notifs[0]); await navbar.mount(fixture); - expect(removed).toBe(false); + expect(notifs.length).toBe(1); (fixture.getElementsByClassName("o_close")[0]).click(); - expect(removed).toBe(true); + expect(notifs.length).toBe(0); });