move demo code to examples/

This commit is contained in:
Géry Debongnie
2019-03-15 12:15:37 +01:00
parent d99ea4c9f4
commit 46aedf78af
1638 changed files with 32 additions and 29 deletions
+341
View File
@@ -0,0 +1,341 @@
// ------------------------------------------------------------------
// Variables
// ------------------------------------------------------------------
$navbar-height: 46px;
$main-color: #875a7b;
$o-main-text-color: #666666;
$o-notification-info-bg-color: #fcfbea;
$o-notification-error-bg-color: #f16567;
$o-view-background-color: white;
$o-control-panel-background-color: $o-view-background-color;
$o-horizontal-padding: 16px;
$o-cp-breadcrumb-height: 30px;
// bootstrap overrides
// this does not work: $breadcrumb-bg: $o-control-panel-background-color;
.breadcrumb {
background-color: white;
padding: 0;
}
// ------------------------------------------------------------------
// Mixins
// ------------------------------------------------------------------
@mixin o-webclient-padding(
$top: 0px,
$right: $o-horizontal-padding,
$bottom: 0px,
$left: $o-horizontal-padding
) {
padding-top: $top;
padding-right: $right;
padding-bottom: $bottom;
padding-left: $left;
}
@mixin o-text-overflow($display: inline-block, $max-width: 100%) {
display: $display;
max-width: $max-width;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top; // To update display context changed by overflow:hidden
}
// ------------------------------------------------------------------
// CSS
// ------------------------------------------------------------------
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
}
/***** Generic stuff *****/
.dropdown-toggle.o-no-caret::before,
.dropdown-toggle.o-no-caret::after {
content: normal;
}
.dropdown-item {
font-size: 13px;
line-height: 20px;
&:hover {
background-color: #e9ecef;
}
&:active {
color: #212529;
background-color: #dee2e6;
}
}
.o_hidden {
display: none;
}
/***** Web Client *****/
.o_web_client {
font-family: sans-serif;
height: 100%;
display: grid;
grid-template-rows: $navbar-height auto;
}
/***** Navbar *****/
.o_navbar {
background-color: $main-color;
color: white;
display: flex;
line-height: $navbar-height;
font-size: 13px;
&.o_in_home {
background-color: #bba2a5;
a.o_title:hover {
background-color: #bba2a5;
}
}
a.o_title {
padding: 13px 16px 16px 20px;
color: white;
font-size: 18px;
text-decoration: none;
}
a.o_title:hover,
a.o_menu_brand:hover {
background-color: darken($main-color, 10);
}
a.o_menu_brand {
font-size: 20px;
color: white;
text-decoration: none;
padding: 1px 12px;
}
ul {
list-style-type: none;
display: inline-flex;
height: 100%;
margin: 0;
}
.o_menu_sections > li {
margin: 0 4px;
&:hover {
background-color: darken($main-color, 10);
}
> a {
display: block;
height: 40px;
font-size: 15px;
color: white;
text-decoration: none;
padding: 0 10px;
}
}
}
/***** Home Menu *****/
.o_home_menu {
background-color: #bba2a5;
display: flex;
justify-content: center;
.o_apps {
margin-top: 100px;
width: 100%;
max-width: 850px;
display: flex;
flex-flow: row wrap;
.o_app {
width: 16.6666667%;
max-height: 110px;
padding: 20px 0;
text-decoration: none;
text-align: center;
color: white;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8);
font-size: 15px;
transition: background-color 0.3s ease 0s;
&:hover {
background-color: darken(#bba2a5, 4);
}
.o_app_icon {
max-width: 70px;
width: 80%;
margin: auto;
box-shadow: 0 8px 0 -10px black;
}
}
}
}
/***** Notifications *****/
.o_notification_container {
position: absolute;
width: 300px;
right: 5px;
top: $navbar-height;
bottom: 0;
.o_notification {
padding: 0;
margin: 5px 0 0 0;
background-color: $o-notification-info-bg-color;
box-shadow: 0px 0px 5px 1px $o-main-text-color;
position: relative;
&.o_error {
color: white;
background-color: $o-notification-error-bg-color;
}
.o_close {
position: absolute;
top: 5px;
right: 5px;
color: rgba(0, 0, 0, 0.3);
text-decoration: none;
}
.o_notification_title {
display: flex;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 10px 10px 10px 20px;
font-weight: bold;
.o_icon {
display: inline-block;
margin-right: 20px;
color: rgba(0, 0, 0, 0.3);
}
}
.o_notification_content {
padding: 10px;
}
}
}
/***** Discuss *****/
.o_discuss .o_content {
font-size: 15px;
display: flex;
.o_discuss_sidebar {
background-color: #212529;
flex: 0 0 300px;
color: white;
padding: 20px;
}
.o_discuss_content {
padding: 20px;
flex: 1 1 auto;
}
button {
padding: 5px;
}
}
/***** Loading indicator *****/
.o_loading {
position: fixed;
bottom: 0;
right: 0;
z-index: 1051; // lol...
background-color: $main-color;
color: white;
padding: 4px;
font-size: 14px;
}
/***** Control Panel *****/
.o_control_panel {
display: flex;
flex-flow: row wrap;
border-bottom: 1px solid darken($o-control-panel-background-color, 20%);
@include o-webclient-padding($top: 10px, $bottom: 10px);
background-color: $o-control-panel-background-color;
> .breadcrumb {
width: 50%;
font-size: 18px;
> li {
@include o-text-overflow($max-width: 90%);
> a {
cursor: pointer;
}
}
}
> .o_cp_searchview {
width: 50%;
min-height: $o-cp-breadcrumb-height;
}
> .o_cp_left {
display: flex;
justify-content: space-between;
width: 50%;
margin-top: 5px;
> .o_cp_sidebar {
padding-right: 10px;
}
}
> .o_cp_right {
display: flex;
width: 50%;
margin-top: 5px;
> .o_cp_pager {
margin: auto 0 auto auto;
padding-left: 5px;
text-align: center;
user-select: none;
> div {
display: flex;
align-items: center;
}
.o_pager_counter {
margin-right: 5px;
}
}
}
}
/***** Action Controller *****/
.o_action_controller {
height: 100%;
display: flex;
flex-flow: column nowrap;
padding: 0;
> .o_cp_controller {
flex: 0 0 auto;
}
> .o_content {
overflow: auto;
flex: 1 1 100%;
position: relative;
}
}
@@ -0,0 +1,47 @@
import { Widget } from "../widget";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
interface State {
currentTime: string;
}
//------------------------------------------------------------------------------
// Clock
//------------------------------------------------------------------------------
export class Clock extends Widget<{}, State> {
inlineTemplate = `<div class="o_clock"><t t-esc="state.currentTime"/></div>`;
timeout: any | undefined;
state = {
currentTime: ""
};
willStart() {
return this.env.rpc({ model: "res.partner", method: "fetch" });
}
mounted() {
this.updateTime();
this.startClock();
}
willUnmount() {
clearTimeout(this.timeout);
}
updateTime() {
this.setState({ currentTime: new Date().toLocaleTimeString() });
}
startClock() {
const now = Date.now();
const offset = 1000 - (now % 1000);
this.timeout = setTimeout(() => {
this.updateTime();
this.startClock();
}, offset);
}
}
@@ -0,0 +1,39 @@
import { Widget } from "../widget";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
interface Props {
initialState?: number;
}
interface State {
counter: number;
}
//------------------------------------------------------------------------------
// Counter
//------------------------------------------------------------------------------
export class Counter extends Widget<Props, State> {
inlineTemplate = `
<div t-name="counter">
<button t-on-click="increment(-1)">-</button>
<span style="font-weight:bold">Value: <t t-esc="state.counter"/></span>
<button t-on-click="increment(1)">+</button>
</div>`;
state = {
counter: 0
};
constructor(parent: Widget<any, any>, props: Props) {
super(parent, props);
this.state.counter = props.initialState || 0;
}
increment(delta: number) {
this.setState({ counter: this.state.counter + delta });
}
}
@@ -0,0 +1,72 @@
import { Clock } from "./clock";
import { Counter } from "./counter";
import { Widget } from "../widget";
import { ControlPanel } from "../ui/control_panel";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
interface State {
validcounter: boolean;
color: "red" | "blue";
}
//------------------------------------------------------------------------------
// Discuss
//------------------------------------------------------------------------------
export class Discuss extends Widget<{}, State> {
template = "web.discuss";
widgets = { Clock, Counter, ColorWidget, ControlPanel };
state: State = { validcounter: true, color: "red" };
resetCounter(ev: MouseEvent) {
if (this.refs.counter instanceof Counter) {
this.refs.counter.setState({ counter: 3 });
}
}
resetCounterAsync(ev: MouseEvent) {
setTimeout(() => {
if (this.refs.counter2 instanceof Counter) {
this.refs.counter2.setState({ counter: 300 });
}
}, 3000);
}
toggle() {
this.setState({ validcounter: !this.state.validcounter });
}
toggleColor() {
const newColor = this.state.color === "red" ? "blue" : "red";
this.setState({ color: newColor });
}
addNotif(sticky: boolean) {
const text = (<any>this.refs.textinput).value;
const message = `It is now ${new Date().toLocaleTimeString()}.<br/> Msg: ${text}`;
this.env.addNotification({
title: "hey",
message: message,
sticky
});
}
addWarning() {
this.env.addNotification({
title: "This is a warning",
message: "All your bases are belong to us",
type: "warning",
sticky: true
});
}
}
class ColorWidget extends Widget<{ color: "red" | "blue" }, {}> {
inlineTemplate = `
<div t-name="colorwidget">
<span>Current Color: </span>
<t t-esc="props.color"/>
</div>`;
}
+85
View File
@@ -0,0 +1,85 @@
import { Env as WEnv } from "../../../../../src/component";
import { QWeb } from "../../../../../src/qweb";
import { Registry } from "../../../../../src/registry";
import { RPC as RPCService } from "./services/ajax";
import { RPC } from "./store/store";
import { IRouter } from "./services/router";
import { ControllerWidget, Store, Notification } from "./store/store";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
export interface Services {
rpc: RPCService;
router: IRouter;
}
export interface Env extends WEnv {
services: Services;
// registries
actionRegistry: Registry<ControllerWidget>;
viewRegistry: Registry<ControllerWidget>;
// commands
activateMenuItem(menuId: number): void;
addNotification(notif: Partial<Notification>): number;
closeNotification(id: number): void;
toggleHomeMenu(): void;
// helpers
rpc: RPC;
// configuration
debug: boolean;
isMobile: boolean;
}
export interface InitData {
services: Services;
templates: string;
actionRegistry: Registry<ControllerWidget>;
viewRegistry: Registry<ControllerWidget>;
}
//------------------------------------------------------------------------------
// Environment
//------------------------------------------------------------------------------
export function makeEnv(data: InitData): Env {
const qweb = new QWeb();
qweb.addTemplate("default", "<div/>");
qweb.loadTemplates(data.templates);
function throwNonImplementedError(): any {
throw new Error("Not implemented. This feature requires a store.");
}
const env: Env = {
qweb,
services: data.services,
actionRegistry: data.actionRegistry,
viewRegistry: data.viewRegistry,
activateMenuItem: throwNonImplementedError,
addNotification: throwNonImplementedError,
closeNotification: throwNonImplementedError,
toggleHomeMenu: throwNonImplementedError,
rpc: throwNonImplementedError,
debug: false,
isMobile: window.innerWidth <= 768
};
return env;
}
export function linkStoreToEnv(env: Env, store: Store) {
env.activateMenuItem = store.activateMenuItem.bind(store);
env.addNotification = store.addNotification.bind(store);
env.closeNotification = store.closeNotification.bind(store);
env.toggleHomeMenu = store.toggleHomeMenu.bind(store);
env.rpc = store.rpc.bind(store);
}
+91
View File
@@ -0,0 +1,91 @@
import { findInTree } from "../../../../../src/utils";
import { MenuInfo, MenuItem } from "./store/store";
//------------------------------------------------------------------------------
// Templates
//------------------------------------------------------------------------------
/**
* Load xml templates as a string.
*/
export async function loadTemplates(): Promise<string> {
const result = await fetch("templates.xml");
if (!result.ok) {
throw new Error("Error while fetching xml templates");
}
return result.text();
}
//------------------------------------------------------------------------------
// Menus
//------------------------------------------------------------------------------
export interface BaseMenuItem {
id: number;
name: string;
parent_id: number | false;
action: string | false;
icon: string | false;
children: BaseMenuItem[];
}
/**
* Load all menu items
*/
export function loadMenus(): MenuInfo {
const menuItems: BaseMenuItem[] = (<any>window).odoo.menus;
if (!menuItems) {
throw new Error("Cannot find menus description");
}
const menuInfo = getMenuInfo(menuItems);
delete (<any>window).odoo.menus; // overkill?
return menuInfo;
}
/**
* Generate a valid MenuInfo object from a list of BaseMenuItems. This function
* is supposed to be called once at startup.
*/
export function getMenuInfo(items: BaseMenuItem[]): MenuInfo {
const menus: { [key: number]: MenuItem | undefined } = {};
const actionMap: { [id: number]: MenuItem | undefined } = {};
const roots: number[] = [];
// build MenuItems
for (let root of items) {
roots.push(root.id);
addToMap(root);
}
function addToMap(m: BaseMenuItem, app?: MenuItem): MenuItem {
const item: Partial<MenuItem> = {
id: m.id,
name: m.name,
parentId: m.parent_id,
action: m.action,
icon: m.icon,
actionId: -1 // will be filled later on with correct id
};
item.app = app || (item as MenuItem);
item.children = m.children.map(c => addToMap(c, item.app as MenuItem));
menus[item.id!] = item as MenuItem;
return item as MenuItem;
}
// add proper actionId to every menuitems
for (let menuId in menus) {
const menu = menus[menuId]!;
let menuWithAction = menu && findInTree(menu, m => Boolean(m.action));
if (menuWithAction) {
menu.actionId = parseInt(
(<string>menuWithAction.action).split(",")[1],
10
);
if (menuWithAction === menu) {
actionMap[menu.actionId] = menu;
}
}
}
return { menus, roots, actionMap };
}
+33
View File
@@ -0,0 +1,33 @@
///<amd-module name="main" />
import { makeEnv, linkStoreToEnv } from "./env";
import { loadMenus, loadTemplates } from "./loaders";
import { actionRegistry, viewRegistry } from "./registries";
import { rpc } from "./services/ajax";
import { Router } from "./services/router";
import { Store } from "./store/store";
import { Root } from "./ui/root";
//------------------------------------------------------------------------------
// Web Client Bootstrapping
//------------------------------------------------------------------------------
document.addEventListener("DOMContentLoaded", async function() {
const services = {
rpc,
router: new Router()
};
const templates = await loadTemplates();
const menuInfo = loadMenus();
const env = makeEnv({ services, templates, actionRegistry, viewRegistry });
const store = new Store(env, menuInfo);
linkStoreToEnv(env, store);
// Creating root widget
const rootWidget = new Root(env, store);
await rootWidget.mount(document.body);
// For debugging purpose, we keep a reference to the root widget in odoo
(<any>window).odoo.rootWidget = rootWidget;
});
+25
View File
@@ -0,0 +1,25 @@
import { Registry } from "../../../../../src/registry";
import { ControllerWidget } from "./store/store";
import { Discuss } from "./discuss/discuss";
import { ListView } from "./views/list_view";
import { KanbanView } from "./views/kanban_view";
import { FormView } from "./views/form_view";
//------------------------------------------------------------------------------
// Views
//------------------------------------------------------------------------------
export const viewRegistry: Registry<ControllerWidget> = new Registry();
viewRegistry
.add("list", ListView)
.add("kanban", KanbanView)
.add("form", FormView);
//------------------------------------------------------------------------------
// Client Actions
//------------------------------------------------------------------------------
export const actionRegistry: Registry<ControllerWidget> = new Registry();
actionRegistry.add("mail.discuss", Discuss);
@@ -0,0 +1,5 @@
export type RPC = (route: string, params: any) => Promise<any>;
export const rpc: RPC = async function(route, params) {
return (<any>window).demoData.mockAjax(route, params);
};
@@ -0,0 +1,71 @@
import { EventBus, Callback } from "../../../../../../src/event_bus";
//------------------------------------------------------------------------------
// Types and helpers
//------------------------------------------------------------------------------
export type Query = { [key: string]: string | true };
export type RouterEvent = "query_changed";
export interface IRouter {
navigate(query: Query): void;
on(event: RouterEvent, owner: any, callback: Callback): void;
getQuery(): Query;
formatURL(path: string, query: Query): string;
}
//------------------------------------------------------------------------------
// Router
//------------------------------------------------------------------------------
function clearSlashes(s: string): string {
return s.replace(/\/$/, "").replace(/^\//, "");
}
export class Router extends EventBus implements IRouter {
currentQuery: Query;
constructor() {
super();
this.currentQuery = this.getQuery();
window.onhashchange = () => {
this.currentQuery = this.getQuery();
this.trigger("query_changed", this.currentQuery);
};
}
/**
* @param {Query} query
*/
navigate(query: Query) {
const url = this.formatURL("", query);
window.history.replaceState(null, document.title, url);
}
formatQuery(query: Query): string {
let parts: string[] = [];
for (let key in query) {
if (query[key] === true) {
parts.push(key);
} else {
parts.push(`${key}=${query[key]}`);
}
}
return parts.join("&");
}
formatURL(path: string, query: Query): string {
let url = clearSlashes(path);
return `/${url}#${this.formatQuery(query)}`;
}
getQuery(): Query {
const query = {};
for (let part of window.location.hash.slice(1).split("&")) {
let [key, value] = part.split("=");
query[key] = value;
}
return query;
}
}
@@ -0,0 +1,155 @@
import { Type } from "../../../../../../src/component";
import { rpcMixin } from "./rpc_mixin";
import { Widget } from "../widget";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
// Miscellaneous
export type ActionRequest = number;
export type ControllerWidget = Type<Widget<{}, {}>>;
// Action Description
interface BaseActionDescription {
id: number;
target: "current";
name: string;
}
export interface ClientActionDescription extends BaseActionDescription {
type: "ir.actions.client";
tag: string;
}
export interface ActWindowActionDescription extends BaseActionDescription {
type: "ir.actions.act_window";
views: [false | number, string][];
domain: false | string;
res_id: number;
res_model: string;
context: Object | string;
}
export type ActionDescription =
| ClientActionDescription
| ActWindowActionDescription;
// Controller
export interface Controller {
id: number;
actionId: number;
widget?: Widget<any, any>;
create(parent: Widget<any, any>): Promise<Widget<any, any> | null>;
title: string;
}
//------------------------------------------------------------------------------
// Action Manager Mixin
//------------------------------------------------------------------------------
export function actionManagerMixin<T extends ReturnType<typeof rpcMixin>>(
Base: T
) {
return class extends Base {
actionCache: { [key: number]: Promise<ActionDescription> } = {};
currentController?: Controller;
lastController?: Controller;
async doAction(request: ActionRequest) {
const self = this;
const descr = await this.loadAction(request);
let executor;
switch (descr.type) {
case "ir.actions.client":
executor = this.doClientAction(descr);
break;
case "ir.actions.act_window":
executor = this.doActWindowAction(descr);
break;
default:
throw new Error("unhandled action");
}
const action: Controller = {
id: this.generateID(),
actionId: descr.id,
create: executor,
title: descr.name
};
self.lastController = action;
this.trigger("update_action", action);
}
doActWindowAction(descr: ActWindowActionDescription) {
const tag = descr.views[0][1];
let View = this.env.viewRegistry.get(tag);
if (!View) {
this.addNotification({
title: "Invalid View type",
type: "warning",
message: `Cannot find view of type '${tag}' in the view registry`
});
View = Widget;
}
return async function executor(
this: Controller,
parent: Widget<any, any>
) {
const widget = new View!(parent, {
info: descr.views[0][1],
title: descr.name
});
const div = document.createElement("div");
await widget.mount(div);
this.widget = widget;
return widget;
};
}
doClientAction(descr: ClientActionDescription): Controller["create"] {
let key = descr.tag;
let ActionWidget = this.env.actionRegistry.get(key);
if (!ActionWidget) {
this.addNotification({
title: "Invalid Client Action",
type: "warning",
message: `Cannot find widget '${key}' in the action registry`
});
ActionWidget = Widget;
}
return async function executor(
this: Controller,
parent: Widget<any, any>
) {
const widget = new ActionWidget!(parent, {});
const div = document.createElement("div");
await widget.mount(div);
this.widget = widget;
return widget;
};
}
loadAction(id: number): Promise<ActionDescription> {
if (id in this.actionCache) {
return this.actionCache[id];
}
return (this.actionCache[id] = this.rpc({
route: "web/action/load",
params: {
action_id: id
}
}));
}
activateController(controller: Controller) {
if (this.currentController && this.currentController.widget) {
this.currentController.widget.destroy();
}
this.currentController = controller;
this.update({
inHome: false
});
document.title = controller.title + " - Odoo";
}
};
}
@@ -0,0 +1,74 @@
import { Type } from "../../../../../../src/component";
import { BaseStore } from "./store";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
export interface RPCModelQuery {
model: string;
method: string;
args?: any[];
kwargs?: { [key: string]: any };
context?: { [key: string]: any };
}
export interface RPCControllerQuery {
route: string;
params: { [key: string]: any };
}
export type RPCQuery = RPCModelQuery | RPCControllerQuery;
export type RPC = (rpc: RPCQuery) => Promise<any>;
export interface RequestParameters {
route: string;
params: { [key: string]: any };
}
//------------------------------------------------------------------------------
// rpc Mixin
//------------------------------------------------------------------------------
export 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.env.services.rpc(request.route, request.params);
this.counter--;
if (this.counter === 0) {
this.trigger("rpc_status", "notloading");
}
return result;
}
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 };
}
};
}
+186
View File
@@ -0,0 +1,186 @@
import { EventBus } from "../../../../../../src/event_bus";
import { idGenerator } from "../../../../../../src/utils";
import { Env } from "../env";
import { Query } from "../services/router";
import { actionManagerMixin } from "./action_manager_mixin";
import { rpcMixin } from "./rpc_mixin";
import { MenuItem } from "./store";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
export { ControllerWidget } from "./action_manager_mixin";
export { RPC } from "./rpc_mixin";
export interface MenuItem {
id: number;
name: string;
parentId: number | false;
action: string | false;
icon: string | false;
// root menu id
app: MenuItem;
actionId: number;
children: MenuItem[];
}
export interface Notification {
id: number;
title: string;
message: string;
type: "notification" | "warning";
sticky: boolean;
}
export interface MenuInfo {
menus: { [key: number]: MenuItem | undefined };
actionMap: { [id: number]: MenuItem | undefined };
roots: number[];
}
export interface State {
inHome: boolean;
currentApp: MenuItem | null;
notifications: Notification[];
}
//------------------------------------------------------------------------------
// Store
//------------------------------------------------------------------------------
export class BaseStore extends EventBus {
state: State = {
inHome: false,
currentApp: null,
notifications: []
};
menuInfo: MenuInfo;
env: Env;
currentQuery: Query;
generateID = idGenerator();
constructor(env: Env, menuInfo: MenuInfo) {
super();
this.env = env;
this.menuInfo = menuInfo;
this.currentQuery = {};
}
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 });
if (this.state.inHome) {
this.env.services.router.navigate({ home: true });
} else {
this.updateQuery(this.currentQuery);
}
}
updateQuery(query: Query) {
this.currentQuery = query;
this.env.services.router.navigate(query);
}
addNotification(notif: Partial<Notification>): number {
const id = this.generateID();
const defaultVals = {
title: "",
message: "",
type: "notification",
sticky: false
};
const notification = Object.assign(defaultVals, notif, { id });
const notifs = this.state.notifications.concat(notification);
this.update({ notifications: notifs });
if (!notification.sticky) {
setTimeout(() => this.closeNotification(id), 2500);
}
return id;
}
closeNotification(id: number) {
const notifs = this.state.notifications.filter(n => n.id !== id);
this.update({ notifications: notifs });
}
}
export class Store extends actionManagerMixin(rpcMixin(BaseStore)) {
constructor(env: Env, menuInfo: MenuInfo) {
super(env, menuInfo);
const query = this.env.services.router.getQuery();
let { app, actionId } = this.getAppAndAction(query);
this.state.currentApp = app;
if (!actionId) {
this.state.inHome = true;
this.env.services.router.navigate({ home: true });
}
this.env.services.router.on("query_changed", this, this.updateAction);
this.updateAction(this.env.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");
}
return this.updateAppState(menu.app, menu.actionId);
}
private async 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 });
}
await this.doAction(actionId);
this.updateQuery(query);
} else {
this.update({ inHome: true, currentApp: newApp });
}
}
getAppAndAction(
query: Query
): { app: MenuItem | null; actionId: number | null } {
const menuInfo = this.menuInfo;
let app: MenuItem | null = null;
let actionId: number | null = null;
if (typeof query.action_id === "string") {
actionId = parseInt(query.action_id, 10);
if (menuInfo.actionMap[actionId]) {
const menu = menuInfo.actionMap[actionId]!;
app = menu.app;
}
}
if (typeof query.menu_id === "string") {
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 };
}
}
@@ -0,0 +1,17 @@
import { Widget } from "../widget";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
export interface Props {
title: string;
}
//------------------------------------------------------------------------------
// Navbar
//------------------------------------------------------------------------------
export class ControlPanel extends Widget<Props, {}> {
template = "web.control_panel";
}
@@ -0,0 +1,28 @@
import { MenuInfo, MenuItem } from "../store/store";
import { Widget } from "../widget";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
export interface Props {
menuInfo: MenuInfo;
}
//------------------------------------------------------------------------------
// Home Menu
//------------------------------------------------------------------------------
export class HomeMenu extends Widget<Props, {}> {
template = "web.home_menu";
get apps(): MenuItem[] {
const info = this.props.menuInfo;
return info.roots.map(root => info.menus[root]!);
}
openMenu(app: MenuItem, event: MouseEvent) {
event.preventDefault();
this.env.activateMenuItem(app.id);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { MenuItem } from "../store/store";
import { PureWidget } from "../widget";
//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
export interface Props {
inHome: boolean;
app: MenuItem | null;
}
//------------------------------------------------------------------------------
// Navbar
//------------------------------------------------------------------------------
export class Navbar extends PureWidget<Props, {}> {
template = "web.navbar";
getUrl(menu: MenuItem) {
const action_id = String(menu.actionId);
const menu_id = String(menu.app.id);
return this.env.services.router.formatURL("", { action_id, menu_id });
}
toggleHome(ev: MouseEvent) {
ev.preventDefault();
this.env.toggleHomeMenu();
}
openMenu(menu: MenuItem) {
this.env.activateMenuItem(menu.id);
}
}
@@ -0,0 +1,16 @@
import { Notification as INotification } from "../store/store";
import { Widget } from "../widget";
export class Notification extends Widget<INotification, {}> {
template = "web.notification";
close(ev: MouseEvent) {
// we do not want the url to change
ev.preventDefault();
this.env.closeNotification(this.props.id);
}
get icon() {
return this.props.type === "warning" ? "fa-exclamation" : "fa-lightbulb";
}
}
+61
View File
@@ -0,0 +1,61 @@
import { debounce } from "../../../../../../src/utils";
import { Env } from "../env";
import { Controller } from "../store/action_manager_mixin";
import { State, Store } from "../store/store";
import { Widget } from "../widget";
import { HomeMenu } from "./home_menu";
import { Navbar } from "./navbar";
import { Notification } from "./notification";
//------------------------------------------------------------------------------
// Root Widget
//------------------------------------------------------------------------------
export class Root extends Widget<Store, State> {
template = "web.web_client";
widgets = { Navbar, HomeMenu, Notification };
store: Store;
constructor(env: Env, store: Store) {
super(env, store);
this.store = store;
this.state = store.state;
}
mounted() {
this.store.on("state_updated", this, this.setState);
this.store.on("rpc_status", this, this.toggleLoadingIndicator);
this.store.on("update_action", this, this.applyController);
if (this.store.lastController) {
this.applyController(this.store.lastController);
}
// adding reactiveness to mobile/non mobile
window.addEventListener("resize", <any>(
debounce(this.applyMobileInterface.bind(this), 50)
));
}
applyMobileInterface() {
const isMobile = window.innerWidth <= 768;
if (isMobile !== this.env.isMobile) {
this.env.isMobile = isMobile;
this.render();
}
}
toggleLoadingIndicator(status: "loading" | "notloading") {
const method = status === "loading" ? "remove" : "add";
(<any>this.refs.loading_indicator).classList[method]("d-none");
}
async applyController(controller: Controller) {
const widget = await controller.create(this);
if (widget) {
this.attachChild(widget, <HTMLElement>this.refs.content);
widget.el!.classList.add("o_action_controller");
this.store.activateController(controller);
}
}
}
@@ -0,0 +1,7 @@
import { Widget } from "../widget";
import { ControlPanel } from "../ui/control_panel";
export class BaseView extends Widget<{ info: any }, {}> {
template = "web.base_view";
widgets = { ControlPanel };
}
@@ -0,0 +1,5 @@
import { BaseView } from "./base_view";
export class FormView extends BaseView {
viewType = "form";
}
@@ -0,0 +1,5 @@
import { BaseView } from "./base_view";
export class KanbanView extends BaseView {
viewType = "kanban";
}
@@ -0,0 +1,5 @@
import { BaseView } from "./base_view";
export class ListView extends BaseView {
viewType = "list";
}
+38
View File
@@ -0,0 +1,38 @@
import { Component } from "../../../../../src/component";
import { Env } from "./env";
//------------------------------------------------------------------------------
// Widget classes
//------------------------------------------------------------------------------
export class Widget<P, S> extends Component<Env, P, S> {
constructor(parent, props) {
super(parent, props);
(<any>this.__widget__).isMobile = this.env.isMobile;
}
async updateProps(nextProps: P): Promise<void> {
if ((<any>this.__widget__).isMobile !== this.env.isMobile) {
(<any>this.__widget__).isMobile = this.env.isMobile;
return this._updateProps(nextProps);
}
return super.updateProps(nextProps);
}
}
export class PureWidget<P, S> extends Widget<P, S> {
shouldUpdate(nextProps: P): boolean {
for (let k in nextProps) {
if (nextProps[k] !== this.props[k]) {
return true;
}
}
return false;
}
async setState(nextState: Partial<S>) {
for (let k in nextState) {
if (nextState[k] !== this.state[k]) {
return super.setState(nextState);
}
}
}
}
+134
View File
@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<div t-name="web.web_client" class="o_web_client">
<t t-widget="Navbar" t-props="{inHome:state.inHome,app:state.currentApp}" />
<t t-widget="HomeMenu" t-if="state.inHome" t-keep-alive="1" t-props="{menuInfo:props.menuInfo}" />
<div class="o_content" t-att-class="state.inHome ? 'o_hidden' : ''" t-ref="content"/>
<div class="o_notification_container">
<t t-foreach="state.notifications" t-as="notif">
<t t-widget="Notification" t-props="notif"/>
</t>
</div>
<div class="o_loading d-none" t-ref="loading_indicator">Loading</div>
</div>
<div t-name="web.navbar" class="o_navbar" t-att-class="props.inHome ? 'o_in_home' : ''">
<a aria-label="Applications" t-if="!props.inHome || props.app" class="o_title fa" t-att-class="props.inHome ? 'fa-chevron-left' : 'fa-th'" href="#" title="Applications" accesskey="h" t-on-click="toggleHome"/>
<t t-if="!props.inHome &amp;&amp; props.app">
<a class="o_menu_brand" t-att-href="getUrl(props.app)" role="button" t-on-click="openMenu(props.app)">
<t t-esc="props.app.name"/>
</a>
<ul class="o_menu_sections">
<li t-foreach="props.app.children" t-as="menu">
<t t-if="menu.children.length === 0">
<a t-att-href="getUrl(menu)" role="menuitem" t-on-click="openMenu(menu)">
<span>
<t t-esc="menu.name"/>
</span>
</a>
</t>
<t t-else="1">
<a href="#" role="button" class="dropdown-toggle o-no-caret" data-toggle="dropdown">
<span>
<t t-esc="menu.name"/>
</span>
</a>
<div class="dropdown-menu" role="menu">
<t t-foreach="menu.children" t-as="submenu">
<a t-att-href="getUrl(submenu)" data-toggle="collapse" data-target="#someid" role="menuitem" class="dropdown-item" t-on-click="openMenu(submenu)">
<span>
<t t-esc="submenu.name"/>
</span>
</a>
</t>
</div>
</t>
</li>
<li t-if="env.isMobile">MOBILEMODE</li>
</ul>
</t>
</div>
<div t-name="web.control_panel" class="o_control_panel">
<ol class="breadcrumb" role="navigation">
<li class="breadcrumb-item active">
<t t-esc="props.title"/>
</li>
</ol>
<div class="o_cp_searchview" role="search">
<t t-call="web.search_view"/>
</div>
<div class="o_cp_left">
<div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar"/>
<aside class="o_cp_sidebar"/>
</div>
<div class="o_cp_right">
<div class="btn-group o_search_options" role="search"/>
<nav class="o_cp_pager" role="search" aria-label="Pager"/>
<nav class="btn-group o_cp_switch_buttons" role="toolbar" aria-label="View switcher"/>
</div>
</div>
<div t-name="web.search_view" class="o_searchview" role="search" aria-autocomplete="list">
<span class="o_searchview_more fa" title="Advanced Search..." role="img" aria-label="Advanced Search..."/>
</div>
<div t-name="web.base_view" class="o_view_controller">
<t t-widget="ControlPanel" t-props="{title: props.title}"/>
<div class="o_content">
<span t-esc="viewType"/>
</div>
</div>
<div t-name="web.home_menu" class="o_home_menu">
<div class="o_apps">
<a t-foreach="apps" t-as="app" t-att-href="'#menu_id=' + app.id + '&amp;action_id=' + app.actionId" class="o_app" t-on-click="openMenu(app)" t-att-data-menu="app.id">
<i t-att-class="app.icon + ' o_app_icon fa-3x fa-fw'"/>
<div class="o_caption">
<t t-esc="app.name"/>
</div>
</a>
</div>
</div>
<div t-name="web.notification" class="o_notification" t-att-class="props.type === 'warning' ? 'o_error' : ''">
<a t-if="props.sticky" class="fa fa-times o_close" href="#" title="Close" aria-label="Close" t-on-click="close"/>
<div class="o_notification_title">
<span t-attf-class="o_icon fa fa-3x {{icon}}" role="img" t-attf-aria-label="Notification {{props.title}}" t-attf-title="Notification {{props.title}}"/>
<t t-raw="props.title"/>
</div>
<div class="o_notification_content" t-if="props.message.length">
<t t-raw="props.message"/>
</div>
</div>
<div t-name="web.discuss" class="o_discuss">
<t t-widget="ControlPanel" t-props="{title: 'Discuss'}"/>
<div class="o_content">
<div class="o_discuss_sidebar">
<span>DISCUSS!!</span>
</div>
<div class="o_discuss_content">
<button t-on-click="resetCounter">Reset first counter</button>
<button t-on-click="resetCounterAsync">Reset counter 2 in 3s</button>
<button t-on-click="toggle">Toggle Clock/counters</button>
<button t-on-click="toggleColor">Toggle Color</button>
<button t-on-click="setState({})">Rerender this widget</button>
<input t-ref="textinput"/>
<t t-if="state.validcounter">
<t t-widget="Counter" t-ref="counter" t-props="{initialState:4}"/>
<t t-widget="Counter" t-ref="counter2" t-props="{initialState:400}"/>
</t>
<t t-else="1">
<t t-widget="Clock"/>
</t>
<t t-widget="ColorWidget" t-props="{color: state.color}"/>
<button t-on-click="addNotif(false)">Add notif</button>
<button t-on-click="addNotif(true)">Add sticky notif</button>
<button t-on-click="addWarning">Add warning</button>
</div>
</div>
</div>
</templates>