mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
refactoring: implement actionmanager, and router
So we now have a working SPA!
This commit is contained in:
@@ -20,7 +20,7 @@ export class Widget<T extends WEnv> {
|
|||||||
env: T;
|
env: T;
|
||||||
el: HTMLElement | null = null;
|
el: HTMLElement | null = null;
|
||||||
state: Object = {};
|
state: Object = {};
|
||||||
refs: { [key: string]: Widget<T> } = {};
|
refs: { [key: string]: any } = {}; // either HTMLElement or Widget
|
||||||
|
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|||||||
@@ -5,9 +5,22 @@ interface Subscription {
|
|||||||
callback: Callback;
|
callback: Callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple event bus: it can emit events, and add/remove listeners.
|
||||||
|
*/
|
||||||
export class Bus {
|
export class Bus {
|
||||||
private subscriptions: { [eventType: string]: Subscription[] } = {};
|
private subscriptions: { [eventType: string]: Subscription[] } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a listener for the 'eventType' events.
|
||||||
|
*
|
||||||
|
* Note that the 'owner' of this event can be anything, but will more likely
|
||||||
|
* be a widget or a class. The idea is that the callback will be called with
|
||||||
|
* the proper owner bound.
|
||||||
|
*
|
||||||
|
* Also, the owner should be kind of unique. This will be used to remove the
|
||||||
|
* listener.
|
||||||
|
*/
|
||||||
on(eventType: string, owner: any, callback: Callback) {
|
on(eventType: string, owner: any, callback: Callback) {
|
||||||
if (!this.subscriptions[eventType]) {
|
if (!this.subscriptions[eventType]) {
|
||||||
this.subscriptions[eventType] = [];
|
this.subscriptions[eventType] = [];
|
||||||
@@ -17,16 +30,25 @@ export class Bus {
|
|||||||
callback
|
callback
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a listener
|
||||||
|
*/
|
||||||
off(eventType: string, owner: any) {
|
off(eventType: string, owner: any) {
|
||||||
const subs = this.subscriptions[eventType];
|
const subs = this.subscriptions[eventType];
|
||||||
if (subs) {
|
if (subs) {
|
||||||
this.subscriptions[eventType] = subs.filter(s => s.owner !== owner);
|
this.subscriptions[eventType] = subs.filter(s => s.owner !== owner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an event of type 'eventType'. Any extra arguments will be passed to
|
||||||
|
* the listeners callback.
|
||||||
|
*/
|
||||||
trigger(eventType: string, ...args: any[]) {
|
trigger(eventType: string, ...args: any[]) {
|
||||||
const subs = this.subscriptions[eventType] || [];
|
const subs = this.subscriptions[eventType] || [];
|
||||||
for (let sub of subs) {
|
for (let sub of subs) {
|
||||||
sub.callback(...args);
|
sub.callback.call(sub.owner, ...args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
import { QWeb } from "./core/qweb_vdom";
|
import { QWeb } from "./core/qweb_vdom";
|
||||||
import { WEnv } from "./core/widget";
|
import { WEnv } from "./core/widget";
|
||||||
import { actions, Action } from "./services/actions";
|
|
||||||
import { ActionManager } from "./services/action_manager";
|
import { ActionManager } from "./services/action_manager";
|
||||||
import Ajax from "./services/ajax";
|
import { Ajax } from "./services/ajax";
|
||||||
import Router from "./services/router";
|
import { Router } from "./services/router";
|
||||||
|
|
||||||
|
export interface Menu {
|
||||||
|
title: string;
|
||||||
|
actionID: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Env extends WEnv {
|
export interface Env extends WEnv {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
actions: Action[];
|
|
||||||
ajax: Ajax;
|
ajax: Ajax;
|
||||||
router: Router;
|
router: Router;
|
||||||
|
menus: Menu[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeEnvironment(): Env {
|
export function makeEnvironment(): Env {
|
||||||
const qweb = new QWeb();
|
const qweb = new QWeb();
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
const ajax = new Ajax();
|
const ajax = new Ajax();
|
||||||
const actionManager = new ActionManager();
|
const actionManager = new ActionManager(router);
|
||||||
|
const menus = [
|
||||||
|
{ title: "Discuss", actionID: 1 },
|
||||||
|
{ title: "CRM", actionID: 2 }
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
qweb,
|
qweb,
|
||||||
ajax,
|
ajax,
|
||||||
router,
|
router,
|
||||||
actionManager,
|
actionManager,
|
||||||
actions
|
menus
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,81 @@
|
|||||||
import { Action } from "./actions";
|
import { Bus } from "../core/bus";
|
||||||
|
import { Widget } from "../core/widget";
|
||||||
|
import { Env } from "../env";
|
||||||
|
import { Router, Query } from "./router";
|
||||||
|
import { Discuss } from "../widgets/discuss";
|
||||||
|
import { CRM } from "../widgets/crm";
|
||||||
|
|
||||||
// export interface Action {
|
interface Type<T> extends Function {
|
||||||
// id: number;
|
new (...args: any[]): T;
|
||||||
// title: string;
|
}
|
||||||
// Widget: Type<Widget<Env>>;
|
|
||||||
// default?: boolean;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const actions: Action[] = [
|
interface ClientAction {
|
||||||
// { id: 1, title: "Discuss", Widget: Discuss, default: true },
|
type: "client";
|
||||||
// { id: 2, title: "CRM", Widget: CRM }
|
name: string;
|
||||||
// ];
|
}
|
||||||
|
|
||||||
export class ActionManager {
|
interface ActWindowAction {
|
||||||
doAction(action: Action) {
|
type: "act_window";
|
||||||
console.log(action);
|
views: string[];
|
||||||
// load data (??)
|
}
|
||||||
// trigger action somewhere
|
|
||||||
// upload url with router
|
export type Action = ClientAction | ActWindowAction;
|
||||||
|
|
||||||
|
export interface ActionWidget {
|
||||||
|
id: number;
|
||||||
|
Widget: Type<Widget<Env>>;
|
||||||
|
props: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: any[] = [
|
||||||
|
{ id: 1, title: "Discuss", Widget: Discuss, default: true },
|
||||||
|
{ id: 2, title: "CRM", Widget: CRM }
|
||||||
|
];
|
||||||
|
|
||||||
|
export class ActionManager extends Bus {
|
||||||
|
router: Router;
|
||||||
|
currentAction: ActionWidget | null = null;
|
||||||
|
|
||||||
|
constructor(router: Router) {
|
||||||
|
super();
|
||||||
|
this.router = router;
|
||||||
|
const query = this.router.getQuery();
|
||||||
|
this.update(query);
|
||||||
|
this.router.on("query_changed", this, this.update);
|
||||||
|
if (!this.currentAction) {
|
||||||
|
const action = actions.find(a => a.default);
|
||||||
|
if (action) {
|
||||||
|
this.doAction(action.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { CRM } from "../widgets/crm";
|
|
||||||
import { Discuss } from "../widgets/discuss";
|
|
||||||
import { Widget } from "../core/widget";
|
|
||||||
import { Env } from "../env";
|
|
||||||
|
|
||||||
interface Type<T> extends Function {
|
|
||||||
new (...args: any[]): T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Action {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
Widget: Type<Widget<Env>>;
|
|
||||||
default?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const actions: Action[] = [
|
|
||||||
{ id: 1, title: "Discuss", Widget: Discuss, default: true },
|
|
||||||
{ id: 2, title: "CRM", Widget: CRM }
|
|
||||||
];
|
|
||||||
|
|
||||||
// class ActionManager {
|
|
||||||
|
|
||||||
// doAction(action: Action) {
|
|
||||||
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@@ -1 +1 @@
|
|||||||
export default class Ajax {}
|
export class Ajax {}
|
||||||
|
|||||||
@@ -2,76 +2,49 @@ import { Bus } from "../core/bus";
|
|||||||
|
|
||||||
export type Query = { [key: string]: string };
|
export type Query = { [key: string]: string };
|
||||||
|
|
||||||
export interface Route {
|
function clearSlashes(s: string): string {
|
||||||
// this is the part before the hash: www.something.com/web#action=1 => web
|
|
||||||
path: string;
|
|
||||||
query: Query;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSlaches(s: string): string {
|
|
||||||
return s.replace(/\/$/, "").replace(/^\//, "");
|
return s.replace(/\/$/, "").replace(/^\//, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Router extends Bus {
|
export class Router extends Bus {
|
||||||
listeners: { owner: any; callback: (info: Route) => void }[] = [];
|
currentQuery: Query;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
window.addEventListener("popstate", this.onUrlChange.bind(this));
|
this.currentQuery = this.getQuery();
|
||||||
window.onhashchange = function() {
|
window.onhashchange = () => {
|
||||||
console.log("aaaa");
|
this.currentQuery = this.getQuery();
|
||||||
debugger;
|
this.trigger("query_changed", this.currentQuery);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onUrlChange(event: PopStateEvent) {
|
|
||||||
debugger;
|
|
||||||
event.preventDefault();
|
|
||||||
const info = this.getRoute();
|
|
||||||
for (let listener of this.listeners) {
|
|
||||||
listener.callback.call(listener.owner, info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Route} route relative route: for example, /web/
|
|
||||||
* @param {Query} query
|
* @param {Query} query
|
||||||
*/
|
*/
|
||||||
navigate(info: Partial<Route>) {
|
navigate(query: Query) {
|
||||||
const currentRoute = this.getRoute();
|
const url = this.formatURL("", query);
|
||||||
const route = info.path || currentRoute.path;
|
window.history.replaceState(null, document.title, url);
|
||||||
const query = info.query || {};
|
|
||||||
const title = info.title || currentRoute.title;
|
|
||||||
const url = this.formatURL(route, query);
|
|
||||||
window.history.pushState(null, title, url);
|
|
||||||
}
|
|
||||||
register(owner: any, callback: (info: Route) => void) {
|
|
||||||
this.listeners.push({ owner, callback });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unregister(owner: any) {
|
formatQuery(query: Query): string {
|
||||||
this.listeners = this.listeners.filter(l => l.owner !== owner);
|
let parts: string[] = [];
|
||||||
|
for (let key in query) {
|
||||||
|
parts.push(`${key}=${query[key]}`);
|
||||||
|
}
|
||||||
|
return parts.join("&");
|
||||||
}
|
}
|
||||||
|
|
||||||
formatURL(path: string, query: Query): string {
|
formatURL(path: string, query: Query): string {
|
||||||
let url = clearSlaches(path);
|
let url = clearSlashes(path);
|
||||||
let hasHash = false;
|
return `/${url}#${this.formatQuery(query)}`;
|
||||||
for (let key in query) {
|
|
||||||
url = url + (hasHash ? "&" : "#");
|
|
||||||
url = url + `${key}=${query[key]}`;
|
|
||||||
hasHash = true;
|
|
||||||
}
|
|
||||||
return "/" + url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getRoute(): Route {
|
getQuery(): Query {
|
||||||
const path = clearSlaches(window.location.pathname);
|
|
||||||
const query = {};
|
const query = {};
|
||||||
for (let part of window.location.hash.slice(1).split("?")) {
|
for (let part of window.location.hash.slice(1).split("?")) {
|
||||||
let [key, value] = part.split("=");
|
let [key, value] = part.split("=");
|
||||||
query[key] = value;
|
query[key] = value;
|
||||||
}
|
}
|
||||||
return { path, query, title: document.title };
|
return query;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Widget } from "../core/widget";
|
import { Widget } from "../core/widget";
|
||||||
import { Action } from "../services/actions";
|
import { Env, Menu } from "../env";
|
||||||
import { Env } from "../env";
|
|
||||||
|
|
||||||
const template = `
|
const template = `
|
||||||
<div class="o_navbar">
|
<div class="o_navbar">
|
||||||
<span class="title">Odoo</span>
|
<span class="title">Odoo</span>
|
||||||
<ul>
|
<ul>
|
||||||
<li t-foreach="env.actions" t-as="action">
|
<li t-foreach="env.menus" t-as="menu">
|
||||||
<a t-on-click="activateAction(action)" t-att-href="getUrl(action)">
|
<a t-on-click="activateMenu(menu)" t-att-href="getUrl(menu)">
|
||||||
<t t-esc="action.title"/>
|
<t t-esc="menu.title"/>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -19,13 +18,13 @@ export class Navbar extends Widget<Env> {
|
|||||||
name = "navbar";
|
name = "navbar";
|
||||||
template = template;
|
template = template;
|
||||||
|
|
||||||
getUrl(action: Action) {
|
getUrl(menu: Menu) {
|
||||||
const action_id = String(action.id);
|
const action_id = String(menu.actionID);
|
||||||
return this.env.router.formatURL("web", { action_id });
|
return this.env.router.formatURL("", { action_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
activateAction(action: Action, event: MouseEvent) {
|
activateMenu(menu: Menu, event: MouseEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.env.actionManager.doAction(action);
|
this.env.actionManager.doAction(menu.actionID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Widget } from "../core/widget";
|
import { Widget } from "../core/widget";
|
||||||
import { Navbar } from "./navbar";
|
import { Navbar } from "./navbar";
|
||||||
import { Action } from "../services/actions";
|
import { ActionWidget } from "../services/action_manager";
|
||||||
|
// import { Action } from "../services/actions";
|
||||||
import { Env } from "../env";
|
import { Env } from "../env";
|
||||||
|
|
||||||
const template = `
|
const template = `
|
||||||
<div class="o_web_client">
|
<div class="o_web_client">
|
||||||
<t t-widget="Navbar"/>
|
<t t-widget="Navbar"/>
|
||||||
<div class="o_content">
|
<div class="o_content" t-ref="content">
|
||||||
<t t-widget="Content"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -16,39 +16,44 @@ export class RootWidget extends Widget<Env> {
|
|||||||
name = "root";
|
name = "root";
|
||||||
template = template;
|
template = template;
|
||||||
widgets = { Navbar };
|
widgets = { Navbar };
|
||||||
|
content: Widget<Env> | null = null;
|
||||||
constructor(env: Env) {
|
|
||||||
super(env);
|
|
||||||
this.setMainWidget();
|
|
||||||
}
|
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.env.router.register(this, this.onUrlChange);
|
this.env.actionManager.on("action_ready", this, this.setContentWidget);
|
||||||
}
|
const actionWidget = this.env.actionManager.getCurrentAction();
|
||||||
|
if (actionWidget) {
|
||||||
setMainWidget() {
|
this.setContentWidget(actionWidget);
|
||||||
const action = this.getAction();
|
|
||||||
(<any>this.widgets).Content = action.Widget;
|
|
||||||
}
|
|
||||||
|
|
||||||
onUrlChange() {
|
|
||||||
this.setMainWidget();
|
|
||||||
// notice that this can only be safely done because the root widget is
|
|
||||||
// mounted now.
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
getAction(): Action {
|
|
||||||
const routeInfo = this.env.router.getRoute();
|
|
||||||
const actionID = parseInt(routeInfo.query.action_id);
|
|
||||||
let actions: Action[] = this.env.actions;
|
|
||||||
let action = actions.find(a => a.id === actionID);
|
|
||||||
if (!action) {
|
|
||||||
action = actions.find(a => a.default === true);
|
|
||||||
if (!action) {
|
|
||||||
throw new Error("No valid action!");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return action;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setContentWidget(actionWidget: ActionWidget) {
|
||||||
|
const currentWidget = this.content;
|
||||||
|
const newWidget = new actionWidget.Widget(this, actionWidget.props);
|
||||||
|
await newWidget.mount(<HTMLElement>this.refs.content);
|
||||||
|
if (currentWidget) {
|
||||||
|
currentWidget.destroy();
|
||||||
|
}
|
||||||
|
this.content = newWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
// onUrlChange() {
|
||||||
|
// this.setMainWidget();
|
||||||
|
// // notice that this can only be safely done because the root widget is
|
||||||
|
// // mounted now.
|
||||||
|
// this.render();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// getAction(): Action {
|
||||||
|
// const routeInfo = this.env.router.getRoute();
|
||||||
|
// const actionID = parseInt(routeInfo.query.action_id);
|
||||||
|
// let actions: Action[] = this.env.actions;
|
||||||
|
// let action = actions.find(a => a.id === actionID);
|
||||||
|
// if (!action) {
|
||||||
|
// action = actions.find(a => a.default === true);
|
||||||
|
// if (!action) {
|
||||||
|
// throw new Error("No valid action!");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return action;
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ describe("event bus behaviour", () => {
|
|||||||
expect(notified).toBe(true);
|
expect(notified).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("callbacks are called with proper 'this'", () => {
|
||||||
|
expect.assertions(1);
|
||||||
|
const bus = new Bus();
|
||||||
|
const owner = {};
|
||||||
|
bus.on("event", owner, function(this: any) {
|
||||||
|
expect(this).toBe(owner);
|
||||||
|
});
|
||||||
|
bus.trigger("event");
|
||||||
|
});
|
||||||
|
|
||||||
test("can unsubscribe", () => {
|
test("can unsubscribe", () => {
|
||||||
const bus = new Bus();
|
const bus = new Bus();
|
||||||
let notified = false;
|
let notified = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user