mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
rename web into demo
This commit is contained in:
@@ -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,333 @@
|
||||
import h from "../../../libs/snabbdom/src/h";
|
||||
import sdAttrs from "../../../libs/snabbdom/src/modules/attributes";
|
||||
import sdListeners from "../../../libs/snabbdom/src/modules/eventlisteners";
|
||||
import { init } from "../../../libs/snabbdom/src/snabbdom";
|
||||
import { VNode } from "../../../libs/snabbdom/src/vnode";
|
||||
import { EventBus } from "./event_bus";
|
||||
import { QWeb } from "./qweb_vdom";
|
||||
import { idGenerator } from "./utils";
|
||||
|
||||
let getId = idGenerator();
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Types/helpers
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
export interface WEnv {
|
||||
qweb: QWeb;
|
||||
}
|
||||
|
||||
let wl: any[] = [];
|
||||
(<any>window).wl = wl;
|
||||
|
||||
export interface Meta<T extends WEnv, Props> {
|
||||
readonly id: number;
|
||||
vnode: VNode | null;
|
||||
isStarted: boolean;
|
||||
isMounted: boolean;
|
||||
isDestroyed: boolean;
|
||||
parent: Component<T, any, any> | null;
|
||||
children: { [key: number]: Component<T, any, any> };
|
||||
// children mapping: from templateID to widgetID
|
||||
// should it be a map number => Widget?
|
||||
cmap: { [key: number]: number };
|
||||
|
||||
renderId: number;
|
||||
renderProps: Props | null;
|
||||
renderPromise: Promise<VNode> | null;
|
||||
boundHandlers: { [key: number]: any };
|
||||
}
|
||||
|
||||
const patch = init([sdListeners, sdAttrs]);
|
||||
|
||||
export interface Type<T> extends Function {
|
||||
new (...args: any[]): T;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Widget
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
export class Component<
|
||||
T extends WEnv,
|
||||
Props,
|
||||
State extends {}
|
||||
> extends EventBus {
|
||||
readonly __widget__: Meta<WEnv, Props>;
|
||||
template: string = "default";
|
||||
inlineTemplate: string | null = null;
|
||||
|
||||
get el(): HTMLElement | null {
|
||||
return this.__widget__.vnode ? (<any>this).__widget__.vnode.elm : null;
|
||||
}
|
||||
|
||||
env: T;
|
||||
state: State = <State>{};
|
||||
props: Props;
|
||||
refs: {
|
||||
[key: string]: Component<T, any, any> | HTMLElement | undefined;
|
||||
} = {};
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
constructor(parent: Component<T, any, any> | T, props?: Props) {
|
||||
super();
|
||||
wl.push(this);
|
||||
|
||||
// is this a good idea?
|
||||
// Pro: if props is empty, we can create easily a widget
|
||||
// Con: this is not really safe
|
||||
// Pro: but creating widget (by a template) is always unsafe anyway
|
||||
this.props = <Props>props;
|
||||
let id: number;
|
||||
let p: Component<T, any, any> | null = null;
|
||||
if (parent instanceof Component) {
|
||||
p = parent;
|
||||
this.env = parent.env;
|
||||
id = getId();
|
||||
parent.__widget__.children[id] = this;
|
||||
} else {
|
||||
this.env = parent;
|
||||
id = getId();
|
||||
}
|
||||
this.__widget__ = {
|
||||
id: id,
|
||||
vnode: null,
|
||||
isStarted: false,
|
||||
isMounted: false,
|
||||
isDestroyed: false,
|
||||
parent: p,
|
||||
children: {},
|
||||
cmap: {},
|
||||
renderId: 1,
|
||||
renderPromise: null,
|
||||
renderProps: props || null,
|
||||
boundHandlers: {}
|
||||
};
|
||||
}
|
||||
|
||||
async willStart() {}
|
||||
|
||||
mounted() {}
|
||||
|
||||
shouldUpdate(nextProps: Props): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
willUnmount() {}
|
||||
|
||||
destroyed() {}
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attach a child widget to a given html element
|
||||
*
|
||||
* This is most of the time not necessary, since widgets should primarily be
|
||||
* created/managed with the t-widget directive in a qweb template. However,
|
||||
* for the cases where we need more control, this method will do what is
|
||||
* necessary to make sure all the proper hooks are called (for example,
|
||||
* mounted/willUnmount)
|
||||
*
|
||||
* Note that this method makes a few assumptions:
|
||||
* - the child widget is indeed a child of the current widget
|
||||
* - the target is inside the dom of the current widget (typically a ref)
|
||||
*/
|
||||
attachChild(child: Component<T, any, any>, target: HTMLElement) {
|
||||
target.appendChild(child.el!);
|
||||
child.__mount();
|
||||
}
|
||||
async mount(target: HTMLElement): Promise<void> {
|
||||
const vnode = await this._start();
|
||||
if (this.__widget__.isDestroyed) {
|
||||
// widget was destroyed before we get here...
|
||||
return;
|
||||
}
|
||||
this._patch(vnode);
|
||||
target.appendChild(this.el!);
|
||||
|
||||
if (document.body.contains(target)) {
|
||||
this.visitSubTree(w => {
|
||||
if (!w.__widget__.isMounted && this.el!.contains(w.el)) {
|
||||
w.__widget__.isMounted = true;
|
||||
w.mounted();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
detach() {
|
||||
if (this.el) {
|
||||
this.visitSubTree(w => {
|
||||
if (w.__widget__.isMounted) {
|
||||
w.willUnmount();
|
||||
w.__widget__.isMounted = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.__widget__.isDestroyed) {
|
||||
for (let id in this.__widget__.children) {
|
||||
this.__widget__.children[id].destroy();
|
||||
}
|
||||
if (this.__widget__.isMounted) {
|
||||
this.willUnmount();
|
||||
}
|
||||
if (this.el) {
|
||||
this.el.remove();
|
||||
this.__widget__.isMounted = false;
|
||||
delete this.__widget__.vnode;
|
||||
}
|
||||
if (this.__widget__.parent) {
|
||||
let id = this.__widget__.id;
|
||||
delete this.__widget__.parent.__widget__.children[id];
|
||||
this.__widget__.parent = null;
|
||||
}
|
||||
this.clear();
|
||||
this.__widget__.isDestroyed = true;
|
||||
this.destroyed();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the safest update method for widget: its job is to update the state
|
||||
* and rerender (if widget is mounted).
|
||||
*
|
||||
* Notes:
|
||||
* - it checks if we do not add extra keys to the state.
|
||||
* - it is ok to call updateState before the widget is started. In that
|
||||
* case, it will simply update the state and will not rerender
|
||||
*/
|
||||
async updateState(nextState: Partial<State>) {
|
||||
if (Object.keys(nextState).length === 0) {
|
||||
return;
|
||||
}
|
||||
Object.assign(this.state, nextState);
|
||||
if (this.__widget__.isStarted) {
|
||||
return this.render();
|
||||
}
|
||||
}
|
||||
|
||||
async updateProps(nextProps: Props): Promise<void> {
|
||||
if (nextProps === this.__widget__.renderProps) {
|
||||
await this.__widget__.renderPromise;
|
||||
return;
|
||||
}
|
||||
const shouldUpdate = this.shouldUpdate(nextProps);
|
||||
return shouldUpdate ? this._updateProps(nextProps) : Promise.resolve();
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
async _updateProps(nextProps: Props): Promise<void> {
|
||||
this.props = nextProps;
|
||||
return this.render();
|
||||
}
|
||||
|
||||
async render(): Promise<void> {
|
||||
if (this.__widget__.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const renderVDom = this._render();
|
||||
const renderId = this.__widget__.renderId;
|
||||
const vnode = await renderVDom;
|
||||
if (renderId === this.__widget__.renderId) {
|
||||
// we only update the vnode and the actual DOM if no other rendering
|
||||
// occurred between now and when the render method was initially called.
|
||||
this._patch(vnode);
|
||||
}
|
||||
}
|
||||
|
||||
_patch(vnode) {
|
||||
this.__widget__.renderPromise = null;
|
||||
this.__widget__.vnode = patch(
|
||||
this.__widget__.vnode || document.createElement(vnode.sel!),
|
||||
vnode
|
||||
);
|
||||
}
|
||||
async _start(): Promise<VNode> {
|
||||
this.__widget__.renderProps = this.props;
|
||||
this.__widget__.renderPromise = this.willStart().then(() => {
|
||||
if (this.__widget__.isDestroyed) {
|
||||
return Promise.resolve(h("div"));
|
||||
}
|
||||
this.__widget__.isStarted = true;
|
||||
if (this.inlineTemplate) {
|
||||
this.env.qweb.addTemplate(
|
||||
this.inlineTemplate,
|
||||
this.inlineTemplate,
|
||||
true
|
||||
);
|
||||
}
|
||||
return this._render();
|
||||
});
|
||||
return this.__widget__.renderPromise;
|
||||
}
|
||||
|
||||
async _render(): Promise<VNode> {
|
||||
this.__widget__.renderId++;
|
||||
const promises: Promise<void>[] = [];
|
||||
const template = this.inlineTemplate || this.template;
|
||||
let vnode = this.env.qweb.render(template, this, {
|
||||
promises,
|
||||
handlers: this.__widget__.boundHandlers
|
||||
});
|
||||
|
||||
// this part is critical for the patching process to be done correctly. The
|
||||
// tricky part is that a child widget can be rerendered on its own, which
|
||||
// will update its own vnode representation without the knowledge of the
|
||||
// parent widget. With this, we make sure that the parent widget will be
|
||||
// able to patch itself properly after
|
||||
vnode.key = this.__widget__.id;
|
||||
this.__widget__.renderProps = this.props;
|
||||
this.__widget__.renderPromise = Promise.all(promises).then(() => vnode);
|
||||
return this.__widget__.renderPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only called by qweb t-widget directive
|
||||
*/
|
||||
_mount(vnode: VNode, elm: HTMLElement): VNode {
|
||||
this.__widget__.vnode = patch(elm, vnode);
|
||||
this.__mount();
|
||||
return this.__widget__.vnode;
|
||||
}
|
||||
|
||||
__mount() {
|
||||
if (this.__widget__.isMounted) {
|
||||
return;
|
||||
}
|
||||
if (this.__widget__.parent) {
|
||||
if (this.__widget__.parent!.__widget__.isMounted) {
|
||||
this.__widget__.isMounted = true;
|
||||
this.mounted();
|
||||
const children = this.__widget__.children;
|
||||
for (let id in children) {
|
||||
children[id].__mount();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visitSubTree(callback: (w: Component<T, any, any>) => boolean) {
|
||||
const shouldVisitChildren = callback(this);
|
||||
if (shouldVisitChildren) {
|
||||
const children = this.__widget__.children;
|
||||
for (let id in children) {
|
||||
children[id].visitSubTree(callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* We define here a simple event bus: it can
|
||||
* - emit events
|
||||
* - add/remove listeners.
|
||||
*
|
||||
* This is a useful pattern of communication in some cases.
|
||||
*/
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Types
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
export type Callback = (...args: any[]) => void;
|
||||
|
||||
export interface Subscription {
|
||||
owner: any;
|
||||
callback: Callback;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// EventBus
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
export class EventBus {
|
||||
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) {
|
||||
if (!callback) {
|
||||
throw new Error("Missing callback");
|
||||
}
|
||||
if (!this.subscriptions[eventType]) {
|
||||
this.subscriptions[eventType] = [];
|
||||
}
|
||||
this.subscriptions[eventType].push({
|
||||
owner,
|
||||
callback
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a listener
|
||||
*/
|
||||
off(eventType: string, owner: any) {
|
||||
const subs = this.subscriptions[eventType];
|
||||
if (subs) {
|
||||
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[]) {
|
||||
const subs = this.subscriptions[eventType] || [];
|
||||
for (let sub of subs) {
|
||||
sub.callback.call(sub.owner, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all subscriptions.
|
||||
*/
|
||||
clear() {
|
||||
this.subscriptions = {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { QWeb } from "./qweb_vdom";
|
||||
import { EventBus } from "./event_bus";
|
||||
import { Component } from "./component";
|
||||
|
||||
export const core = { QWeb, EventBus, Component };
|
||||
@@ -0,0 +1,939 @@
|
||||
import h from "../../../libs/snabbdom/src/h";
|
||||
import { VNode } from "../../../libs/snabbdom/src/vnode";
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Types
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
export type EvalContext = { [key: string]: any };
|
||||
export type RawTemplate = string;
|
||||
export type CompiledTemplate<T> = (context: EvalContext, extra: any) => T;
|
||||
type ProcessedTemplate = Element;
|
||||
|
||||
const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,typeof,eval,void,Math,RegExp,Array,Object,Date".split(
|
||||
","
|
||||
);
|
||||
|
||||
const WORD_REPLACEMENT = {
|
||||
and: "&&",
|
||||
or: "||",
|
||||
gt: ">",
|
||||
gte: ">=",
|
||||
lt: "<",
|
||||
lte: "<="
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Compilation Context
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
export class Context {
|
||||
nextID: number = 1;
|
||||
code: string[] = [];
|
||||
variables: { [key: string]: any } = {};
|
||||
escaping: boolean = false;
|
||||
parentNode: number | null = null;
|
||||
rootNode: number | null = null;
|
||||
indentLevel: number = 0;
|
||||
rootContext: Context;
|
||||
caller: Element | undefined;
|
||||
shouldDefineOwner: boolean = false;
|
||||
shouldProtectContext: boolean = false;
|
||||
inLoop: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.rootContext = this;
|
||||
this.addLine("let h = this.utils.h;");
|
||||
}
|
||||
|
||||
generateID(): number {
|
||||
const id = this.rootContext.nextID++;
|
||||
return id;
|
||||
}
|
||||
|
||||
withParent(node: number): Context {
|
||||
const newContext: Context = Object.create(this);
|
||||
if (this === this.rootContext && this.parentNode) {
|
||||
throw new Error("A template should not have more than one root node");
|
||||
}
|
||||
newContext.parentNode = node;
|
||||
if (!this.rootContext.rootNode) {
|
||||
this.rootContext.rootNode = node;
|
||||
}
|
||||
return newContext;
|
||||
}
|
||||
|
||||
withVariables(variables: { [key: string]: any }) {
|
||||
const newContext = Object.create(this);
|
||||
newContext.variables = Object.create(variables);
|
||||
return newContext;
|
||||
}
|
||||
|
||||
withInLoop(): Context {
|
||||
const newContext = Object.create(this);
|
||||
newContext.inLoop = true;
|
||||
return newContext;
|
||||
}
|
||||
|
||||
withCaller(node: Element): Context {
|
||||
const newContext = Object.create(this);
|
||||
newContext.caller = node;
|
||||
return newContext;
|
||||
}
|
||||
|
||||
withEscaping(): Context {
|
||||
const newContext = Object.create(this);
|
||||
newContext.escaping = true;
|
||||
return newContext;
|
||||
}
|
||||
|
||||
indent() {
|
||||
this.indentLevel++;
|
||||
}
|
||||
|
||||
dedent() {
|
||||
this.indentLevel--;
|
||||
}
|
||||
|
||||
addLine(line: string) {
|
||||
const prefix = new Array(this.indentLevel + 2).join(" ");
|
||||
this.code.push(prefix + line);
|
||||
}
|
||||
|
||||
addIf(condition: string) {
|
||||
this.addLine(`if (${condition}) {`);
|
||||
this.indent();
|
||||
}
|
||||
|
||||
addElse() {
|
||||
this.dedent();
|
||||
this.addLine("} else {");
|
||||
this.indent();
|
||||
}
|
||||
|
||||
closeIf() {
|
||||
this.dedent();
|
||||
this.addLine("}");
|
||||
}
|
||||
|
||||
getValue(val: any): any {
|
||||
return val in this.variables ? this.getValue(this.variables[val]) : val;
|
||||
}
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// QWeb rendering engine
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
export class QWeb {
|
||||
processedTemplates: { [name: string]: ProcessedTemplate } = {};
|
||||
templates: { [name: string]: CompiledTemplate<VNode> } = {};
|
||||
exprCache: { [key: string]: string } = {};
|
||||
directives: Directive[] = [];
|
||||
|
||||
constructor() {
|
||||
[
|
||||
forEachDirective,
|
||||
escDirective,
|
||||
rawDirective,
|
||||
setDirective,
|
||||
elseDirective,
|
||||
elifDirective,
|
||||
ifDirective,
|
||||
callDirective,
|
||||
onDirective,
|
||||
refDirective,
|
||||
widgetDirective
|
||||
].forEach(d => this.addDirective(d));
|
||||
}
|
||||
|
||||
utils = {
|
||||
h: h,
|
||||
getFragment(str: string): DocumentFragment {
|
||||
const temp = document.createElement("template");
|
||||
temp.innerHTML = str;
|
||||
return temp.content;
|
||||
}
|
||||
};
|
||||
|
||||
addDirective(dir: Directive) {
|
||||
this.directives.push(dir);
|
||||
this.directives.sort((d1, d2) => d1.priority - d2.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a template to the internal template map. Note that it is not
|
||||
* immediately compiled.
|
||||
*/
|
||||
addTemplate(
|
||||
name: string,
|
||||
template: RawTemplate,
|
||||
allowDuplicates: boolean = false
|
||||
) {
|
||||
if (name in this.processedTemplates) {
|
||||
if (allowDuplicates) {
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Template ${name} already defined`);
|
||||
}
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(template, "text/xml");
|
||||
if (!doc.firstChild) {
|
||||
throw new Error("Invalid template (should not be empty)");
|
||||
}
|
||||
if (doc.getElementsByTagName("parsererror").length) {
|
||||
throw new Error("Invalid XML in template");
|
||||
}
|
||||
let elem = doc.firstChild as Element;
|
||||
this._processTemplate(elem);
|
||||
|
||||
this.processedTemplates[name] = elem;
|
||||
}
|
||||
|
||||
_processTemplate(elem: Element) {
|
||||
let tbranch = elem.querySelectorAll("[t-elif], [t-else]");
|
||||
for (let i = 0, ilen = tbranch.length; i < ilen; i++) {
|
||||
let node = tbranch[i];
|
||||
let prevElem = node.previousElementSibling!;
|
||||
let pattr = function(name) {
|
||||
return prevElem.getAttribute(name);
|
||||
};
|
||||
let nattr = function(name) {
|
||||
return +!!node.getAttribute(name);
|
||||
};
|
||||
if (prevElem && (pattr("t-if") || pattr("t-elif"))) {
|
||||
if (pattr("t-foreach")) {
|
||||
throw new Error(
|
||||
"t-if cannot stay at the same level as t-foreach when using t-elif or t-else"
|
||||
);
|
||||
}
|
||||
if (
|
||||
["t-if", "t-elif", "t-else"].map(nattr).reduce(function(a, b) {
|
||||
return a + b;
|
||||
}) > 1
|
||||
) {
|
||||
throw new Error(
|
||||
"Only one conditional branching directive is allowed per node"
|
||||
);
|
||||
}
|
||||
// All text nodes between branch nodes are removed
|
||||
let textNode;
|
||||
while ((textNode = node.previousSibling) !== prevElem) {
|
||||
if (textNode.nodeValue.trim().length) {
|
||||
throw new Error("text is not allowed between branching directives");
|
||||
}
|
||||
textNode.remove();
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"t-elif and t-else directives must be preceded by a t-if or t-elif directive"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Load templates from a xml (as a string). This will look up for the first
|
||||
* <templates> tag, and will consider each child of this as a template, with
|
||||
* the name given by the t-name attribute.
|
||||
*/
|
||||
loadTemplates(xmlstr: string) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xmlstr, "text/xml");
|
||||
const templates = doc.getElementsByTagName("templates")[0];
|
||||
if (!templates) {
|
||||
return;
|
||||
}
|
||||
for (let elem of <any>templates.children) {
|
||||
const name = elem.getAttribute("t-name");
|
||||
this._processTemplate(elem);
|
||||
this.processedTemplates[name] = elem;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Render a template
|
||||
*
|
||||
* @param {string} name the template should already have been added
|
||||
*/
|
||||
render(name: string, context: EvalContext = {}, extra: any = null): VNode {
|
||||
if (!(name in this.processedTemplates)) {
|
||||
throw new Error(`Template ${name} does not exist`);
|
||||
}
|
||||
const template = this.templates[name] || this._compile(name);
|
||||
return template.call(this, context, extra);
|
||||
}
|
||||
|
||||
_compile(name: string): CompiledTemplate<VNode> {
|
||||
if (name in this.templates) {
|
||||
return this.templates[name];
|
||||
}
|
||||
|
||||
const mainNode = this.processedTemplates[name];
|
||||
const isDebug = (<Element>mainNode).attributes.hasOwnProperty("t-debug");
|
||||
const ctx = new Context();
|
||||
this._compileNode(mainNode, ctx);
|
||||
|
||||
if (ctx.shouldProtectContext) {
|
||||
ctx.code.unshift(" context = Object.create(context);");
|
||||
}
|
||||
if (ctx.shouldDefineOwner) {
|
||||
// this is necessary to prevent some directives (t-forach for ex) to
|
||||
// pollute the rendering context by adding some keys in it.
|
||||
ctx.code.unshift(" let owner = context;");
|
||||
}
|
||||
|
||||
if (!ctx.rootNode) {
|
||||
throw new Error("A template should have one root node");
|
||||
}
|
||||
ctx.addLine(`return vn${ctx.rootNode};`);
|
||||
if (isDebug) {
|
||||
ctx.code.unshift(" debugger");
|
||||
}
|
||||
let template;
|
||||
try {
|
||||
template = new Function(
|
||||
"context",
|
||||
"extra",
|
||||
ctx.code.join("\n")
|
||||
) as CompiledTemplate<VNode>;
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid template (or compiled code): ${e.message}`);
|
||||
}
|
||||
if (isDebug) {
|
||||
console.log(
|
||||
`Template: ${
|
||||
this.processedTemplates[name].outerHTML
|
||||
}\nCompiled code:\n` + template.toString()
|
||||
);
|
||||
}
|
||||
this.templates[name] = template;
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code from an xml node
|
||||
*
|
||||
*/
|
||||
_compileNode(node: ChildNode, ctx: Context) {
|
||||
if (!(node instanceof Element)) {
|
||||
// this is a text node, there are no directive to apply
|
||||
let text = node.textContent!;
|
||||
if (ctx.parentNode) {
|
||||
ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`});`);
|
||||
} else {
|
||||
// this is an unusual situation: this text node is the result of the
|
||||
// template rendering.
|
||||
let nodeID = ctx.generateID();
|
||||
ctx.addLine(`let vn${nodeID} = {text: \`${text}\`};`);
|
||||
ctx.rootContext.rootNode = nodeID;
|
||||
ctx.rootContext.parentNode = nodeID;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const attributes = (<Element>node).attributes;
|
||||
|
||||
const validDirectives: {
|
||||
directive: Directive;
|
||||
value: string;
|
||||
fullName: string;
|
||||
}[] = [];
|
||||
|
||||
let withHandlers = false;
|
||||
|
||||
for (let directive of this.directives) {
|
||||
// const value = attributes[i].textContent!;
|
||||
let fullName;
|
||||
let value;
|
||||
for (let i = 0; i < attributes.length; i++) {
|
||||
const name = attributes[i].name;
|
||||
if (
|
||||
name === "t-" + directive.name ||
|
||||
name.startsWith("t-" + directive.name + "-")
|
||||
) {
|
||||
fullName = name;
|
||||
value = attributes[i].textContent;
|
||||
validDirectives.push({ directive, value, fullName });
|
||||
if (directive.name === "on") {
|
||||
withHandlers = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let { directive, value, fullName } of validDirectives) {
|
||||
if (directive.atNodeEncounter) {
|
||||
const isDone = directive.atNodeEncounter({
|
||||
node,
|
||||
qweb: this,
|
||||
ctx,
|
||||
fullName,
|
||||
value
|
||||
});
|
||||
if (isDone) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.nodeName !== "t") {
|
||||
let nodeID = this._compileGenericNode(node, ctx, withHandlers);
|
||||
ctx = ctx.withParent(nodeID);
|
||||
|
||||
for (let { directive, value, fullName } of validDirectives) {
|
||||
if (directive.atNodeCreation) {
|
||||
directive.atNodeCreation({
|
||||
node,
|
||||
qweb: this,
|
||||
ctx,
|
||||
fullName,
|
||||
value,
|
||||
nodeID
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._compileChildren(node, ctx);
|
||||
|
||||
for (let { directive, value, fullName } of validDirectives) {
|
||||
if (directive.finalize) {
|
||||
directive.finalize({ node, qweb: this, ctx, fullName, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_compileGenericNode(
|
||||
node: ChildNode,
|
||||
ctx: Context,
|
||||
withHandlers: boolean = true
|
||||
): number {
|
||||
// nodeType 1 is generic tag
|
||||
if (node.nodeType !== 1) {
|
||||
throw new Error("unsupported node type");
|
||||
}
|
||||
const attributes = (<Element>node).attributes;
|
||||
const attrs: string[] = [];
|
||||
const tattrs: number[] = [];
|
||||
for (let i = 0; i < attributes.length; i++) {
|
||||
let name = attributes[i].name;
|
||||
const value = attributes[i].textContent!;
|
||||
|
||||
// regular attributes
|
||||
if (!name.startsWith("t-")) {
|
||||
const attID = ctx.generateID();
|
||||
ctx.addLine(`let _${attID} = '${value}';`);
|
||||
if (!name.match(/^[a-zA-Z]+$/)) {
|
||||
// attribute contains 'non letters' => we want to quote it
|
||||
name = '"' + name + '"';
|
||||
}
|
||||
attrs.push(`${name}: _${attID}`);
|
||||
}
|
||||
|
||||
// dynamic attributes
|
||||
if (name.startsWith("t-att-")) {
|
||||
let attName = name.slice(6);
|
||||
let formattedValue = this._formatExpression(ctx.getValue(value!));
|
||||
const attID = ctx.generateID();
|
||||
if (!attName.match(/^[a-zA-Z]+$/)) {
|
||||
// attribute contains 'non letters' => we want to quote it
|
||||
attName = '"' + attName + '"';
|
||||
}
|
||||
// we need to combine dynamic with non dynamic attributes:
|
||||
// class="a" t-att-class="'yop'" should be rendered as class="a yop"
|
||||
const attValue = (<Element>node).getAttribute(attName);
|
||||
if (attValue) {
|
||||
const attValueID = ctx.generateID();
|
||||
ctx.addLine(`let _${attValueID} = ${formattedValue};`);
|
||||
formattedValue = `'${attValue}' + (_${attValueID} ? ' ' + _${attValueID} : '')`;
|
||||
}
|
||||
ctx.addLine(`let _${attID} = ${formattedValue};`);
|
||||
attrs.push(`${attName}: _${attID}`);
|
||||
}
|
||||
|
||||
if (name.startsWith("t-attf-")) {
|
||||
let attName = name.slice(7);
|
||||
if (!attName.match(/^[a-zA-Z]+$/)) {
|
||||
// attribute contains 'non letters' => we want to quote it
|
||||
attName = '"' + attName + '"';
|
||||
}
|
||||
const formattedExpr = value!.replace(
|
||||
/\{\{.*?\}\}/g,
|
||||
s => "${" + this._formatExpression(s.slice(2, -2)) + "}"
|
||||
);
|
||||
const attID = ctx.generateID();
|
||||
ctx.addLine(`let _${attID} = \`${formattedExpr}\`;`);
|
||||
attrs.push(`${attName}: _${attID}`);
|
||||
}
|
||||
|
||||
// t-att= attributes
|
||||
if (name === "t-att") {
|
||||
let id = ctx.generateID();
|
||||
ctx.addLine(`let _${id} = ${this._formatExpression(value!)};`);
|
||||
tattrs.push(id);
|
||||
}
|
||||
}
|
||||
let nodeID = ctx.generateID();
|
||||
const parts = [`key:${nodeID}`];
|
||||
if (attrs.length + tattrs.length > 0) {
|
||||
parts.push(`attrs:{${attrs.join(",")}}`);
|
||||
}
|
||||
if (withHandlers) {
|
||||
parts.push(`on:{}`);
|
||||
}
|
||||
|
||||
ctx.addLine(`let c${nodeID} = [], p${nodeID} = {${parts.join(",")}};`);
|
||||
for (let id of tattrs) {
|
||||
ctx.addIf(`_${id} instanceof Array`);
|
||||
ctx.addLine(`p${nodeID}.attrs[_${id}[0]] = _${id}[1];`);
|
||||
ctx.addElse();
|
||||
ctx.addLine(`for (let key in _${id}) {`);
|
||||
ctx.indent();
|
||||
ctx.addLine(`p${nodeID}.attrs[key] = _${id}[key];`);
|
||||
ctx.dedent();
|
||||
ctx.addLine(`}`);
|
||||
ctx.closeIf();
|
||||
}
|
||||
ctx.addLine(
|
||||
`let vn${nodeID} = h('${node.nodeName}', p${nodeID}, c${nodeID});`
|
||||
);
|
||||
if (ctx.parentNode) {
|
||||
ctx.addLine(`c${ctx.parentNode}.push(vn${nodeID});`);
|
||||
}
|
||||
|
||||
return nodeID;
|
||||
}
|
||||
|
||||
_compileChildren(node: ChildNode, ctx: Context) {
|
||||
if (node.childNodes.length > 0) {
|
||||
for (let child of Array.from(node.childNodes)) {
|
||||
this._compileNode(child, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_formatExpression(e: string): string {
|
||||
if (e in this.exprCache) {
|
||||
return this.exprCache[e];
|
||||
}
|
||||
// Thanks CHM for this code...
|
||||
const chars = e.split("");
|
||||
let instring = "";
|
||||
let invar = "";
|
||||
let invarPos = 0;
|
||||
let r = "";
|
||||
chars.push(" ");
|
||||
for (var i = 0, ilen = chars.length; i < ilen; i++) {
|
||||
var c = chars[i];
|
||||
if (instring.length) {
|
||||
if (c === instring && chars[i - 1] !== "\\") {
|
||||
instring = "";
|
||||
}
|
||||
} else if (c === '"' || c === "'") {
|
||||
instring = c;
|
||||
} else if (c.match(/[a-zA-Z_\$]/) && !invar.length) {
|
||||
invar = c;
|
||||
invarPos = i;
|
||||
continue;
|
||||
} else if (c.match(/\W/) && invar.length) {
|
||||
// TODO: Should check for possible spaces before dot
|
||||
if (chars[invarPos - 1] !== "." && RESERVED_WORDS.indexOf(invar) < 0) {
|
||||
invar = WORD_REPLACEMENT[invar] || "context['" + invar + "']";
|
||||
}
|
||||
r += invar;
|
||||
invar = "";
|
||||
} else if (invar.length) {
|
||||
invar += c;
|
||||
continue;
|
||||
}
|
||||
r += c;
|
||||
}
|
||||
const result = r.slice(0, -1);
|
||||
this.exprCache[e] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// QWeb Directives
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
interface CompilationInfo {
|
||||
nodeID?: number;
|
||||
node: Element;
|
||||
qweb: QWeb;
|
||||
ctx: Context;
|
||||
fullName: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Directive {
|
||||
name: string;
|
||||
priority: number;
|
||||
// if return true, then directive is fully applied and there is no need to
|
||||
// keep processing node. Otherwise, we keep going.
|
||||
atNodeEncounter?(info: CompilationInfo): boolean;
|
||||
atNodeCreation?(info: CompilationInfo): void;
|
||||
finalize?(info: CompilationInfo): void;
|
||||
}
|
||||
|
||||
function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Context) {
|
||||
if (value === "0" && ctx.caller) {
|
||||
qweb._compileNode(ctx.caller, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const exprID = ctx.generateID();
|
||||
ctx.addLine(`let e${exprID} = ${qweb._formatExpression(value)};`);
|
||||
ctx.addIf(`e${exprID} || e${exprID} === 0`);
|
||||
let text = `e${exprID}`;
|
||||
|
||||
if (!ctx.parentNode) {
|
||||
throw new Error("Should not have a text node without a parent");
|
||||
}
|
||||
if (ctx.escaping) {
|
||||
ctx.addLine(`c${ctx.parentNode}.push({text: ${text}});`);
|
||||
} else {
|
||||
let fragID = ctx.generateID();
|
||||
ctx.addLine(`let frag${fragID} = this.utils.getFragment(e${exprID})`);
|
||||
let tempNodeID = ctx.generateID();
|
||||
ctx.addLine(`let p${tempNodeID} = {hook: {`);
|
||||
ctx.addLine(
|
||||
` insert: n => n.elm.parentNode.replaceChild(frag${fragID}, n.elm),`
|
||||
);
|
||||
ctx.addLine(`}};`);
|
||||
ctx.addLine(`let vn${tempNodeID} = h('div', p${tempNodeID})`);
|
||||
ctx.addLine(`c${ctx.parentNode}.push(vn${tempNodeID});`);
|
||||
}
|
||||
if (node.childNodes.length) {
|
||||
ctx.addElse();
|
||||
qweb._compileChildren(node, ctx);
|
||||
}
|
||||
ctx.closeIf();
|
||||
return;
|
||||
}
|
||||
if (value instanceof NodeList) {
|
||||
for (let node of Array.from(value)) {
|
||||
qweb._compileNode(<ChildNode>node, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const escDirective: Directive = {
|
||||
name: "esc",
|
||||
priority: 70,
|
||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||
if (node.nodeName !== "t") {
|
||||
let nodeID = qweb._compileGenericNode(node, ctx);
|
||||
ctx = ctx.withParent(nodeID);
|
||||
}
|
||||
let value = ctx.getValue(node.getAttribute("t-esc")!);
|
||||
compileValueNode(value, node, qweb, ctx.withEscaping());
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const rawDirective: Directive = {
|
||||
name: "raw",
|
||||
priority: 80,
|
||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||
if (node.nodeName !== "t") {
|
||||
let nodeID = qweb._compileGenericNode(node, ctx);
|
||||
ctx = ctx.withParent(nodeID);
|
||||
}
|
||||
let value = ctx.getValue(node.getAttribute("t-raw")!);
|
||||
compileValueNode(value, node, qweb, ctx);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const setDirective: Directive = {
|
||||
name: "set",
|
||||
priority: 60,
|
||||
atNodeEncounter({ node, ctx }): boolean {
|
||||
const variable = node.getAttribute("t-set")!;
|
||||
let value = node.getAttribute("t-value")!;
|
||||
if (value) {
|
||||
ctx.variables[variable] = value;
|
||||
} else {
|
||||
ctx.variables[variable] = node.childNodes;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const ifDirective: Directive = {
|
||||
name: "if",
|
||||
priority: 20,
|
||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||
let cond = ctx.getValue(node.getAttribute("t-if")!);
|
||||
ctx.addIf(`${qweb._formatExpression(cond)}`);
|
||||
return false;
|
||||
},
|
||||
finalize({ ctx }) {
|
||||
ctx.closeIf();
|
||||
}
|
||||
};
|
||||
|
||||
const elifDirective: Directive = {
|
||||
name: "elif",
|
||||
priority: 30,
|
||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||
let cond = ctx.getValue(node.getAttribute("t-elif")!);
|
||||
ctx.addLine(`else if (${qweb._formatExpression(cond)}) {`);
|
||||
ctx.indent();
|
||||
return false;
|
||||
},
|
||||
finalize({ ctx }) {
|
||||
ctx.closeIf();
|
||||
}
|
||||
};
|
||||
|
||||
const elseDirective: Directive = {
|
||||
name: "else",
|
||||
priority: 40,
|
||||
atNodeEncounter({ ctx }): boolean {
|
||||
ctx.addLine(`else {`);
|
||||
ctx.indent();
|
||||
return false;
|
||||
},
|
||||
finalize({ ctx }) {
|
||||
ctx.closeIf();
|
||||
}
|
||||
};
|
||||
|
||||
const callDirective: Directive = {
|
||||
name: "call",
|
||||
priority: 50,
|
||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||
if (node.nodeName !== "t") {
|
||||
throw new Error("Invalid tag for t-call directive (should be 't')");
|
||||
}
|
||||
const subTemplate = node.getAttribute("t-call")!;
|
||||
const nodeTemplate = qweb.processedTemplates[subTemplate];
|
||||
if (!nodeTemplate) {
|
||||
throw new Error(`Cannot find template "${subTemplate}" (t-call)`);
|
||||
}
|
||||
const nodeCopy = node.cloneNode(true) as Element;
|
||||
nodeCopy.removeAttribute("t-call");
|
||||
|
||||
// extract variables from nodecopy
|
||||
const tempCtx = new Context();
|
||||
qweb._compileNode(nodeCopy, tempCtx);
|
||||
const vars = Object.assign({}, ctx.variables, tempCtx.variables);
|
||||
const subCtx = ctx.withCaller(nodeCopy).withVariables(vars);
|
||||
|
||||
qweb._compileNode(nodeTemplate, subCtx);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const forEachDirective: Directive = {
|
||||
name: "foreach",
|
||||
priority: 10,
|
||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||
ctx.rootContext.shouldProtectContext = true;
|
||||
ctx = ctx.withInLoop();
|
||||
const elems = node.getAttribute("t-foreach")!;
|
||||
const name = node.getAttribute("t-as")!;
|
||||
let arrayID = ctx.generateID();
|
||||
ctx.addLine(`let _${arrayID} = ${qweb._formatExpression(elems)};`);
|
||||
ctx.addLine(
|
||||
`if (!_${arrayID}) { throw new Error('QWeb error: Invalid loop expression')}`
|
||||
);
|
||||
ctx.addLine(
|
||||
`if (typeof _${arrayID} === 'number') { _${arrayID} = Array.from(Array(_${arrayID}).keys())}`
|
||||
);
|
||||
let keysID = ctx.generateID();
|
||||
ctx.addLine(
|
||||
`let _${keysID} = _${arrayID} instanceof Array ? _${arrayID} : Object.keys(_${arrayID});`
|
||||
);
|
||||
let valuesID = ctx.generateID();
|
||||
ctx.addLine(
|
||||
`let _${valuesID} = _${arrayID} instanceof Array ? _${arrayID} : Object.values(_${arrayID});`
|
||||
);
|
||||
ctx.addLine(`for (let i = 0; i < _${keysID}.length; i++) {`);
|
||||
ctx.indent();
|
||||
ctx.addLine(`context.${name}_first = i === 0;`);
|
||||
ctx.addLine(`context.${name}_last = i === _${keysID}.length - 1;`);
|
||||
ctx.addLine(`context.${name}_parity = i % 2 === 0 ? 'even' : 'odd';`);
|
||||
ctx.addLine(`context.${name}_index = i;`);
|
||||
ctx.addLine(`context.${name} = _${keysID}[i];`);
|
||||
ctx.addLine(`context.${name}_value = _${valuesID}[i];`);
|
||||
const nodeCopy = <Element>node.cloneNode(true);
|
||||
nodeCopy.removeAttribute("t-foreach");
|
||||
qweb._compileNode(nodeCopy, ctx);
|
||||
ctx.dedent();
|
||||
ctx.addLine("}");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const onDirective: Directive = {
|
||||
name: "on",
|
||||
priority: 90,
|
||||
atNodeCreation({ ctx, fullName, value, nodeID, qweb }) {
|
||||
ctx.rootContext.shouldDefineOwner = true;
|
||||
const eventName = fullName.slice(5);
|
||||
let extraArgs;
|
||||
let handler = value.replace(/\(.*\)/, function(args) {
|
||||
extraArgs = args.slice(1, -1);
|
||||
return "";
|
||||
});
|
||||
if (extraArgs) {
|
||||
ctx.addLine(
|
||||
`p${nodeID}.on['${eventName}'] = context['${handler}'].bind(owner, ${qweb._formatExpression(
|
||||
extraArgs
|
||||
)});`
|
||||
);
|
||||
} else {
|
||||
ctx.addLine(
|
||||
`extra.handlers['${eventName}' + ${nodeID}] = extra.handlers['${eventName}' + ${nodeID}] || context['${handler}'].bind(owner);`
|
||||
);
|
||||
ctx.addLine(
|
||||
`p${nodeID}.on['${eventName}'] = extra.handlers['${eventName}' + ${nodeID}];`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refDirective: Directive = {
|
||||
name: "ref",
|
||||
priority: 95,
|
||||
atNodeCreation({ ctx, node, nodeID }) {
|
||||
let ref = node.getAttribute("t-ref");
|
||||
ctx.addLine(`p${ctx.parentNode}.hook = {
|
||||
create: (_, n) => context.refs['${ref}'] = n.elm,
|
||||
};`);
|
||||
}
|
||||
};
|
||||
|
||||
const widgetDirective: Directive = {
|
||||
name: "widget",
|
||||
priority: 100,
|
||||
atNodeEncounter({ ctx, value, node, qweb }): boolean {
|
||||
ctx.addLine("//WIDGET");
|
||||
ctx.rootContext.shouldDefineOwner = true;
|
||||
let props = node.getAttribute("t-props");
|
||||
let keepAlive = node.getAttribute("t-keep-alive") ? true : false;
|
||||
|
||||
// t-on- events...
|
||||
const events: [string, string][] = [];
|
||||
const attributes = (<Element>node).attributes;
|
||||
for (let i = 0; i < attributes.length; i++) {
|
||||
const name = attributes[i].name;
|
||||
if (name.startsWith("t-on-")) {
|
||||
events.push([name.slice(5), attributes[i].textContent!]);
|
||||
}
|
||||
}
|
||||
|
||||
let key = node.getAttribute("t-key");
|
||||
if (key) {
|
||||
key = `"${key}"`;
|
||||
} else {
|
||||
key = node.getAttribute("t-att-key");
|
||||
if (key) {
|
||||
key = qweb._formatExpression(key);
|
||||
}
|
||||
}
|
||||
if (props) {
|
||||
props = props.trim();
|
||||
if (props[0] === "{" && props[props.length - 1] === "}") {
|
||||
const innerProp = props
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map(p => {
|
||||
let [key, val] = p.split(":");
|
||||
return `${key}: ${qweb._formatExpression(val)}`;
|
||||
})
|
||||
.join(",");
|
||||
props = "{" + innerProp + "}";
|
||||
} else {
|
||||
props = qweb._formatExpression(props);
|
||||
}
|
||||
}
|
||||
let dummyID = ctx.generateID();
|
||||
let defID = ctx.generateID();
|
||||
let widgetID = ctx.generateID();
|
||||
let keyID = key && ctx.generateID();
|
||||
if (key) {
|
||||
// we bind a variable to the key (could be a complex expression, so we
|
||||
// want to evaluate it only once)
|
||||
ctx.addLine(`let key${keyID} = ${key};`);
|
||||
}
|
||||
ctx.addLine(`let _${dummyID}_index = c${ctx.parentNode}.length;`);
|
||||
ctx.addLine(`c${ctx.parentNode}.push(null);`);
|
||||
ctx.addLine(`let def${defID};`);
|
||||
let templateID = key
|
||||
? `key${keyID}`
|
||||
: ctx.inLoop
|
||||
? `String(-${widgetID} - i)`
|
||||
: String(widgetID);
|
||||
ctx.addLine(
|
||||
`let w${widgetID} = ${templateID} in context.__widget__.cmap ? context.__widget__.children[context.__widget__.cmap[${templateID}]] : false;`
|
||||
);
|
||||
ctx.addLine(`let props${widgetID} = ${props};`);
|
||||
ctx.addLine(`let isNew${widgetID} = !w${widgetID};`);
|
||||
|
||||
// check if we can reuse current rendering promise
|
||||
ctx.addIf(`w${widgetID} && w${widgetID}.__widget__.renderPromise`);
|
||||
ctx.addIf(`w${widgetID}.__widget__.isStarted`);
|
||||
ctx.addLine(`def${defID} = w${widgetID}.updateProps(props${widgetID});`);
|
||||
ctx.addElse();
|
||||
ctx.addLine(`isNew${widgetID} = true`);
|
||||
ctx.addIf(`props${widgetID} === w${widgetID}.__widget__.renderProps`);
|
||||
ctx.addLine(`def${defID} = w${widgetID}.__widget__.renderPromise;`);
|
||||
ctx.addElse();
|
||||
ctx.addLine(`w${widgetID}.destroy();`);
|
||||
ctx.addLine(`w${widgetID} = false`);
|
||||
ctx.closeIf();
|
||||
ctx.closeIf();
|
||||
ctx.closeIf();
|
||||
|
||||
ctx.addIf(`!def${defID}`);
|
||||
ctx.addIf(`w${widgetID}`);
|
||||
ctx.addLine(`def${defID} = w${widgetID}.updateProps(props${widgetID});`);
|
||||
ctx.addElse();
|
||||
ctx.addLine(
|
||||
`w${widgetID} = new context.widgets['${value}'](owner, props${widgetID});`
|
||||
);
|
||||
ctx.addLine(
|
||||
`context.__widget__.cmap[${templateID}] = w${widgetID}.__widget__.id;`
|
||||
);
|
||||
for (let [event, method] of events) {
|
||||
ctx.addLine(`w${widgetID}.on('${event}', owner, owner['${method}'])`);
|
||||
}
|
||||
let ref = node.getAttribute("t-ref");
|
||||
if (ref) {
|
||||
ctx.addLine(`context.refs['${ref}'] = w${widgetID};`);
|
||||
}
|
||||
|
||||
ctx.addLine(`def${defID} = w${widgetID}._start();`);
|
||||
ctx.closeIf();
|
||||
ctx.closeIf();
|
||||
|
||||
ctx.addIf(`isNew${widgetID}`);
|
||||
ctx.addLine(
|
||||
`def${defID} = def${defID}.then(vnode=>{let pvnode=h(vnode.sel, {key: ${templateID}});c${
|
||||
ctx.parentNode
|
||||
}[_${dummyID}_index]=pvnode;pvnode.data.hook = {insert(vn){let nvn=w${widgetID}._mount(vnode, vn.elm);pvnode.elm=nvn.elm},remove(){w${widgetID}.${
|
||||
keepAlive ? "detach" : "destroy"
|
||||
}()}}; w${widgetID}.__widget__.pvnode = pvnode;});`
|
||||
);
|
||||
ctx.addElse();
|
||||
ctx.addLine(
|
||||
`def${defID} = def${defID}.then(()=>{if (w${widgetID}.__widget__.isDestroyed) {return};let vnode;if (!w${widgetID}.__widget__.vnode){vnode=w${widgetID}.__widget__.pvnode} else { vnode=h(w${widgetID}.__widget__.vnode.sel, {key: ${templateID}});vnode.elm=w${widgetID}.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w${widgetID}.el,a.elm);a.elm=w${widgetID}.el;w${widgetID}.__mount();},remove(){w${widgetID}.${
|
||||
keepAlive ? "detach" : "destroy"
|
||||
}()}}}c${ctx.parentNode}[_${dummyID}_index]=vnode;});`
|
||||
);
|
||||
ctx.closeIf();
|
||||
|
||||
ctx.addLine(`extra.promises.push(def${defID});`);
|
||||
|
||||
if (node.getAttribute("t-if") || node.getAttribute("t-else")) {
|
||||
ctx.closeIf();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The registry is basically a simple hashmap. It is only a little safer and
|
||||
* more structured than a simple object.
|
||||
*/
|
||||
export class Registry<T> {
|
||||
private map: { [key: string]: T } = {};
|
||||
|
||||
/**
|
||||
* Add an element to the registry. Note that the add method returns the
|
||||
* registry, to it can be chained.
|
||||
*/
|
||||
add(key: string, item: T): Registry<T> {
|
||||
if (key in this.map) {
|
||||
throw new Error(`Key ${key} already exists!`);
|
||||
}
|
||||
this.map[key] = item;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the element corresponding to the key
|
||||
*
|
||||
* Nothing is done to check that the key actually exists.
|
||||
*/
|
||||
get(key: string): T | undefined {
|
||||
return this.map[key];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export function escape(str: string | number | undefined): string {
|
||||
if (str === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof str === "number") {
|
||||
return String(str);
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "'")
|
||||
.replace(/`/g, "`");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trailing and leading spaces
|
||||
*/
|
||||
export function htmlTrim(s: string): string {
|
||||
let result = s.replace(/(^\s+|\s+$)/g, "");
|
||||
if (s[0] === " ") {
|
||||
result = " " + result;
|
||||
}
|
||||
if (result !== " " && s[s.length - 1] === " ") {
|
||||
result = result + " ";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a function that will generate unique id numbers
|
||||
*/
|
||||
export function idGenerator(): () => number {
|
||||
let nextID = 1;
|
||||
return () => nextID++;
|
||||
}
|
||||
|
||||
export type HashFn = (args: any[]) => string;
|
||||
|
||||
export function memoize<R, T extends (...args: any[]) => R>(
|
||||
f: T,
|
||||
hash?: HashFn
|
||||
): T {
|
||||
if (!hash) {
|
||||
hash = args => args.map(a => String(a)).join(",");
|
||||
}
|
||||
let cache: { [key: string]: R } = {};
|
||||
function memoizedFunction(...args: any[]) {
|
||||
let hashValue = hash!(args);
|
||||
if (!(hashValue in cache)) {
|
||||
cache[hashValue] = f(...args);
|
||||
}
|
||||
return cache[hashValue];
|
||||
}
|
||||
return memoizedFunction as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function, that, as long as it continues to be invoked, will not
|
||||
* be triggered. The function will be called after it stops being called for
|
||||
* N milliseconds. If `immediate` is passed, trigger the function on the
|
||||
* leading edge, instead of the trailing.
|
||||
*
|
||||
* Inspired by https://davidwalsh.name/javascript-debounce-function
|
||||
*/
|
||||
export function debounce(
|
||||
func: Function,
|
||||
wait: number,
|
||||
immediate?: boolean
|
||||
): Function {
|
||||
let timeout;
|
||||
return function(this: any) {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
function later() {
|
||||
timeout = null;
|
||||
if (!immediate) {
|
||||
func.apply(context, args);
|
||||
}
|
||||
}
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
if (callNow) {
|
||||
func.apply(context, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface Tree<T> {
|
||||
children: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a node in a tree.
|
||||
*
|
||||
* This will traverse the tree (depth first) and return the first child that
|
||||
* matches the predicate, if any
|
||||
*/
|
||||
export function findInTree<T extends Tree<T>>(
|
||||
tree: T,
|
||||
predicate: (t: T) => boolean
|
||||
): T | null {
|
||||
if (predicate(tree)) {
|
||||
return tree;
|
||||
}
|
||||
for (let child of tree.children) {
|
||||
let match = findInTree(child, predicate);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -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.updateState({ 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.updateState({ 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.updateState({ counter: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
resetCounterAsync(ev: MouseEvent) {
|
||||
setTimeout(() => {
|
||||
if (this.refs.counter2 instanceof Counter) {
|
||||
this.refs.counter2.updateState({ counter: 300 });
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.updateState({ validcounter: !this.state.validcounter });
|
||||
}
|
||||
|
||||
toggleColor() {
|
||||
const newColor = this.state.color === "red" ? "blue" : "red";
|
||||
this.updateState({ 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>`;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { WEnv } from "./core/component";
|
||||
import { QWeb } from "./core/qweb_vdom";
|
||||
import { Registry } from "./core/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);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { findInTree } from "./core/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 };
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Registry } from "./core/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 "../core/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 "../core/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 "../core/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 };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { EventBus } from "../core/event_bus";
|
||||
import { idGenerator } from "../core/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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { debounce } from "../core/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.updateState);
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Component } from "./core/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 updateState(nextState: Partial<S>) {
|
||||
for (let k in nextState) {
|
||||
if (nextState[k] !== this.state[k]) {
|
||||
return super.updateState(nextState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 && 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 + '&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="updateState({})">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>
|
||||
Reference in New Issue
Block a user