This commit is contained in:
2025-08-19 15:05:55 +07:00
parent 70ae66ee4b
commit f78dc34aee
161 changed files with 13703 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Cheat Module for Odoo Development
> [!WARNING]
> This module is purely experimental and for educational purpose use only.
>
> Do not use it in any environment but in an experimental one, definitely not in a production environment.
>
> I'm not responsible for any damage or harm by the use of anything from this repo.
>
> Use it at your own risk.
> [!CAUTION]
> Do not use this module unless you have reviewed the source codes thoroughly, understand what it does and in an experimental environment.
This module contains codes that are commonly used in Odoo development when using Odoo Web Framework.
This module is not meant to be used in production but to be copied and pasted to the real module.
Please watch this videos for more details:
[![EXPLORING_ODOO](https://img.youtube.com/vi/l4zH2c7b34g/0.jpg)](https://youtu.be/l4zH2c7b34g)
[![EXPLORING_ODOO](https://img.youtube.com/vi/3veS34mM70c/0.jpg)](https://youtu.be/3veS34mM70c)
[![EXPLORING_ODOO](https://img.youtube.com/vi/mM04bjAImOU/0.jpg)](https://youtu.be/mM04bjAImOU)
[![EXPLORING_ODOO](https://img.youtube.com/vi/Lv4UP-Is4Dg/0.jpg)](https://youtu.be/Lv4UP-Is4Dg)
+3
View File
@@ -0,0 +1,3 @@
from . import controllers
from . import models
from . import tools
+28
View File
@@ -0,0 +1,28 @@
# For more details see https://www.odoo.com/documentation/17.0/developer/reference/backend/module.html
{
"name": "Cheat Module for Odoo Web Framework",
# The first 2 numbers are Odoo major version, the last 3 are x.y.z version of the module.
"version": "18.0.1.0.0",
"depends": ["web", "cheat_module", "contacts"],
"author": "Yoni Tjio",
# Categories are freeform, for existing categories visit https://github.com/odoo/odoo/blob/17.0/odoo/addons/base/data/ir_module_category_data.xml
"category": "Customizations",
"description": """
Cheat Module for Odoo Web Framework
""",
# data files always loaded at installation
"data": [
'security/ir.model.access.csv',
'views/cheat_web_views.xml',
'views/contacts_views.xml'
],
"assets": {
"web.assets_backend": [
"cheat_web/static/src/**/*",
],
},
"application": False,
"installable": True,
"auto_install": False,
"license": "Other proprietary",
}
+1
View File
@@ -0,0 +1 @@
from . import main
+24
View File
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from odoo import http
class CheatWebController(http.Controller):
@http.route('/cheat/webrpc', type='json', auth='user', website=True)
def do_something(self):
return { "result": "Ok" }
@http.route('/cheat/webrpcwithparam', type='json', auth='user', website=True)
def do_something_else(self, param1, param2):
return {
"result": "Ok",
"param1": param1,
"param2": param2
}
@http.route('/cheat/webrpc/<int:param1>/<string:param2>', type='json', auth='user', website=True)
def do_something_with_route_param(self, param1, param2):
return {
"result": "Ok",
"param1": param1,
"param2": param2
}
+4
View File
@@ -0,0 +1,4 @@
from . import ir_action
from . import ir_ui_view
from . import cheat_web
from . import res_users_settings
+29
View File
@@ -0,0 +1,29 @@
# Do not forget to add this file to __init__.py
# Refer to https://www.odoo.com/documentation/17.0/contributing/development/coding_guidelines.html for coding guidelines
from odoo import _, fields, models, api
class CheatWeb(models.Model):
_name = "cheat.web"
char_field = fields.Char(string="Char Field", required=True)
int_field = fields.Integer(string="Integer Field")
def do_something(self):
return { "result": "Ok" }
def do_something_else(self, param1 = 0, param2 = 1):
return {
"result": "Ok",
"param1": param1,
"param2": param2
}
@api.model
def do_model_method(self, param1 = 0, param2 = 1):
return {
"result": "Ok",
"param1": param1,
"param2": param2
}
+11
View File
@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class ActWindowView(models.Model):
_inherit = 'ir.actions.act_window.view'
view_mode = fields.Selection(selection_add=[
('hello', "Hello"),
('statistic', "Statistic"),
('cheat', "Cheat")
], ondelete={'hello': 'cascade', 'statistic': 'cascade', 'cheat': 'cascade'})
+25
View File
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
import logging
from odoo import fields, models
_logger = logging.getLogger(__name__)
class View(models.Model):
_inherit = 'ir.ui.view'
type = fields.Selection(selection_add=[
('hello', "Hello"),
('statistic', 'Statistic'),
('cheat', "Cheat")
]
)
def _validate_tag_cheat(self, node, name_manager, node_info):
_logger.info("----------Cheat view validation")
def _get_view_info(self):
return {
'hello': {'icon': 'fa fa-smile-o'},
'statistic': {'icon': 'fa fa-info'},
'cheat': {'icon': 'fa fa-bookmark-o'}
} | super()._get_view_info()
+8
View File
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class Users(models.Model):
_inherit = 'res.users.settings'
cheat_web_user_setting_char_field = fields.Char("User Char Field", default="default")
cheat_web_user_setting_integer_field = fields.Integer("User Integer Field", default=0)
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<rng:grammar xmlns:rng="http://relaxng.org/ns/structure/1.0"
xmlns:a="http://relaxng.org/ns/annotation/1.0"
datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
<rng:define name="cheat">
<rng:element name="cheat">
<rng:optional><rng:attribute name="limit"/></rng:optional>
<rng:zeroOrMore>
<rng:element name="field">
<rng:attribute name="name"/>
<rng:optional><rng:attribute name="conserve-line-breaks"/></rng:optional>
</rng:element>
</rng:zeroOrMore>
</rng:element>
</rng:define>
<rng:start>
<rng:choice>
<rng:ref name="cheat" />
</rng:choice>
</rng:start>
</rng:grammar>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<rng:grammar xmlns:rng="http://relaxng.org/ns/structure/1.0"
xmlns:a="http://relaxng.org/ns/annotation/1.0"
datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
<rng:define name="hello">
<rng:element name="hello">
<rng:empty/>
</rng:element>
</rng:define>
<rng:start>
<rng:choice>
<rng:ref name="hello" />
</rng:choice>
</rng:start>
</rng:grammar>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<rng:grammar xmlns:rng="http://relaxng.org/ns/structure/1.0"
xmlns:a="http://relaxng.org/ns/annotation/1.0"
datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
<rng:define name="statistic">
<rng:element name="statistic">
<rng:empty/>
</rng:element>
</rng:define>
<rng:start>
<rng:choice>
<rng:ref name="statistic" />
</rng:choice>
</rng:start>
</rng:grammar>
+2
View File
@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_cheat_web_model,access_cheat_web_model,model_cheat_web,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_cheat_web_model access_cheat_web_model model_cheat_web base.group_user 1 1 1 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+136
View File
@@ -0,0 +1,136 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import {
Component,
onWillStart,
onWillRender,
onRendered,
onMounted,
onWillUpdateProps,
onWillPatch,
onPatched,
onWillUnmount,
onWillDestroy,
onError,
useState,
useRef
} from "@odoo/owl";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { Layout } from "@web/search/layout";
class CheatOwl extends Component {
static template = "cheat_owl";
static components = { Layout };
static props = {
...standardActionServiceProps,
};
getRandomInteger(min, max) {
return Math.floor(Math.random() * (max - min)) + min;
}
setup() {
this.useRefCardTextRef = useRef("useRefCardText");
//#region non reactive
this.nonReactiveRandomValue = this.getRandomInteger(0, 100);
this.nonReactiveRandomValue1Ref = useRef("nonReactiveRandomValue1");
this.nonReactiveRandomValue2Ref = useRef("nonReactiveRandomValue2");
this.nonReactiveRandomValue3Ref = useRef("nonReactiveRandomValue3");
this.nonReactiveRandomValue4Ref = useRef("nonReactiveRandomValue4");
//#endregion
//#region reactive
this.state = useState({
randomValue: this.getRandomInteger(0, 100),
});
//#endregion
//#region input binding
this.bindingValue = "";
this.inputBindingRef = useRef("inputBinding");
//#endregion
//#region two way input binding
this.bindingState = useState({
valueStandardInputBinding: "",
valueTextAreaInputBinding: "",
valueCheckBoxInputBinding: false,
valueRadioButtonInputBinding: "",
valueSelectionInputBinding: "",
valueRangeInputBinding: 0,
});
//#endregion
//#region owl lifecycle
onWillStart(() => {
console.log("onWIllStart.");
});
onWillRender(() => {
console.log("onWillRender.");
});
onRendered(() => {
console.log("onRendered.");
});
onMounted(() => {
console.log("onMounted.");
});
onWillUpdateProps((nextProps) => {
console.log("onWillUpdateProps:", nextProps);
});
onWillPatch(() => {
console.log("onWillPatch.");
});
onPatched(() => {
console.log("onPatched.");
});
onWillUnmount(() => {
console.log("onWillUnmount.");
});
onWillDestroy(() => {
console.log("onWillDestroy.");
});
onError(() => {
console.log("onError.");
});
//#endregion
}
//#region functions
generateNonReactiveRandomValue() {
let randomVal = this.getRandomInteger(0, 100);
this.nonReactiveRandomValue1Ref.el.innerText = randomVal;
this.nonReactiveRandomValue2Ref.el.innerText = randomVal;
this.nonReactiveRandomValue3Ref.el.innerText = randomVal;
this.nonReactiveRandomValue4Ref.el.innerText = randomVal;
}
generateRandomValue() {
this.state.randomValue = this.getRandomInteger(0, 100);
}
changeUseRefCardTextColor() {
this.useRefCardTextRef.el.classList.toggle("text-danger");
}
getInputBindingValue(){
this.inputBindingRef.el.innerText = this.bindingValue;
}
changeValueStandardInputBinding(){
this.bindingState.valueStandardInputBinding = "Random value: " + this.getRandomInteger(0, 100);
}
//#endregion
}
registry.category("actions").add("cheat_owl", CheatOwl);
+199
View File
@@ -0,0 +1,199 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="cheat_owl" owl="1">
<Layout display="{ controlPanel: {} }" className="'overflow-auto h-100'">
<div class="cheat-web-container">
<div class="row g-2">
<div class="col-xl-2 col-sm-6">
<div class="card">
<h5 class="card-header">
<a class="collapsed d-block" data-bs-toggle="collapse" href="#collapse-owl-use-ref" aria-expanded="true" aria-controls="collapse-collapsed">
<em>useRef</em> Example
<i class="fa fa-chevron-down float-end"></i>
</a>
</h5>
<div id="collapse-owl-use-ref" class="collapse">
<div class="card-body">
<p class="card-text" t-ref="useRefCardText">
This text color can be changed by clicking the button below.
</p>
</div>
<div class="card-footer">
<div class="d-flex justify-content-end">
<button t-on-click="changeUseRefCardTextColor" class="btn btn-sm btn-outline-primary w-50">Change Color</button>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-sm-6">
<div class="card">
<h5 class="card-header">
<a class="collapsed d-block" data-bs-toggle="collapse" href="#collapse-owl-use-state" aria-expanded="true" aria-controls="collapse-collapsed">
<em>useState</em> Example
<i class="fa fa-chevron-down float-end"></i>
</a>
</h5>
<div id="collapse-owl-use-state" class="collapse">
<div class="card-body">
<p class="card-text">
This is a random value: <br/>
<span class="text-primary"><t t-out="this.state.randomValue" /></span>
<span class="text-danger ms-1"><t t-out="this.state.randomValue" /></span>
<span class="text-warning ms-1"><t t-out="this.state.randomValue" /></span>
<span class="text-info ms-1"><t t-out="this.state.randomValue" /></span>
</p>
</div>
<div class="card-footer">
<div class="d-flex justify-content-end">
<button t-on-click="generateRandomValue" class="btn btn-sm btn-outline-primary w-50">Randomize</button>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-sm-6">
<div class="card">
<h5 class="card-header">
<a class="collapsed d-block" data-bs-toggle="collapse" href="#collapse-owl-non-reactive" aria-expanded="true" aria-controls="collapse-collapsed">
Non Reactive Example
<i class="fa fa-chevron-down float-end"></i>
</a>
</h5>
<div id="collapse-owl-non-reactive" class="collapse">
<div class="card-body">
<p class="card-text">
This is a random value: <br/>
<span class="text-primary" t-ref="nonReactiveRandomValue1"><t t-out="this.nonReactiveRandomValue" /></span>
<span class="text-danger ms-1" t-ref="nonReactiveRandomValue2"><t t-out="this.nonReactiveRandomValue" /></span>
<span class="text-warning ms-1" t-ref="nonReactiveRandomValue3"><t t-out="this.nonReactiveRandomValue" /></span>
<span class="text-info ms-1" t-ref="nonReactiveRandomValue4"><t t-out="this.nonReactiveRandomValue" /></span>
</p>
</div>
<div class="card-footer">
<div class="d-flex justify-content-end">
<button t-on-click="generateNonReactiveRandomValue" class="btn btn-sm btn-outline-primary w-50">Randomize</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-1 g-2">
<div class="col-12">
<div class="card">
<h5 class="card-header">
<a class="collapsed d-block" data-bs-toggle="collapse" href="#collapse-owl-input-binding" aria-expanded="true" aria-controls="collapse-collapsed">
Input Binding
<i class="fa fa-chevron-down float-end"></i>
</a>
</h5>
<div id="collapse-owl-input-binding" class="collapse">
<div class="row m-2 mt-0 g-2">
<div class="col-xl-2 col-sm-6">
<div class="card h-100">
<h5 class="card-header"><i class="fa fa-keyboard-o" /> Input</h5>
<div class="card-body">
<p class="card-text">
<input t-model="this.bindingValue"/>
<hr/>
<div>Input value: <span t-ref="inputBinding"> <t t-out="this.bindingValue" /></span></div>
<button class="btn btn-sm btn-outline-primary" t-on-click="getInputBindingValue">Get Input Value</button>
</p>
</div>
</div>
</div>
</div>
<div class="row m-2 mt-0 g-2">
<div class="hr-sect">Two-way Binding</div>
<div class="col-xl-2 col-sm-6">
<div class="card h-100">
<h5 class="card-header"><i class="fa fa-keyboard-o" /> Input</h5>
<div class="card-body">
<p class="card-text justify-content-between d-flex flex-column h-100">
<input t-model="this.bindingState.valueStandardInputBinding"/>
<div><hr/>Input value: <t t-out="this.bindingState.valueStandardInputBinding" /></div>
<button class="btn btn-sm btn-outline-primary" t-on-click="changeValueStandardInputBinding">Randomize</button>
</p>
</div>
</div>
</div>
<div class="col-xl-2 col-sm-6">
<div class="card h-100">
<h5 class="card-header"><i class="fa fa-font" /> Textarea</h5>
<div class="card-body">
<p class="card-text justify-content-between d-flex flex-column h-100">
<textarea t-model="this.bindingState.valueTextAreaInputBinding"/>
<div><hr/>Input value: <t t-out="this.bindingState.valueTextAreaInputBinding" /></div>
</p>
</div>
</div>
</div>
<div class="col-xl-2 col-sm-6">
<div class="card h-100">
<h5 class="card-header"><i class="fa fa-check-square" /> Checkbox</h5>
<div class="card-body">
<p class="card-text justify-content-between d-flex flex-column h-100">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="cbInputBindingCheck" t-model="this.bindingState.valueCheckBoxInputBinding"/>
<label class="form-check-label" for="cbInputBindingCheck">Checkbox</label>
</div>
<div><hr/>Input value: <t t-out="this.bindingState.valueCheckBoxInputBinding" /></div>
</p>
</div>
</div>
</div>
<div class="col-xl-2 col-sm-6">
<div class="card h-100">
<h5 class="card-header"><i class="fa fa-stop-circle-o" /> Radio Buttons</h5>
<div class="card-body">
<p class="card-text justify-content-between d-flex flex-column h-100">
<div>
<div class="form-check">
<input class="form-check-input" type="radio" id="cbInputBindingOne" value="one" t-model="this.bindingState.valueRadioButtonInputBinding"/>
<label class="form-check-label" for="cbInputBindingOne">One</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="cbInputBindingTwo" value="two" t-model="this.bindingState.valueRadioButtonInputBinding" />
<label class="form-check-label" for="cbInputBindingTwo">Two</label>
</div>
</div>
<div><hr/>Input value: <t t-out="this.bindingState.valueRadioButtonInputBinding" /></div>
</p>
</div>
</div>
</div>
<div class="col-xl-2 col-sm-6">
<div class="card h-100">
<h5 class="card-header"><i class="fa fa-caret-square-o-down" /> Select</h5>
<div class="card-body">
<p class="card-text justify-content-between d-flex flex-column h-100">
<select class="form-select" t-model="this.bindingState.valueSelectionInputBinding">
<option value="red">Red</option>
<option value="blue">Blue</option>
</select>
<div><hr/>Input value: <t t-out="this.bindingState.valueSelectionInputBinding" /></div>
</p>
</div>
</div>
</div>
<div class="col-xl-2 col-sm-6">
<div class="card h-100">
<h5 class="card-header"><i class="fa fa-arrows-h" /> Range</h5>
<div class="card-body">
<p class="card-text justify-content-between d-flex flex-column h-100">
<input class="form-range" min="0" max="5" step="0.5" type="range" t-model="this.bindingState.valueRangeInputBinding"/>
<div><hr/>Input value: <t t-out="this.bindingState.valueRangeInputBinding" /></div>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Layout>
</t>
</templates>
+79
View File
@@ -0,0 +1,79 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { Component, useState, markup } from "@odoo/owl";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { Layout } from "@web/search/layout";
import { HAccordion } from "./core/haccordion/haccordion";
import { Collapsible } from "./core/collapsible/collapsible";
import { CheatOwlQwebInlineTemplate } from "./cheat_owl_qweb_inline_template"
class CheatOwlQweb extends Component {
static template = "cheat_owl_qweb";
static components = { Layout, CheatOwlQwebInlineTemplate, HAccordion, Collapsible };
static props = {
...standardActionServiceProps,
};
//#region Misc Functions
getRandomInteger(min, max) {
return Math.floor(Math.random() * (max - min)) + min;
}
get randomString() {
var letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
var llen = letters.length;
var res = "";
for (var i = 0; i < 6; i++) {
res += letters[Math.floor(Math.random() * llen)];
}
return res;
}
get randomBoolean() {
return Math.round(Math.random()) == 1;
}
get dynamicTemplate() {
if (this.state.dynamicTemplate === "one") {
return "cheat_owl_qweb_sub_template_one"
} else if (this.state.dynamicTemplate === "two"){
return "cheat_owl_qweb_sub_template_two"
} else if (this.state.dynamicTemplate === "three"){
return "cheat_owl_qweb_sub_template_three"
} else {
return "cheat_owl_qweb_sub_template_four"
}
}
//#endregion
setup() {
this.state = useState({
randomValue: this.getRandomInteger(0, 100),
checkBoxValue: "one",
dynamicTag: "div",
dynamicTemplate: "one"
});
this.markup = markup;
this.divText = "<div>some text 1</div>";
this.markupDivText = markup("<div>some text 2</div>");
this.arrayOfObjects = [
{
id: 1,
name: "Item No. 1",
},
{
id: 2,
name: "Item No. 2",
},
{
id: 3,
name: "Item No. 3",
},
];
this.aSetOfObjects = new Set(this.arrayOfObjects);
}
}
registry.category("actions").add("cheat_owl_qweb", CheatOwlQweb);
+404
View File
@@ -0,0 +1,404 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="cheat_owl_qweb">
<Layout display="{ controlPanel: {} }" className="'overflow-auto h-100'">
<HAccordion name="'hacc'" startItem="0">
<t t-set-slot="basics-1" title.translate="The Basics - I">
<div class="d-flex w-100 flex-column gap-2 pt-2">
<div class="row g-2 w-100">
<div class="col-xl-4 col-sm-12">
<Collapsible name="'inline-template'" title.translate="Inline Template">
<CheatOwlQwebInlineTemplate />
</Collapsible>
</div>
<div class="col-xl-4 col-sm-12">
<Collapsible name="'white-spaces'" title.translate="White Spaces">
<p>
<span>This is white spaces in a <span class="badge">&lt;span&gt;</span> : ' '
</span>
</p>
<p>
<span>This is a <span class="badge">&lt;span&gt;</span> with a line break:
'
'
</span>
</p>
<p>
<pre>This is white spaces in a &lt;pre&gt;: ' '</pre>
</p>
</Collapsible>
</div>
<div class="col-xl-4 col-sm-12">
<Collapsible name="'outputs'" title.translate="Displaying Data">
<p>
<span>This is an html fragment <span class="badge">t-out</span> :
<pre><t t-out="divText" /></pre>
</span>
<span>This is an html fragment <span class="badge">t-out</span> with markup:
<pre><t t-out="markupDivText" /></pre>
</span>
</p>
<p>
<span>This is an html fragment <span class="badge">t-esc</span> :
<pre><t t-esc="divText" /></pre>
</span>
<span>This is an html fragment <span class="badge">t-esc</span> with markup:
<pre><t t-esc="markupDivText" /></pre>
</span>
</p>
</Collapsible>
</div>
</div>
<div class="row g-2 w-100">
<div class="col-xl-4 col-sm-12">
<Collapsible name="'variables'" title.translate="Variables">
<p>
<t t-set="foo" t-value="'bar'" />
<span>The variable <span class="badge">foo</span> is set to <span class="badge"><t t-out="foo"/></span>.</span>
</p>
<p>
<t t-set="wal">
<strong>do</strong>
</t>
<span>The variable <span class="badge">wal</span> is set to <span class="badge"><t t-out="wal"/></span> but inside tags.</span>
</p>
</Collapsible>
</div>
<div class="col-xl-4 col-sm-12">
<Collapsible name="'expressions'" title.translate="Basic Expressions">
<p>
<span>This is evaluation result of <span class="badge">1 + 3</span> :
<pre><t t-out="1 + 3" /></pre>
</span>
<span>With <span class="badge">state.randomValue = <t t-out="state.randomValue"/></span>, this expression <span class="badge">1 + state.randomValue</span> will result to:
<pre><t t-out="1 + state.randomValue" /></pre>
</span>
</p>
</Collapsible>
</div>
<div class="col-xl-4 col-sm-12">
<Collapsible name="'logical-expressions'" title.translate="Logical Expressions">
<p>
<table class="table table-striped table-hover">
<thead>
<tr class="table-secondary">
<th>Operation</th>
<th>Operator</th>
<th>Example</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>Equality</td>
<td>==</td>
<td>1 == 3</td>
<td><t t-out="1 == 3" /></td>
</tr>
<tr>
<td>And</td>
<td>and</td>
<td>true and false</td>
<td><t t-out="true and false" /></td>
</tr>
<tr>
<td>Or</td>
<td>or</td>
<td>true or false</td>
<td><t t-out="true or false" /></td>
</tr>
<tr>
<td>Greater Than</td>
<td>gt</td>
<td>1 gt 1</td>
<td><t t-out="1 gt 1" /></td>
</tr>
<tr>
<td>Greater Than or Equal</td>
<td>gte</td>
<td>1 gte 1</td>
<td><t t-out="1 gte 1" /></td>
</tr>
<tr>
<td>Less Than</td>
<td>lt</td>
<td>1 lt 1</td>
<td><t t-out="1 lt 1" /></td>
</tr>
<tr>
<td>Less Than or Equal</td>
<td>lte</td>
<td>1 lte 1</td>
<td><t t-out="1 lte 1" /></td>
</tr>
</tbody>
</table>
</p>
</Collapsible>
</div>
</div>
</div>
</t>
<t t-set-slot="basics-2" title.translate="The Basics - II">
<div class="d-flex w-100 flex-column gap-2 pt-2">
<div class="row g-2 w-100">
<div class="col-xl-4 col-sm-12">
<Collapsible name="'conditionals'" title.translate="Conditionals">
<p>
<div>
<div class="form-check">
<input class="form-check-input"
type="radio"
id="cbConditionalOne"
name="cbConditionals"
value="one"
t-model="state.checkBoxValue" />
<label class="form-check-label" for="cbConditionalOne">One</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
id="cbConditionalTwo"
name="cbConditionals"
value="two"
t-model="state.checkBoxValue" />
<label class="form-check-label" for="cbConditionalTwo">Two</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
id="cbConditionalThree"
name="cbConditionals"
value="three"
t-model="state.checkBoxValue" />
<label class="form-check-label" for="cbConditionalThree">Three</label>
</div>
</div>
<span t-if="state.checkBoxValue === 'one'">This will be displayed if the first radio button above is selected.</span>
<span t-elif="state.checkBoxValue === 'two'">This will be displayed if the second radio button is checked.</span>
<span t-else="">This will be displayed if the third radio button is checked.</span>
</p>
</Collapsible>
</div>
<div class="col-xl-4 col-sm-12">
<Collapsible name="'loops'" title.translate="Loops">
<p>
<span>This list is made using <span class="badge">t-foreach</span> loop:</span>
<ul>
<t t-foreach="[1, 2, 3]" t-as="i" t-key="i">
<li><t t-out="'Item: ' + i.toString()"/></li>
</t>
</ul>
</p>
<p>
<span>This list is made using <span class="badge">t-foreach</span> loop too but applied to the element instead of the <span class="badge">t</span> element:</span>
<ul>
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i"><t t-out="'Item: ' + i.toString()"/></li>
</ul>
</p>
<p>
<span>These demonstrate the <span class="badge">t-foreach</span>'s special variables:</span>
<table class="table table-striped table-hover mt-1">
<thead>
<tr class="table-secondary">
<td>x_index</td>
<td>x_value</td>
<td>x_first</td>
<td>x_last</td>
</tr>
</thead>
<tbody>
<tr t-foreach="arrayOfObjects" t-as="item" t-key="item.id">
<td><t t-out="item_index"/></td>
<td><t t-out="item_value.toString()"/></td>
<td><t t-out="item_first"/></td>
<td><t t-out="item_last"/></td>
</tr>
</tbody>
</table>
</p>
<p>
<span>This list is made using <span class="badge">Set</span> and spread operator <span class="badge">...</span> :</span>
<ul>
<t t-foreach="[...aSetOfObjects]" t-as="i" t-key="i.id">
<li><t t-out="i.name"/></li>
</t>
</ul>
</p>
</Collapsible>
</div>
<div class="col-xl-4 col-sm-12">
<Collapsible name="'dynamics'" title.translate="Dynamics">
<p>
<span>The placeholder attribute is set using random value:</span>
<input type="text" class="form-control mt-1" t-att-placeholder="randomString" />
</p>
<p>
<span>The placeholder attribute is set using string interpolation with random value:</span>
<input type="text" class="form-control mt-1" t-attf-placeholder="This is a random string: {{randomString}}" />
</p>
<p>
<span>The CSS class is set using object with CSS class as key and boolean value to indicate the inclusion
<span class="badge">{'text-info': true, 'fw-bold': true}</span>:</span>
<p t-att-class="{'text-info': true, 'fw-bold': true}">This is an example text.</p>
</p>
<p>
<span>This is an exmple of dynamic tag:</span>
<div>
<div class="form-check">
<input class="form-check-input"
type="radio"
id="cbDynamicTagDiv"
name="cbDynamicTag"
value="div"
t-model="state.dynamicTag" />
<label class="form-check-label" for="cbDynamicTagDiv">Div</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
id="cbDynamicTagSpan"
name="cbDynamicTag"
value="pre"
t-model="state.dynamicTag" />
<label class="form-check-label" for="cbDynamicTagSpan">Pre</label>
</div>
</div>
<t t-tag="state.dynamicTag">
<span>This text is inside <span class="badge"><t t-out="state.dynamicTag"/> element</span></span>
</t>
</p>
</Collapsible>
</div>
</div>
</div>
</t>
<t t-set-slot="more-on-templates" title.translate="More On Templates">
<div class="d-flex w-100 flex-column gap-2 pt-2">
<div class="row g-2 w-100">
<div class="col-xl-6 col-sm-12">
<Collapsible name="'sub-templates'" title.translate="Sub Templates">
<p>
<div>Sub templates are called with <span class="badge">t-call</span>:</div>
<t t-call="cheat_owl_qweb_sub_template_two"/>
</p>
<p>
<div>This templates is using special placeholder <span class="badge">t-out="0"</span>:</div>
<t t-call="cheat_owl_qweb_sub_template_three">
<span>This text is rendered inside the sub template.</span>
</t>
</p>
<p>
<div>Variables are passed down to the sub template:</div>
<t t-set="parentTemplateVariable" t-value="'This is a string declared on parent template.'"/>
<t t-call="cheat_owl_qweb_sub_template_four">
<t t-set="scopedTemplateVariable" t-value="'This is declared inside the t element.'"/>
</t>
<!-- scopedTemplateVariable does not exist here -->
</p>
<p>
<span>This is an example of dynamic sub templates:</span>
<div>
<div class="form-check">
<input class="form-check-input"
type="radio"
id="cbDynamicTemplateOne"
name="cbDynamicTemplate"
value="one"
t-model="state.dynamicTemplate" />
<label class="form-check-label" for="cbDynamicTemplateOne">One</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
id="cbDynamicTemplateTwo"
name="cbDynamicTemplate"
value="two"
t-model="state.dynamicTemplate" />
<label class="form-check-label" for="cbDynamicTemplateTwo">Two</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
id="cbDynamicTemplateThree"
name="cbDynamicTemplate"
value="three"
t-model="state.dynamicTemplate" />
<label class="form-check-label" for="cbDynamicTemplateThree">Three</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
id="cbDynamicTemplateFour"
name="cbDynamicTemplate"
value="four"
t-model="state.dynamicTemplate" />
<label class="form-check-label" for="cbDynamicTemplateFour">Four</label>
</div>
</div>
<t t-call="{{ dynamicTemplate }}"/>
</p>
</Collapsible>
</div>
<div class="col-xl-6 col-sm-12">
<Collapsible name="'template-inheritance'" title.translate="Template Inheritance">
<p>
<div>We call the parent template, but since there's an extension child which modified the content we get the modified version:</div>
<t t-call="cheat_owl.qweb_parent_template"/>
</p>
<p>
<div>This is the primary child template, which we can call directly by it's name:</div>
<t t-call="cheat_owl.qweb_primary_child_template"/>
</p>
</Collapsible>
</div>
</div>
</div>
</t>
<t t-set-slot="subscribe" title.translate="Subscribe for more tutorials">
<div class="d-flex d-flex h-100 justify-content-center align-items-start">
<div class="card" style="width: 150px;">
<img src="/cheat_web/static/img/exploring-odoo.png" class="card-img-top" />
<div class="card-body text-center">
<p class="card-text">
<a href="https://youtube.com/@exploring-odoo">Exploring Odoo</a>
</p>
</div>
</div>
</div>
</t>
</HAccordion>
</Layout>
</t>
<t t-name="cheat_owl_qweb_sub_template_one">
<div>This text is inside a template <span class="badge">cheat_owl_qweb_sub_template_one</span>.</div>
</t>
<t t-name="cheat_owl_qweb_sub_template_two">
<div>This text is inside a template <span class="badge">cheat_owl_qweb_sub_template_two</span>.</div>
<t t-call="cheat_owl_qweb_sub_template_one"/>
</t>
<t t-name="cheat_owl_qweb_sub_template_three">
<div>This text is inside a template <span class="badge">cheat_owl_qweb_sub_template_three</span>.</div>
<t t-out="0" />
</t>
<t t-name="cheat_owl_qweb_sub_template_four">
<div>This text is inside a template <span class="badge">cheat_owl_qweb_sub_template_four</span>.</div>
<div><t t-out="parentTemplateVariable" /></div>
<div><t t-out="scopedTemplateVariable" /></div>
</t>
<t t-name="cheat_owl.qweb_parent_template">
<p>This is parent template.</p>
</t>
<t t-name="cheat_owl.qweb_primary_child_template" t-inherit="cheat_owl.qweb_parent_template" t-inherit-mode="primary">
<xpath expr="//p[1]" position="after">
<p>This is primary child template.</p>
</xpath>
</t>
<t t-inherit="cheat_owl.qweb_parent_template" t-inherit-mode="extension">
<xpath expr="//p[1]" position="after">
<p>This is extension child template.</p>
</xpath>
</t>
</templates>
@@ -0,0 +1,11 @@
/** @odoo-module */
import { Component, xml } from "@odoo/owl";
export class CheatOwlQwebInlineTemplate extends Component {
static template = xml`
<p>
This text is in an inline template
</p>`;
static props = { };
}
+63
View File
@@ -0,0 +1,63 @@
.cheat-web-container {
margin: 2rem !important;
}
.cheat-web-user-profile {
margin: 1rem;
}
.cheat-web-user-profile .card {
width: 400px;
border: none;
border-radius: 10px;
background-color: #fff;
}
.cheat-web-user-profile .stats {
background: #f2f5f8 !important;
color: #000 !important;
}
.cheat-web-user-profile .articles {
font-size: 10px;
color: #a1aab9;
}
.cheat-web-user-profile .followers {
font-size: 10px;
color: #a1aab9;
}
.cheat-web-user-profile .rating {
font-size: 10px;
color: #a1aab9;
}
.cheat-web-user-profile .stat-number,
.cheat-web-user-profile .stat-string {
font-weight: 500;
}
.cheat-web-container .card-header .fa {
transition: 0.3s transform ease-in-out;
}
.cheat-web-container .card-header .collapsed .fa {
transform: rotate(90deg);
}
.hr-sect {
display: flex;
flex-basis: 100%;
align-items: center;
color: rgba(0, 0, 0, 0.35);
margin: 8px 0px;
}
.hr-sect::before,
.hr-sect::after {
content: "";
flex-grow: 1;
background: rgba(0, 0, 0, 0.35);
height: 1px;
font-size: 0px;
line-height: 0px;
margin: 0px 8px;
}
+298
View File
@@ -0,0 +1,298 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Component, useState, xml } from "@odoo/owl";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { Layout } from "@web/search/layout";
import { AlertDialog, ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { user } from "@web/core/user";
import { url } from "@web/core/utils/urls";
const { DateTime } = luxon;
//#region custom dialog
class MyDialog extends Component {
static components = { Dialog };
static template = xml`
<Dialog size="'md'" title="'This is my dialog title'">
<p>
This is my dialog content.
</p>
</Dialog>
`;
}
class MyDialogWithButtons extends Component {
static components = { Dialog };
static template = xml`
<Dialog size="'md'" title="'This is my dialog with buttons'">
<p>
This is my dialog with buttons content.
</p>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="onConfirm">Ok</button>
<button class="btn btn-secondary" t-on-click="onCancel">Cancel</button>
</t>
</Dialog>
`;
setup() {
this.notification = useService("notification");
}
onConfirm() {
this.notification.add('You confirmed the dialog.');
this.props.close();
}
onCancel() {
this.notification.add('You canceled the dialog.');
this.props.close();
}
}
//#endregion
class CheatWeb extends Component {
//#region statics
static template = "cheat_web";
static components = { Layout };
static props = {
...standardActionServiceProps,
};
//#endregion
setup() {
//#region services
this.orm = useService("orm");
this.notification = useService("notification");
this.dialog = useService("dialog");
//#endregion
this.user = user;
this.state = useState({
userCharValue: user.settings.cheat_web_user_setting_char_field,
userIntegerValue: user.settings.cheat_web_user_setting_integer_field
});
}
//#region user
get userImage(){
return url("/web/image", {
model: 'res.users',
id: user.userId,
field: "avatar_128",
});
}
async dumpUserSettings(){
console.log("User:", user);
console.log("User settings:", user.settings);
}
async dumpState(){
console.log("state:", this.state);
}
async saveUserSettings(){
await user.setUserSettings(
"cheat_web_user_setting_char_field",
this.state.userCharValue
);
await user.setUserSettings(
"cheat_web_user_setting_integer_field",
this.state.userIntegerValue
);
}
//#endregion
//#region misc function
getRandomInteger(min, max) {
return Math.floor(Math.random() * (max - min) ) + min;
}
//#endregion
//#region rpc
async rpcDoSomething() {
const res = await rpc("/cheat/webrpc");
console.log(res);
}
async rpcDoSomethingElse() {
const res = await rpc("/cheat/webrpcwithparam", { param1: 1, param2: "2"});
console.log(res);
}
async rpcDoSomethingWithRouteParam() {
const param1 = 3;
const param2 = 4;
const res = await rpc(`/cheat/webrpc/${param1}/${param2}`);
console.log(res);
}
//#endregion
//#region orm
async ormCreate() {
const res = await this.orm.create("cheat.web", [
{
char_field: "Char Field #" + DateTime.now().toUnixInteger(),
int_field: this.getRandomInteger(1, 1000)
},
{
char_field: "Char Field #" + DateTime.now().toUnixInteger(),
int_field: this.getRandomInteger(1, 1000)
},
]);
console.log(res);
}
async ormSearch() {
const res = await this.orm.search("cheat.web", [['id', '>=', 0]], { order: "id desc", limit: 100, offset: 0 });
console.log(res);
}
async ormSearchCount() {
const res = await this.orm.searchCount("cheat.web", [['id', '>=', 0]]);
console.log(res);
}
async ormSearchRead() {
const res = await this.orm.searchRead("cheat.web", [['id', '>=', 0]], ["char_field", "int_field"]);
console.log(res);
}
async ormRead() {
const ids = await this.orm.search("cheat.web", [['id', '>=', 0]]);
if (ids.length > 0){
const res = await this.orm.read("cheat.web", [ids[0]], ["char_field"]);
console.log(res);
}
}
async ormWrite() {
const ids = await this.orm.search("cheat.web", [['id', '>=', 0]]);
if (ids.length > 0) {
let res = await this.orm.read("cheat.web", [ids[0]], ["char_field"]);
console.log('Old Values:', res);
await this.orm.write('cheat.web', [ids[0]],
{
'char_field': `Updated #${DateTime.now().toUnixInteger()}`
}
);
res = await this.orm.read("cheat.web", [ids[0]], ["char_field"]);
console.log('New Values:', res);
}
}
async ormUnlink() {
let ids = await this.orm.search("cheat.web", [['id', '>=', 0]], { order: "id desc" });
if (ids.length > 0) {
console.log('Search Result:', ids);
await this.orm.unlink('cheat.web', [ids[0]]);
ids = await this.orm.search("cheat.web", [['id', '>=', 0]], { order: "id desc" });
console.log('Search Result:', ids);
}
}
async ormDoSomething() {
const res = await this.orm.call("cheat.web", "do_something", [1]);
console.log(res);
}
async ormDoSomethingElse() {
const res = await this.orm.call("cheat.web", "do_something_else", [1], { param1: '1', param2: "2"});
console.log(res);
}
async ormDoModelMethod() {
const res = await this.orm.call("cheat.web", "do_model_method", [], { param1: "3", param2: "4" });
console.log(res);
}
//#endregion
//#region notification
async notifSimple(){
this.notification.add('This is a simple Notification');
}
async notifSticky(){
this.notification.add('This is a sticky Notification', {
title: 'Sticky Notification',
type: 'success',
sticky: true,
});
}
async notifCallback(){
this.notification.add('This is a Notification with onClose callback', {
title: 'Sticky Notification',
type: 'info',
sticky: true,
onClose: () => {
this.notification.add('This is from onclose callback.');
}
});
}
async notifWithButtons(){
this.notification.add('This is a Notification with Buttons', {
title: 'Sticky Notification',
type: 'warning',
sticky: true,
buttons: [
{
name: 'Button 1',
onClick: () => {
this.notification.add('This is from button 1 onclick.');
}
},
{
name: 'Button 2',
onClick: () => {
this.notification.add('This is from button 2 onclick.');
}
}
]
});
}
//#endregion
//#region dialog
async dialogAlert(){
this.dialog.add(AlertDialog, {
title: 'This is an Alert Dialog',
body: 'This is the alert message.',
contentClass: 'text-danger'
});
}
async dialogConfirm(){
this.dialog.add(ConfirmationDialog, {
title: 'This is an Confirmation Dialog',
body: 'This is the dialog message.',
confirmLabel: 'Click here to confirm.',
cancelLabel: "Click here to cancel.",
cancel: async () => {
this.notification.add('You canceled the dialog.');
},
confirm: async () => {
this.notification.add('You confirmed the dialog.');
},
});
}
async dialogCustom(){
this.dialog.add(MyDialog);
}
async dialogCustomWithButtons(){
this.dialog.add(MyDialogWithButtons);
}
//#endregion
}
registry.category("actions").add("cheat_web", CheatWeb);
+152
View File
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="cheat_web" owl="1">
<Layout display="{ controlPanel: {} }" className="'overflow-auto h-100'">
<div class="cheat-web-container">
<div class="accordion shadow-sm" id="mainAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseRpc" aria-expanded="true" aria-controls="collapseRpc">
RPC
</button>
</h2>
<div id="collapseRpc" class="accordion-collapse collapse show" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="container mx-2 my-4 gx-2">
<div class="btn-group me-2" role="group">
<button class="btn btn-primary" t-on-click="rpcDoSomething">RPC Call</button>
<button class="btn btn-primary" t-on-click="rpcDoSomethingElse">RPC Call With Param</button>
<button class="btn btn-primary" t-on-click="rpcDoSomethingWithRouteParam">RPC Call With Router Param</button>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOrm" aria-expanded="true" aria-controls="collapseOrm">
ORM
</button>
</h2>
<div id="collapseOrm" class="accordion-collapse collapse" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="container mx-2 my-4 gx-2">
<div class="btn-group me-2" role="group">
<button class="btn btn-primary" t-on-click="ormCreate">ORM Create</button>
<button class="btn btn-primary" t-on-click="ormSearch">ORM Search</button>
<button class="btn btn-primary" t-on-click="ormSearchCount">ORM Search Count</button>
<button class="btn btn-primary" t-on-click="ormSearchRead">ORM Search Read</button>
<button class="btn btn-primary" t-on-click="ormRead">ORM Read</button>
<button class="btn btn-primary" t-on-click="ormWrite">ORM Write</button>
<button class="btn btn-primary" t-on-click="ormUnlink">ORM Unlink</button>
</div>
<div class="btn-group me-2" role="group">
<button class="btn btn-primary" t-on-click="ormDoSomething">ORM Call</button>
<button class="btn btn-primary" t-on-click="ormDoSomethingElse">ORM Call With Param</button>
<button class="btn btn-primary" t-on-click="ormDoModelMethod">ORM Call Model Method</button>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseNotification" aria-expanded="true" aria-controls="collapseNotification">
Notification
</button>
</h2>
<div id="collapseNotification" class="accordion-collapse collapse" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="container mx-2 my-4 gx-2">
<div class="btn-group me-2" role="group">
<button class="btn btn-primary" t-on-click="notifSimple">Simple Notification</button>
<button class="btn btn-primary" t-on-click="notifSticky">Sticky Notification</button>
<button class="btn btn-primary" t-on-click="notifCallback">Notification with On Close Callback</button>
<button class="btn btn-primary" t-on-click="notifWithButtons">Notification with Buttons</button>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDialogBox" aria-expanded="true" aria-controls="collapseDialogBox">
Dialog Box
</button>
</h2>
<div id="collapseDialogBox" class="accordion-collapse collapse" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="container mx-2 my-4 gx-2">
<div class="btn-group me-2" role="group">
<button class="btn btn-primary" t-on-click="dialogAlert">Alert Dialog</button>
<button class="btn btn-primary" t-on-click="dialogConfirm">Confirmation Dialog</button>
<button class="btn btn-primary" t-on-click="dialogCustom">Custom Dialog</button>
<button class="btn btn-primary" t-on-click="dialogCustomWithButtons">Custom Dialog with Buttons</button>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseUserService" aria-expanded="true" aria-controls="collapseUserService">
User Information
</button>
</h2>
<div id="collapseUserService" class="accordion-collapse collapse" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="container d-flex justify-content-center cheat-web-user-profile">
<div class="card p-3">
<div class="d-flex align-items-center">
<div class="image">
<img t-att-src="userImage" class="rounded" width="155" />
</div>
<div class="ms-3 w-100">
<h4 class="mb-0 mt-0"><t t-out="this.user.name" /></h4>
<span><t t-out="this.user.login" /></span>
<div class="p-2 mt-2 bg-primary d-flex justify-content-between rounded text-white stats">
<div class="d-flex flex-column">
<span class="articles">Language</span>
<span class="stat-string"><t t-out="this.user.lang" /></span>
</div>
<div class="d-flex flex-column">
<span class="followers">Timezone</span>
<span class="stat-string"><t t-out="this.user.tz" /></span>
</div>
</div>
<div class="button mt-2 d-flex flex-row align-items-center">
<button t-on-click="dumpUserSettings" class="btn btn-sm btn-outline-primary w-100">Dump User Data</button>
<button data-bs-toggle="collapse" data-bs-target="#collapseUserSettings" class="btn btn-sm btn-outline-primary w-100 ms-3">Show Settings</button>
</div>
</div>
</div>
<div class="collapse" id="collapseUserSettings">
<div class="card-body">
<div class="row mb-3">
<label for="userSettingCharField" class="col-sm-4 col-form-label">Char Field</label>
<div class="col-sm-8">
<input class="form-control" id="userSettingCharField" t-model.lazy="this.state.userCharValue" />
</div>
</div>
<div class="row mb-3">
<label for="userSettingIntegerField" class="col-sm-4 col-form-label">Integer Field</label>
<div class="col-sm-8">
<input class="form-control" id="userSettingIntegerField" t-model.lazy.number="this.state.userIntegerValue" />
</div>
</div>
<div class="button mt-2 d-flex flex-row align-items-center">
<button t-on-click="dumpState" class="btn btn-sm btn-outline-primary w-100">Dump State</button>
<button t-on-click="saveUserSettings" class="btn btn-sm btn-outline-primary w-100 ms-3">Save</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Layout>
</t>
</templates>
@@ -0,0 +1,17 @@
/** @odoo-module */
import { Component, useState } from "@odoo/owl";
export class Collapsible extends Component {
static template = "web.collapsible";
static components = { };
static props = {
name: { type: String },
title: { type: String },
slots: { type: Object, optional: true },
collapsed: { type: Boolean, optional: true}
};
static defaultProps = {
collapsed: true
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.collapsible" owl="1">
<div class="card">
<h5 class="card-header">
<a class="d-block" data-bs-toggle="collapse" t-attf-href="{{ '#' + props.name }}" aria-expanded="true" aria-controls="collapse-collapsed">
<t t-out="props.title" />
<i class="fa fa-chevron-down float-end"></i>
</a>
</h5>
<div t-att-id="props.name" t-attf-class="{{ props.collapsed ? 'collapse' : 'collapse show' }}">
<div class="card-body">
<p class="card-text text-wrap overflow-auto" t-ref="useRefCardText">
<t t-slot="default"/>
</p>
</div>
<div class="card-footer" t-if="props.slots['footer']">
<t t-slot="footer"/>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,25 @@
/** @odoo-module */
import { Component, useState } from "@odoo/owl";
export class HAccordion extends Component {
static template = "web.haccordion";
static components = { };
static props = {
name: { type: String },
startItem: { type: Number, optional: true },
slots: { type: Object, optional: true },
};
static defaultProps = {
startItem: 0
}
setup() {
this.state = useState({ activeItem: this.props.startItem });
this.itemNames = Object.keys(this.props.slots);
}
onClick(item) {
this.state.activeItem = item;
}
}
@@ -0,0 +1,71 @@
// Variables
$tab-width: 60px;
$screen-width: 100vw;
.haccordion-root {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
align-content: center;
position: relative;
min-height: 100%;
height: 100%;
width: 100%;
.haccordion-item {
flex-grow: 1;
height: 100%;
overflow: auto;
transition: width 500ms ease;
}
.haccordion-fold {
width: $tab-width !important;
display: flex;
cursor: pointer;
& .haccordion-title-container {
width: 100%;
height: 100%;
display: flex;
background-color: $o-gray-800 !important;
& .haccordion-title {
white-space: nowrap;
transform: rotate(-90deg);
align-self: end;
position: relative;
bottom: 75px;
left: 5px;
width: 100%;
& * {
color: $o-gray-600 !important;
&:hover {
color: $o-gray-300 !important;
}
}
}
}
& .haccordion-content {
display: none;
}
}
.haccordion-expand {
width: 100%;
& .haccordion-title {
display: none;
}
& .haccordion-content {
padding-left: 10px;
display: revert;
height: 100%;
}
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.haccordion" owl="1">
<div class="haccordion-root">
<div t-attf-class="{{ 'haccordion-item ' + (this.state.activeItem === item_index ? 'haccordion-expand' : 'haccordion-fold') }}"
t-foreach="itemNames"
t-as="item"
t-key="item_index"
t-on-click="() => this.state.activeItem = item_index" >
<div class="haccordion-title-container">
<div class="haccordion-title" t-attf-id="{{ props.name + '-' + item + '-' + item_index.toString()}}">
<h1><t t-out="props.slots[item].title" /></h1>
</div>
</div>
<div class="haccordion-content p-4">
<h1>
<t t-out="props.slots[item].title" />
</h1>
<t t-slot="{{ item }}"/>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,41 @@
import { visitXML } from "@web/core/utils/xml";
import { Field } from "@web/views/fields/field";
export class CheatArchParser {
parseFieldNode(node, models, modelName) {
return Field.parseFieldNode(node, models, modelName, "cheat");
}
parse(xmlDoc, models, modelName) {
console.log("Cheat Arch Parser - xmlDoc: ", xmlDoc);
console.log("Cheat Arch Parser - models: ", models);
console.log("Cheat Arch Parser - modelName: ", modelName);
const fieldNodes = {};
const limit = xmlDoc.getAttribute("limit") || 80;
let display_name_field = [...xmlDoc.children].find(o => o.attributes['name'].value === "display_name");
if (!display_name_field){
display_name_field = document.createElement("field");
display_name_field.setAttribute("name", "display_name");
xmlDoc.appendChild(display_name_field);
}
visitXML(xmlDoc, (node) => {
console.log("Cheat Arch Parser - node: ", node);
if (node.tagName === "field") {
const fieldNode = this.parseFieldNode(node, models, modelName);
const conserveLineBreaks = node.getAttribute("conserve-line-breaks");
fieldNode.conserveLineBreaks = conserveLineBreaks === 'true';
fieldNodes[fieldNode.name] = fieldNode;
}
});
return {
fieldNodes,
limit
};
}
}
@@ -0,0 +1,98 @@
import { _t } from "@web/core/l10n/translation";
import { Component, useState, useRef, onWillPatch } from "@odoo/owl";
import { Layout } from "@web/search/layout";
import { usePager } from "@web/search/pager_hook";
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
import { SearchBar } from "@web/search/search_bar/search_bar";
import { useModel } from "@web/model/model";
import { extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
import { standardViewProps } from "@web/views/standard_view_props";
import { executeButtonCallback } from "@web/views/view_button/view_button_hook";
export class CheatController extends Component {
static template = `cheat_web.CheatView`;
static props = {
...standardViewProps,
offset: { type: Number, optional: true },
Model: Function,
Renderer: Function,
archInfo: Object,
}
static components = {
Layout,
SearchBar
};
setup() {
console.log("Statistic Controller - this: ", this);
console.log("Statistic Controller - props: ", this.props);
this.rootRef = useRef("root");
this.props.offset = 0;
this.model = useState(useModel(this.props.Model, this.modelParams));
usePager(() => {
return {
offset: this.model.offset,
limit: this.model.limit,
total: this.model.recordsLength,
onUpdate: async ({ offset, limit }) => {
this.props.offset = offset;
this.props.limit = limit;
await this.model.load(this.props);
},
};
});
this.searchBarToggler = useSearchBarToggler();
this.firstLoad = true;
onWillPatch(() => {
this.firstLoad = false;
});
}
get modelParams() {
const { activeFields, fields } = extractFieldsFromArchInfo(
this.props.archInfo,
this.props.fields
);
for (let [key, value] of Object.entries(activeFields)) {
let fieldNode = this.props.archInfo.fieldNodes[key];
value.conserveLineBreaks = fieldNode.conserveLineBreaks;
}
return {
resModel: this.props.resModel,
fields,
activeFields,
};
}
get rendererProps() {
return {
model: this.model,
records: this.model.records,
activeFields: this.model.config.activeFields,
editRecord: this.onClickEdit.bind(this),
};
}
get className() {
return this.props.className;
}
async onClickCreate() {
return executeButtonCallback(this.rootRef.el, () => this.props.createRecord());
}
async onClickEdit(ev, record) {
return executeButtonCallback(this.rootRef.el, () => this.props.selectRecord(record.id, true));
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="cheat_web.CheatView">
<div t-att-class="className" t-ref="root">
<Layout display="props.display" className="'h-100 overflow-auto'">
<t t-set-slot="control-panel-create-button">
<button type="button" class="btn btn-primary o_list_button_add" t-on-click="onClickCreate">
New
</button>
</t>
<t t-set-slot="layout-actions">
<SearchBar t-if="searchBarToggler.state.showSearchBar" autofocus="firstLoad"/>
</t>
<t t-component="props.Renderer" t-props="rendererProps"/>
</Layout>
</div>
</t>
</templates>
@@ -0,0 +1,46 @@
/** @odoo-module */
import { markRaw } from "@odoo/owl";
import { KeepLast } from "@web/core/utils/concurrency";
import { Model } from "@web/model/model";
import { getFieldsSpec } from "@web/model/relational_model/utils";
import { orderByToString } from "@web/search/utils/order_by";
export class CheatModel extends Model {
setup(params) {
console.log("Cheat Model - this: ", this);
console.log("Cheat Model - setup params: ", params);
this.keepLast = markRaw(new KeepLast());
this.config = params;
}
_getNextConfig(currentConfig, params) {
const config = Object.assign({}, currentConfig, params);
return config;
}
async load(params = {}) {
console.log("Cheat Model - load params: ", params);
const config = this._getNextConfig(this.config, params);
const kwargs = {
specification: getFieldsSpec(config.activeFields, config.fields, config.context),
offset: config.offset,
order: orderByToString(config.orderBy),
limit: config.limit,
context: { ...config.context }
};
const { length, records } = await this.keepLast.add(
this.orm.webSearchRead(config.resModel, config.domain, kwargs)
);
this.offset = config.offset;
this.limit = config.limit;
this.records = records;
this.recordsLength = length;
this.config = config;
}
}
@@ -0,0 +1,43 @@
import { Component, useRef } from "@odoo/owl";
import { executeButtonCallback } from "@web/views/view_button/view_button_hook";
export class CheatRenderer extends Component {
static template = `cheat_web.CheatRenderer`;
static props = {
model: Object,
records: Object,
activeFields: Object,
editRecord: Function
}
setup() {
console.log("Cheat Renderer - this: ", this);
this.rootRef = useRef("renderer_root");
}
getInputId(record, field){
return record.id + '_' + field
}
getFirstFiveFields(){
const fields = Object.keys(this.props.activeFields).filter(o => o !== "display_name" & o !== "id") .sort().slice(0, 4);
return fields;
}
getLabel(field){
const config = this.props.model.config;
return config.fields[field].string;
}
getFieldValue(record, field){
return record[field];
}
getConserveLineBreakSetting(field){
return this.props.activeFields[field].conserveLineBreaks;
}
onEditButtonClick(ev, record) {
this.props.editRecord(ev, record);
}
}
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="cheat_web.CheatRenderer">
<div class="row m-2" t-ref="renderer_root">
<t t-foreach="props.records" t-as="record" t-key="record.id">
<div class="col-lg-6 col-sm-12 p-2">
<div class="card">
<div class="row g-0">
<div class="col-md-3 d-flex justify-content-center align-items-center bg-secondary rounded-start">
<h1><t t-out="record.id"/></h1>
</div>
<div class="col-md-9">
<div class="card-body">
<h2 class="card-title"><t t-out="record.display_name"/></h2>
<div class="card-text card-text border rounded">
<t t-foreach="getFirstFiveFields()" t-as="field" t-key="field_index">
<div t-attf-class="form-group d-flex {{ field_last ? '' : 'border-bottom' }}">
<t t-set="fieldId" t-value="getInputId(record, field)"/>
<label t-att-for="fieldId" class="col-sm-4 col-form-label me-1 px-1 bg-secondary">
<t t-out="getLabel(field)"/>
</label>
<div class="col-sm-8 px-1">
<t t-if="getConserveLineBreakSetting(field)" >
<textarea readonly="1" rows="5" class="form-control-plaintext" t-att-id="fieldId">
<t t-out="getFieldValue(record, field)" />
</textarea>
</t>
<t t-else="" >
<input type="text" readonly="1" class="form-control-plaintext text-truncate"
t-att-id="fieldId" t-att-value="getFieldValue(record, field)"/>
</t>
</div>
</div>
</t>
</div>
<div class="d-flex justify-content-end">
<button t-on-click="(ev) => this.onEditButtonClick(ev, record)" class="btn btn-primary mt-2">Edit</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,33 @@
import { registry } from "@web/core/registry";
import { CheatModel } from "./cheat_model";
import { CheatController } from "./cheat_controller";
import { CheatArchParser } from "./cheat_arch_parser";
import { CheatRenderer } from "./cheat_renderer";
export const cheatView = {
type: "cheat",
searchMenuTypes: ["filter", "favorite"],
Controller: CheatController,
Renderer: CheatRenderer,
Model: CheatModel,
ArchParser: CheatArchParser,
props(genericProps, view) {
console.log("Cheat View - this: ", this);
console.log("Cheat View - genericProps: ", genericProps);
console.log("Cheat View - view: ", view);
const { ArchParser } = view;
const { arch, relatedModels, resModel } = genericProps;
const archInfo = new ArchParser().parse(arch, relatedModels, resModel);
return {
...genericProps,
Model: view.Model,
Renderer: view.Renderer,
archInfo,
};
},
};
registry.category("views").add("cheat", cheatView);
@@ -0,0 +1,18 @@
import { Component } from "@odoo/owl";
import { standardViewProps } from "@web/views/standard_view_props";
import { Layout } from "@web/search/layout";
export class HelloController extends Component {
static template = `cheat_web.HelloView`;
static props = {
...standardViewProps,
aValue: { type: String }
};
static components = { Layout };
setup() {
console.log("Hello Controller - this: ", this);
console.log("Hello Controller - props: ", this.props);
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="cheat_web.HelloView">
<div class="h-100" t-ref="root">
<Layout display="props.display" className="'h-100'">
<div class="d-flex flex-column h-75 justify-content-center align-items-center">
<h1><span><i class="fa fa-smile-o" style="font-size: xxx-large !important;"></i></span></h1>
<h1>Hello there!</h1>
</div>
</Layout>
</div>
</t>
</templates>
@@ -0,0 +1,20 @@
import { registry } from "@web/core/registry";
import { HelloController } from "./hello_controller";
export const helloView = {
type: "hello",
Controller: HelloController,
props(genericProps, view) {
console.log("Hello View - this: ", this);
console.log("Hello View - genericProps: ", genericProps);
console.log("Hello View - view: ", view);
return {
...genericProps,
aValue: 'A value from view type.'
};
},
};
registry.category("views").add("hello", helloView);
@@ -0,0 +1,32 @@
import { Component, useState, useRef, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { standardViewProps } from "@web/views/standard_view_props";
import { Layout } from "@web/search/layout";
export class StatisticController extends Component {
static template = `cheat_web.StatisticView`;
static props = {
...standardViewProps,
Model: Function,
Renderer: Function,
};
static components = { Layout };
setup() {
console.log("Statistic Controller - this: ", this);
console.log("Statistic Controller - props: ", this.props);
this.orm = useService("orm");
this.model = new this.props.Model(
this.orm,
this.props.resModel,
this.props.fields
);
onWillStart(async () => {
await this.model.load(this.props);
});
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="cheat_web.StatisticView">
<div class="h-100">
<Layout display="props.display" className="'h-100'">
<t t-component="props.Renderer" model="model"/>
</Layout>
</div>
</t>
</templates>
@@ -0,0 +1,21 @@
import { KeepLast } from "@web/core/utils/concurrency";
export class StatisticModel {
constructor(orm, resModel, fields) {
this.orm = orm;
this.resModel = resModel;
this.fields = fields;
this.keepLast = new KeepLast();
console.log("Statistic Model - this: ", this);
}
async load(params) {
console.log("Statistic Model - load params: ", params);
const recordCount = await this.keepLast.add(
this.orm.searchCount(this.resModel, [])
);
this.recordCount = recordCount;
}
}
@@ -0,0 +1,26 @@
import {
Component
} from "@odoo/owl";
export class StatisticRenderer extends Component {
static template = `cheat_web.StatisticRenderer`;
static props = {
model: Object
}
fieldInfo(name){
return this.props.model.fields[name];
}
fieldCount() {
return Object.keys(this.props.model.fields).length;
}
fieldNames() {
return Object.keys(this.props.model.fields).sort();
}
setup() {
console.log("Statistic Renderer - this: ", this);
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="cheat_web.StatisticRenderer">
<div class="row m-3 d-flex justify-content-center align-items-center">
<div class="card p-0">
<div class="row g-0 ">
<div class="col-md-1 col-2 d-flex flex-column pt-4 justify-content-start align-items-center bg-secondary rounded-start">
<h1><t t-out="props.model.recordCount"/></h1>
<div>records</div>
</div>
<div class="col-md-11 col-10">
<div class="card-body overflow-y-auto">
<h2 class="card-title">Model: <t t-out="props.model.resModel"/></h2>
<div class="card-text">
<div>Fields count: <t t-out="fieldCount()"/></div>
<div style="max-height: 75dvh;">
<table class="table table-striped table-hover mt-3 no-cellpadding">
<thead>
<tr class="table-secondary">
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr t-foreach="fieldNames()" t-as="field" t-key="field_index">
<t t-set="fInfo" t-value="fieldInfo(field)" />
<td><t t-out="fInfo['name']"/></td>
<td><t t-out="fInfo['type']"/></td>
<td><t t-out="fInfo['help']"/></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,25 @@
import { registry } from "@web/core/registry";
import { StatisticModel } from "./statistic_model";
import { StatisticController } from "./statistic_controller";
import { StatisticRenderer } from "./statistic_renderer";
export const statisticView = {
type: "statistic",
Controller: StatisticController,
Renderer: StatisticRenderer,
Model: StatisticModel,
props(genericProps, view) {
console.log("Statistic View - this: ", this);
console.log("Statistic View - genericProps: ", genericProps);
console.log("Statistic View - view: ", view);
return {
...genericProps,
Model: view.Model,
Renderer: view.Renderer
};
},
};
registry.category("views").add("statistic", statisticView);
+1
View File
@@ -0,0 +1 @@
from . import view_validation
+34
View File
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
import os
import logging
from lxml import etree
from odoo import tools
from odoo.tools.view_validation import validate
_logger = logging.getLogger(__name__)
_schema_validator = {}
def _get_validator(view_type):
""" Return a validator for the given view type, or None. """
if view_type not in _schema_validator:
with tools.file_open(os.path.join('cheat_web', 'rng', '%s_view.rng' % view_type)) as frng:
try:
relaxng_doc = etree.parse(frng)
_schema_validator[view_type] = etree.RelaxNG(relaxng_doc)
except Exception:
_schema_validator[view_type] = None
return _schema_validator[view_type]
@validate('hello', 'statistic', 'cheat')
def view_schema_validation(arch, **kwargs):
""" Get RNG validator and validate RNG file."""
validator = _get_validator(arch.tag)
if validator and not validator.validate(arch):
for error in validator.error_log:
_logger.error("%s", error)
return False
return True
+107
View File
@@ -0,0 +1,107 @@
<odoo>
<data>
<!-- List view, also called list view on models -->
<record id="cheat_web_view_list" model="ir.ui.view">
<field name="name">cheat.web.view.list</field>
<field name="model">cheat.web</field>
<field name="arch" type="xml">
<list string="Cheat Web">
<field name="char_field" />
<field name="int_field" />
</list>
</field>
</record>
<!-- Form view on models -->
<record id="cheat_web_view_form" model="ir.ui.view">
<field name="name">cheat.web.view.form</field>
<field name="model">cheat.web</field>
<field name="arch" type="xml">
<form string="Cheat Web">
<sheet>
<group>
<field name="char_field" />
<field name="int_field" />
</group>
</sheet>
</form>
</field>
</record>
<!-- Search view on models -->
<record id="cheat_web_view_search" model="ir.ui.view">
<field name="name">cheat.web.view.search</field>
<field name="model">cheat.web</field>
<field name="arch" type="xml">
<search>
<field name="char_field" />
<field name="int_field" />
</search>
</field>
</record>
<!-- Hello view on models -->
<record id="cheat_web_view_hello" model="ir.ui.view">
<field name="name">cheat.web.view.hello</field>
<field name="model">cheat.web</field>
<field name="arch" type="xml">
<hello/>
</field>
</record>
<!-- Statistic view on models -->
<record id="cheat_web_view_statistic" model="ir.ui.view">
<field name="name">cheat.web.view.statistic</field>
<field name="model">cheat.web</field>
<field name="arch" type="xml">
<statistic/>
</field>
</record>
<!-- Cheat view on models -->
<record id="cheat_web_view_cheat" model="ir.ui.view">
<field name="name">cheat.web.view.cheat</field>
<field name="model">cheat.web</field>
<field name="arch" type="xml">
<cheat limit="20">
<field name="char_field" />
<field name="int_field" />
</cheat>
</field>
</record>
<!-- Action opening view on model -->
<record model="ir.actions.act_window" id="action_cheat_web_custom_view_type">
<field name="name">Custom View Type</field>
<field name="res_model">cheat.web</field>
<field name="view_mode">list,form,hello,statistic,cheat</field>
</record>
<record model="ir.actions.client" id="action_cheat_web">
<field name="name">Web Framework</field>
<field name="tag">cheat_web</field>
</record>
<record model="ir.actions.client" id="action_cheat_owl">
<field name="name">Web Library</field>
<field name="tag">cheat_owl</field>
</record>
<record model="ir.actions.client" id="action_cheat_owl_qweb">
<field name="name">QWeb</field>
<field name="tag">cheat_owl_qweb</field>
</record>
<menuitem name="Web" id="cheat_web_menu_top" parent="cheat_module.cheat_menu_root"
sequence="100" />
<menuitem name="Web Framework" id="cheat_web_menu" parent="cheat_web_menu_top"
action="action_cheat_web" sequence="10" />
<menuitem name="Web Library" id="cheat_owl_menu" parent="cheat_web_menu_top"
action="action_cheat_owl" sequence="20" />
<menuitem name="QWeb" id="cheat_owl_qweb_menu" parent="cheat_web_menu_top"
action="action_cheat_owl_qweb" sequence="30" />
<menuitem name="Custom View Type" id="cheat_web_custom_view_type"
parent="cheat_web_menu_top"
action="action_cheat_web_custom_view_type" sequence="40" />
</data>
</odoo>
+36
View File
@@ -0,0 +1,36 @@
<odoo>
<data>
<record id="view_partner_hello" model="ir.ui.view">
<field name="name">res.partner.hello</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<hello />
</field>
</record>
<record id="view_partner_statistic" model="ir.ui.view">
<field name="name">res.partner.statistic</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<statistic />
</field>
</record>
<record id="view_partner_cheat" model="ir.ui.view">
<field name="name">res.partner.cheat</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<cheat>
<field name="complete_name"/>
<field name="contact_address" conserve-line-breaks="true"/>
<field name="email"/>
<field name="type"/>
</cheat>
</field>
</record>
<record id="contacts.action_contacts" model="ir.actions.act_window">
<field name="view_mode">kanban,list,form,activity,hello,statistic,cheat</field>
</record>
</data>
</odoo>
+21
View File
@@ -0,0 +1,21 @@
# Node UI Basics
> [!WARNING]
> This module is purely experimental and for educational purpose use only.
>
> Do not use it in any environment but in an experimental one, definitely not in a production environment.
>
> I'm not responsible for any damage or harm by the use of anything from this repo.
>
> Use it at your own risk.
> [!CAUTION]
> Do not use this module unless you have reviewed the source codes thoroughly, understand what it does and in an experimental environment.
A demo of what a node based UI app looks like.
Please watch these videos for more details:
[![EXPLORING_ODOO](https://img.youtube.com/vi/sMDIly3bddo/0.jpg)](https://youtu.be/sMDIly3bddo)
[![EXPLORING_ODOO](https://img.youtube.com/vi/iFPyQjJ2Uyw/0.jpg)](https://youtu.be/iFPyQjJ2Uyw)
[![EXPLORING_ODOO](https://img.youtube.com/vi/knC4BaGbWGo/0.jpg)](https://youtu.be/knC4BaGbWGo)
[![EXPLORING_ODOO](https://img.youtube.com/vi/v9TKiaKA_SE/0.jpg)](https://youtu.be/v9TKiaKA_SE)
View File
+25
View File
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
{
'name': "Node UI",
'summary': """Node UI""",
'description': """
Node UI
""",
'author': "Yoni Tjio",
'category': 'Productivity',
'version': '18.0.1.0.0',
'depends': ['web'],
'data': [
'views/node_ui_views.xml',
],
'assets': {
"web.assets_backend": [
"node_ui/static/lib/FileSaver.js",
"node_ui/static/src/**/*",
],
},
"license":"Other proprietary",
"application": True,
"installable": True,
"auto_install": False
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-align-center">
<path d="M8 1a.5.5 0 0 1 .5.5V6h-1V1.5A.5.5 0 0 1 8 1zm0 14a.5.5 0 0 1-.5-.5V10h1v4.5a.5.5 0 0 1-.5.5zM2 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7z"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-align-end">
<path fill-rule="evenodd" d="M14.5 1a.5.5 0 0 0-.5.5v13a.5.5 0 0 0 1 0v-13a.5.5 0 0 0-.5-.5z"/>
<path d="M13 7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7z"/>
</svg>

After

Width:  |  Height:  |  Size: 438 B

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-align-start">
<path fill-rule="evenodd" d="M1.5 1a.5.5 0 0 1 .5.5v13a.5.5 0 0 1-1 0v-13a.5.5 0 0 1 .5-.5z"/>
<path d="M3 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z"/>
</svg>

After

Width:  |  Height:  |  Size: 438 B

+1
View File
@@ -0,0 +1 @@
Svg files from svgrepo.com
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="undefined" height="undefined" viewBox="0 0 36 36"><path fill="currentColor" d="M9.8 18.8h16.4v3.08h1.6V17.2h-9V14h-1.6v3.2h-9v4.68h1.6V18.8z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M14 23H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2ZM4 31v-6h10v6Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="currentColor" d="M32 23H22a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2Zm-10 8v-6h10v6Z" class="clr-i-outline clr-i-outline-path-3"/><path fill="currentColor" d="M13 13h10a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H13a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2Zm0-8h10v6H13Z" class="clr-i-outline clr-i-outline-path-4"/><path fill="none" d="M0 0h36v36H0z"/></svg>

After

Width:  |  Height:  |  Size: 763 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="undefined" height="undefined" viewBox="0 0 24 24"><path fill="currentColor" d="M15.27 19v-1.866H11.5V12.5H8.711v1.846H3V9.635h5.712V11.5H11.5V6.846h3.77V4.981H21v4.73h-5.73V7.847H12.5v8.289h2.77V14.29H21V19zm1-1H20v-2.712h-3.73zM4 13.346h3.712v-2.711H4zm12.27-4.634H20V5.98h-3.73zm0 9.288v-2.712zm-8.558-4.654v-2.711zm8.557-4.634V5.98z"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-node-minus">
<path fill-rule="evenodd" d="M11 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM6.025 7.5a5 5 0 1 1 0 1H4A1.5 1.5 0 0 1 2.5 10h-1A1.5 1.5 0 0 1 0 8.5v-1A1.5 1.5 0 0 1 1.5 6h1A1.5 1.5 0 0 1 4 7.5h2.025zM1.5 7a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zM8 8a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5A.5.5 0 0 1 8 8z"/>
</svg>

After

Width:  |  Height:  |  Size: 586 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-node-plus">
<path fill-rule="evenodd" d="M11 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM6.025 7.5a5 5 0 1 1 0 1H4A1.5 1.5 0 0 1 2.5 10h-1A1.5 1.5 0 0 1 0 8.5v-1A1.5 1.5 0 0 1 1.5 6h1A1.5 1.5 0 0 1 4 7.5h2.025zM11 5a.5.5 0 0 1 .5.5v2h2a.5.5 0 0 1 0 1h-2v2a.5.5 0 0 1-1 0v-2h-2a.5.5 0 0 1 0-1h2v-2A.5.5 0 0 1 11 5zM1.5 7a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="undefined" height="undefined" viewBox="0 0 24 24"><path fill="currentColor" d="M10 2a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H8v2h5V9a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-1H8v6h5v-1a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-1H7a1 1 0 0 1-1-1V8H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h6Zm9 16h-4v2h4v-2Zm0-8h-4v2h4v-2ZM9 4H5v2h4V4Z"/></svg>

After

Width:  |  Height:  |  Size: 416 B

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#000000" fill-rule="evenodd" d="M4 6.25A2.25 2.25 0 016.25 4h3a2.25 2.25 0 012.25 2.25V7h3.25a.75.75 0 010 1.5H11.5v.75a2.25 2.25 0 01-2.25 2.25h-3A2.25 2.25 0 014 9.25V8.5H.75a.75.75 0 010-1.5H4v-.75zm6 0a.75.75 0 00-.75-.75h-3a.75.75 0 00-.75.75v3c0 .414.336.75.75.75h3a.75.75 0 00.75-.75v-3z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 555 B

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>reset</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Combined-Shape" fill="#000000" transform="translate(74.806872, 64.000000)">
<path d="M351.859794,42.6666667 L351.859794,85.3333333 L283.193855,85.3303853 C319.271288,116.988529 341.381875,163.321355 341.339886,213.803851 C341.27474,291.98295 288.098183,360.121539 212.277591,379.179704 C136.456999,398.237869 57.3818117,363.341907 20.3580507,294.485411 C-16.6657103,225.628916 -2.17003698,140.420413 55.5397943,87.68 C63.6931909,100.652227 75.1888658,111.189929 88.8197943,118.186667 C59.4998648,141.873553 42.4797783,177.560832 42.5264609,215.253333 C43.5757012,285.194843 100.577082,341.341203 170.526461,341.333333 C234.598174,342.388718 289.235113,295.138227 297.4321,231.584253 C303.556287,184.101393 282.297007,138.84385 245.195596,112.637083 L245.193128,192 L202.526461,192 L202.526461,42.6666667 L351.859794,42.6666667 Z M127.859794,-1.42108547e-14 C151.423944,-1.42108547e-14 170.526461,19.1025173 170.526461,42.6666667 C170.526461,66.230816 151.423944,85.3333333 127.859794,85.3333333 C104.295645,85.3333333 85.1931276,66.230816 85.1931276,42.6666667 C85.1931276,19.1025173 104.295645,-1.42108547e-14 127.859794,-1.42108547e-14 Z">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23 5.5C23 7.98528 20.9853 10 18.5 10C17.0993 10 15.8481 9.36007 15.0228 8.35663L9.87308 10.9315C9.95603 11.2731 10 11.63 10 11.9971C10 12.3661 9.9556 12.7247 9.87184 13.0678L15.0228 15.6433C15.8482 14.6399 17.0993 14 18.5 14C20.9853 14 23 16.0147 23 18.5C23 20.9853 20.9853 23 18.5 23C16.0147 23 14 20.9853 14 18.5C14 18.1319 14.0442 17.7742 14.1276 17.4318L8.97554 14.8558C8.1502 15.8581 6.89973 16.4971 5.5 16.4971C3.01472 16.4971 1 14.4824 1 11.9971C1 9.51185 3.01472 7.49713 5.5 7.49713C6.90161 7.49713 8.15356 8.13793 8.97886 9.14254L14.1275 6.5682C14.0442 6.2258 14 5.86806 14 5.5C14 3.01472 16.0147 1 18.5 1C20.9853 1 23 3.01472 23 5.5ZM16.0029 5.5C16.0029 6.87913 17.1209 7.99713 18.5 7.99713C19.8791 7.99713 20.9971 6.87913 20.9971 5.5C20.9971 4.12087 19.8791 3.00287 18.5 3.00287C17.1209 3.00287 16.0029 4.12087 16.0029 5.5ZM16.0029 18.5C16.0029 19.8791 17.1209 20.9971 18.5 20.9971C19.8791 20.9971 20.9971 19.8791 20.9971 18.5C20.9971 17.1209 19.8791 16.0029 18.5 16.0029C17.1209 16.0029 16.0029 17.1209 16.0029 18.5ZM5.5 14.4943C4.12087 14.4943 3.00287 13.3763 3.00287 11.9971C3.00287 10.618 4.12087 9.5 5.5 9.5C6.87913 9.5 7.99713 10.618 7.99713 11.9971C7.99713 13.3763 6.87913 14.4943 5.5 14.4943Z" fill="#0F0F0F"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.803 5.33333C13.803 3.49238 15.3022 2 17.1515 2C19.0008 2 20.5 3.49238 20.5 5.33333C20.5 7.17428 19.0008 8.66667 17.1515 8.66667C16.2177 8.66667 15.3738 8.28596 14.7671 7.67347L10.1317 10.8295C10.1745 11.0425 10.197 11.2625 10.197 11.4872C10.197 11.9322 10.109 12.3576 9.94959 12.7464L15.0323 16.0858C15.6092 15.6161 16.3473 15.3333 17.1515 15.3333C19.0008 15.3333 20.5 16.8257 20.5 18.6667C20.5 20.5076 19.0008 22 17.1515 22C15.3022 22 13.803 20.5076 13.803 18.6667C13.803 18.1845 13.9062 17.7255 14.0917 17.3111L9.05007 13.9987C8.46196 14.5098 7.6916 14.8205 6.84848 14.8205C4.99917 14.8205 3.5 13.3281 3.5 11.4872C3.5 9.64623 4.99917 8.15385 6.84848 8.15385C7.9119 8.15385 8.85853 8.64725 9.47145 9.41518L13.9639 6.35642C13.8594 6.03359 13.803 5.6896 13.803 5.33333Z" fill="#1C274C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg"><path d="M 2.9629 21.9230 C 4.1254 21.9230 4.8091 21.2392 4.8091 20.0540 L 4.8091 14.6523 C 4.8091 12.2820 6.0627 11.0740 8.3419 11.0740 L 47.6810 11.0740 C 49.9375 11.0740 51.2136 12.2820 51.2136 14.6523 L 51.2136 20.0540 C 51.2136 21.2392 51.8975 21.9230 53.0598 21.9230 C 54.2449 21.9230 54.8830 21.2392 54.8830 20.0540 L 54.8830 14.4700 C 54.8830 9.7748 52.5127 7.4044 47.7263 7.4044 L 8.2963 7.4044 C 3.5327 7.4044 1.1396 9.7520 1.1396 14.4700 L 1.1396 20.0540 C 1.1396 21.2392 1.8006 21.9230 2.9629 21.9230 Z M 27.9658 38.2648 C 29.1509 38.2648 29.8119 37.4671 29.8119 36.2135 L 29.8119 21.4443 L 35.4643 21.4443 C 36.3532 21.4443 36.9914 20.8517 36.9914 19.9400 C 36.9914 19.0056 36.3532 18.4586 35.4643 18.4586 L 20.5584 18.4586 C 19.6923 18.4586 19.0313 19.0056 19.0313 19.9400 C 19.0313 20.8517 19.6923 21.4443 20.5584 21.4443 L 26.1652 21.4443 L 26.1652 36.2135 C 26.1652 37.4215 26.8034 38.2648 27.9658 38.2648 Z M 2.9629 30.7663 C 4.6040 30.7663 5.9259 29.4215 5.9259 27.7805 C 5.9259 26.1623 4.6040 24.8404 2.9629 24.8404 C 1.3447 24.8404 0 26.1623 0 27.7805 C 0 29.4215 1.3447 30.7663 2.9629 30.7663 Z M 53.0598 30.7663 C 54.6781 30.7663 56 29.4215 56 27.7805 C 56 26.1623 54.6781 24.8404 53.0598 24.8404 C 51.3961 24.8404 50.0966 26.1395 50.0966 27.7805 C 50.0966 29.4215 51.3961 30.7663 53.0598 30.7663 Z M 8.2963 49.3645 L 47.7263 49.3645 C 52.5127 49.3645 54.8830 46.9942 54.8830 42.2990 L 54.8830 35.5754 C 54.8830 34.3902 54.2220 33.7292 53.0598 33.7292 C 51.8746 33.7292 51.2136 34.3902 51.2136 35.5754 L 51.2136 42.1167 C 51.2136 44.4870 49.9375 45.6950 47.6810 45.6950 L 8.3419 45.6950 C 6.0627 45.6950 4.8091 44.4870 4.8091 42.1167 L 4.8091 35.5754 C 4.8091 34.3902 4.1254 33.7292 2.9629 33.7292 C 1.8006 33.7292 1.1396 34.3902 1.1396 35.5754 L 1.1396 42.2990 C 1.1396 47.0170 3.5327 49.3645 8.2963 49.3645 Z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+20
View File
@@ -0,0 +1,20 @@
.menu-icon {
filter: invert(0.80);
vertical-align: text-bottom;
}
.dragging {
border: gray dashed 1px;
height: 24px;
}
.dragged-placeholder {
width: 100%;
margin-top: "gray dashed 1px";
border: gray dashed 1px;
height: 24px;
}
.dragged-placeholder::after {
content: '\200b';
}
+123
View File
@@ -0,0 +1,123 @@
import { Component, useRef } from "@odoo/owl";
import { useBus } from "@web/core/utils/hooks";
import { useDraggable } from "@node_ui/node_ui/utils";
export class Path extends Component {
static template = "node_ui.connection-path";
static props = {
path: Object,
};
}
export class Waypoint extends Component {
static template = "node_ui.connection-waypoint";
static props = {
waypoint: Object,
};
}
export class Connection extends Component {
static template = "node_ui.connection";
static components = { Path, Waypoint };
static props = {
connection: Object
};
setup() {
this.rootRef = useRef("root");
useDraggable({
ref: this.rootRef,
elements: ".point",
// @ts-ignore
onWillStartDrag: ({ element: ctx, x, y}) => {
const cnn = this.props.connection;
const wp = cnn.waypoints.find(o => o.id == ctx.id);
ctx.startX = wp.pos.centerX;
ctx.startY = wp.pos.centerY;
ctx.startPointerX = x;
ctx.startPointerY = y;
},
onDrag: ({ element: ctx, x, y}) => {
const deltaX = (x - ctx.startPointerX) / this.env.translation.zoom;
const deltaY = (y - ctx.startPointerY) / this.env.translation.zoom;
const wpX = (ctx.startX + deltaX);
const wpY = (ctx.startY + deltaY);
this.onMoveWaypoint(ctx.id, wpX, wpY);
},
});
useBus(this.env.bus, this.env.channel + "/debug", this.onDebug.bind(this));
}
onMoveWaypoint(id, x, y){
const cnn = this.props.connection;
cnn.moveWaypoint(id, x, y);
}
_pointIsOnPath(pathEl, x, y) {
const svg = this.rootRef.el;
let point = svg.createSVGPoint();
point.x = x;
point.y = y;
return pathEl.isPointInStroke(point);
}
_findPrecedingWaypoint(x, y){
const cnn = this.props.connection;
let onPath = false;
for(let i = 0; i < cnn.paths.length; i++){
const pathEl = this.rootRef.el.getElementById(cnn.paths[i].id);
const onPath = this._pointIsOnPath(pathEl, x, y)
if (onPath){
const waypointIdx = cnn.waypoints.findIndex(o => o.endPathId == cnn.paths[i].id)
return { idx: waypointIdx, onPath: onPath };
}
}
return { idx: -1, onPath: onPath };
}
onClick(event) {
if (event.ctrlKey){
const cnn = this.props.connection;
if (event.target.nodeName === "circle") {
const waypoint = cnn.waypoints.find(o => o.id == event.target.id);
if (waypoint){
cnn.removeWaypoint(waypoint);
}
} else if (event.target.nodeName === "path"){
const path = cnn.paths.find(o => o.id == event.target.id);
if (path){
const docElement = document.querySelector(".node-ui-doc");
const docRect = docElement.getBoundingClientRect();
const evX = event.clientX;
const evY = event.clientY;
const x = (evX - docRect.left) / this.env.translation.zoom;
const y = (evY - docRect.top) / this.env.translation.zoom;
const precedingWaypoint = this._findPrecedingWaypoint(x, y);
if (precedingWaypoint.onPath){
cnn.createWaypoint(path, x, y, precedingWaypoint.idx);
}
}
}
} else {
this.env.bus.trigger(this.env.channel + "/selected", {
id: this.props.connection.id,
type: "connection"
});
}
}
onDebug(ev){
console.log("debug");
}
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="node_ui.connection">
<svg xmlns="http://www.w3.org/2000/svg" class="connection" t-ref="root"
t-on-click.stop.prevent="onClick">
<g class="path" t-att-id="props.connection.id" >
<t t-foreach="props.connection.paths" t-as="path" t-key="path.id">
<Path path="path" />
</t>
</g>
<t t-foreach="props.connection.waypoints" t-as="waypoint" t-key="waypoint.id">
<Waypoint waypoint="waypoint"/>
</t>
</svg>
</t>
<t t-name="node_ui.connection-path">
<path t-att-id="props.path.id" t-att-d="props.path.svgPath()"/>
</t>
<t t-name="node_ui.connection-waypoint">
<circle xmlns="http://www.w3.org/2000/svg" class="point"
t-att-id="props.waypoint.id" t-att-cx="props.waypoint.pos.centerX" t-att-cy="props.waypoint.pos.centerY"
t-att-r="props.waypoint.pos.radius"/>
</t>
</templates>
+174
View File
@@ -0,0 +1,174 @@
import { Component, useRef, onMounted, onWillUnmount } from "@odoo/owl";
import { useBus } from "@web/core/utils/hooks";
import { useDebounced, useThrottleForAnimation } from "@web/core/utils/timing";
import { uuidv4 } from "@node_ui/node_ui/utils";
import { DumbNode, DumbNodeNoInput, DumbNodeNoOutput, DumbNodeWithTextArea,
DumbNodeMultipleInputs, DumbNodeMultipleOutputs } from "@node_ui/node_ui/node";
import { Connection } from "@node_ui/node_ui/connection";
export class Document extends Component {
static template = "node_ui.document";
static props = {
document: Object,
};
setup() {
this.rootRef = useRef("root");
onMounted(() => {
document.addEventListener("mousemove", this.onMouseMove.bind(this));
document.addEventListener("mouseup", this.onMouseUp.bind(this));
});
onWillUnmount(() => {
document.removeEventListener("mousemove", this.onMouseMove.bind(this));
document.removeEventListener("mouseup", this.onMouseUp.bind(this));
});
this.onMouseUp = useDebounced(this.onMouseUp, "animationFrame");
this.onMouseMove = useThrottleForAnimation(this.onMouseMove);
useBus(this.env.bus, this.env.channel + "/new", this.onNewNode.bind(this));
useBus(this.env.bus, this.env.channel + "/delete", this.onDeleteSelected.bind(this));
useBus(this.env.bus, this.env.channel + "/reset", this.onReset.bind(this));
useBus(this.env.bus, this.env.channel + "/selected", this.onSelected.bind(this));
useBus(this.env.bus, this.env.channel + "/clear-selected", this.onClearSelected.bind(this));
useBus(this.env.bus, this.env.channel + "/out-connect", this.onOutConnect.bind(this));
useBus(this.env.bus, this.env.channel + "/in-connect", this.onInConnect.bind(this));
useBus(this.env.bus, this.env.channel + "/debug", this.onDebug.bind(this));
}
get connectionComponent() {
return Connection;
}
_createNode(title, component, x, y) {
const id = uuidv4();
const doc = this.props.document;
doc.addNode(id, title, component, x, y);
}
onNewNode(event) {
const type = event.detail.type;
const x = event.detail.x;
const y = event.detail.y;
switch (type) {
case "dumb": {
this._createNode("Node", DumbNode, x, y);
break;
}
case "dumb-no-input": {
this._createNode("Node - No Input", DumbNodeNoInput, x, y);
break;
}
case "dumb-no-output": {
this._createNode("Node - No Output", DumbNodeNoOutput, x, y);
break;
}
case "dumb-textarea": {
this._createNode("Node - Textarea", DumbNodeWithTextArea, x, y);
break;
}
case "dumb-multiple-outputs": {
this._createNode("Node - Multiple Outputs", DumbNodeMultipleOutputs, x, y);
break;
}
case "dumb-multiple-inputs": {
this._createNode("Node - Multiple Inputs", DumbNodeMultipleInputs, x, y);
break;
}
}
}
clearSelected() {
let els = document.getElementsByClassName("selected");
while (els.length > 0) {
els[0].classList.remove("selected");
els = document.getElementsByClassName("selected");
}
const doc = this.props.document;
doc.clearSelected();
}
onClearSelected() {
this.clearSelected();
}
onSelected(event) {
const el = document.getElementById(event.detail.id);
if (el) {
el.classList.toggle("selected");
const doc = this.props.document;
doc.toggleSelect(event.detail.type, event.detail.id);
} else {
this.clearSelected();
}
}
onDeleteSelected() {
const doc = this.props.document;
doc.deleteSelected();
this.clearSelected();
}
onReset() {
const doc = this.props.document;
doc.reset();
}
onOutConnect(event) {
const id = uuidv4();
const portId = event.detail.id;
const nodeId = event.detail.nodeId;
const doc = this.props.document;
doc.prepareConnection(id, portId, nodeId, event.detail.x, event.detail.y);
}
onInConnect(event) {
const portId = event.detail.id;
const nodeId = event.detail.nodeId;
const doc = this.props.document;
doc.completeConnection(portId, nodeId, event.detail.x, event.detail.y);
}
onMouseMove(event) {
const doc = this.props.document;
if (doc && doc.newConnection !== undefined) {
const docElement = document.querySelector(".node-ui-doc");
const docRect = docElement.getBoundingClientRect();
const x = (event.clientX - docRect.left) / this.env.translation.zoom;
const y = (event.clientY - docRect.top) / this.env.translation.zoom;
doc.updateNewConnection(x, y);
}
}
onMouseUp(event) {
const doc = this.props.document;
if (doc && doc.newConnection !== undefined) {
doc.clearNewConnection();
}
}
onKeydown(event) {
if (event.key === "Delete" && event.ctrlKey) {
event.preventDefault();
event.stopPropagation();
this.onDeleteComponent();
}
}
onDebug(ev) {
console.log("debug");
}
}
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="node_ui.document">
<div t-ref="root" tabindex="0" class="node-ui-doc" t-on-mouseup="onMouseUp"
t-on-keydown="onKeydown">
<t t-foreach="props.document.nodes" t-as="node" t-key="node.id">
<t t-component="node.component" node="node"/>
</t>
<t t-foreach="props.document.connections" t-as="cnn" t-key="cnn.id">
<t t-component="connectionComponent" connection="cnn" />
</t>
<t t-if="props.document.newConnection !== undefined">
<t t-component="connectionComponent" connection="props.document.newConnection" />
</t>
</div>
</t>
</templates>
+575
View File
@@ -0,0 +1,575 @@
import { pick } from "@web/core/utils/objects";
import { uuidv4, makeReactive, removeItem } from "@node_ui/node_ui/utils";
import { BaseNode } from "@node_ui/node_ui/node";
import { DumbNode, DumbNodeNoInput, DumbNodeNoOutput, DumbNodeWithTextArea,
DumbNodeMultipleInputs, DumbNodeMultipleOutputs } from "@node_ui/node_ui/node";
const CLASS_MAP = {
DumbNode: DumbNode,
DumbNodeNoInput: DumbNodeNoInput,
DumbNodeNoOutput: DumbNodeNoOutput,
DumbNodeWithTextArea: DumbNodeWithTextArea,
DumbNodeMultipleInputs: DumbNodeMultipleInputs,
DumbNodeMultipleOutputs: DumbNodeMultipleOutputs,
}
class NodeUiBase {
constructor(id = uuidv4()){
this.id = id;
this.loadedFromFile = false;
}
}
class TitledUiBase extends NodeUiBase {
constructor(id, title){
super(id);
this.title = title;
}
}
export class Waypoint extends NodeUiBase {
constructor(id, centerX, centerY, radius, startPathId, endPathId){
super(id);
const initPos = {
centerX: centerX,
centerY: centerY,
radius: radius,
}
this.pos = makeReactive(this, initPos);
this.startPathId = startPathId;
this.endPathId = endPathId;
}
}
export class Path extends NodeUiBase {
constructor(id, startX, startY, endX, endY){
super(id);
const initPos = {
startX: startX,
startY: startY,
endX: endX,
endY: endY
}
this.pos = makeReactive(this, initPos);
}
getSvgStraightPath() {
const svgPath = [];
svgPath.push("M", this.pos.startX, this.pos.startY);
svgPath.push("L", this.pos.endX, this.pos.endY);
const res = svgPath.join(" ");
return res;
}
// https://stackoverflow.com/a/45245042
_drawCurve(startX, startY, endX, endY) {
// L
let BX = Math.abs(endX - startX) * 0.05 + startX;
let BY = startY;
// C
let CX = startX + Math.abs(endX - startX) * 0.33;
let CY = startY;
let DX = endX - Math.abs(endX - startX) * 0.33;
let DY = endY;
let EX = -Math.abs(endX - startX) * 0.05 + endX;
let EY = endY;
const svgPath = []
svgPath.push("M", startX, startY);
svgPath.push("L", BX, ",", BY);
svgPath.push("C", CX, ",", CY);
svgPath.push(DX, ",", DY);
svgPath.push(EX, ",", EY);
svgPath.push("L", endX, ",", endY);
const res = svgPath.join(" ");
return res;
}
svgCurvyPath() {
const res = this._drawCurve(
this.pos.startX,
this.pos.startY,
this.pos.endX,
this.pos.endY
);
return res;
}
svgPath() {
return this.svgCurvyPath();
}
}
export class Connection extends NodeUiBase {
constructor(id, startX, startY, endX, endY, inPortId, inNodeId, outPortId, outNodeId, {
paths = [],
waypoints = []
} = {}){
super(id);
this.paths = [].concat(paths);
this.waypoints = [].concat(waypoints);
this.lastPos = {};
const initPos = {
startX: startX,
startY: startY,
endX: endX,
endY: endY
}
this.pos = makeReactive(this, initPos, { onChangedHandler: this.updatePaths });
this.inPortId = inPortId;
this.inNodeId = inNodeId;
this.outPortId = outPortId;
this.outNodeId = outNodeId;
if (this.paths.length == 0){
const firstPathId = uuidv4();
const firstPath = new Path(firstPathId, this.pos.startX, this.pos.startY, this.pos.endX, this.pos.endY);
this.paths.push(firstPath);
}
}
updatePaths(owner, pos) {
if (owner.paths.length > 0){
if (pos.startX == owner.lastPos.startX && pos.startY == owner.lastPos.startY){
Object.assign(owner.paths[owner.paths.length - 1].pos, pick(pos, "endX", "endY"));
} else if (pos.endX == owner.lastPos.endX && pos.endY == owner.lastPos.endY){
Object.assign(owner.paths[0].pos, pick(pos, "startX", "startY"));
}
}
Object.assign(owner.lastPos, pos);
}
updateEndPos(x, y) {
Object.assign(this.pos, {endX: x, endY: y});
}
updateStartPos(x, y) {
Object.assign(this.pos, {startX: x, startY: y});
}
removeWaypoint(waypoint){
const waypointIdx = this.waypoints.indexOf(waypoint);
const startPathIdx = this.paths.findIndex(o => o.id == waypoint.startPathId);
const startPath = this.paths[startPathIdx];
if (this.waypoints.length > 1) {
if (waypointIdx == this.waypoints.length - 1){
startPath.pos.endX = this.pos.endX;
startPath.pos.endY = this.pos.endY;
} else {
startPath.pos.endX = this.waypoints[waypointIdx + 1].pos.centerX;
startPath.pos.endY = this.waypoints[waypointIdx + 1].pos.centerY;
this.waypoints[waypointIdx + 1].startPathId = startPath.id;
}
} else { // just one waypoint
startPath.pos.endX = this.pos.endX;
startPath.pos.endY = this.pos.endY;
}
const endPathIdx = this.paths.findIndex(o => o.id == waypoint.endPathId);
this.paths.splice(endPathIdx, 1); // remove path
this.waypoints.splice(waypointIdx, 1); // remove waypoint
}
createWaypoint(path, x, y, previousWaypointIndex){
const pathIdx = this.paths.indexOf(path);
const newWaypointId = uuidv4();
const newWaypoint = new Waypoint(newWaypointId, x, y, 6, path.id);
this.waypoints.splice(previousWaypointIndex + 1, 0, newWaypoint);
const oldEndX = path.pos.endX;
const oldEndY = path.pos.endY;
path.pos.endX = x;
path.pos.endY = y;
const newPathId = uuidv4();
const newPath = new Path(newPathId, x, y, oldEndX, oldEndY)
this.paths.splice(pathIdx + 1, 0, newPath);
newWaypoint.endPathId = newPathId;
const newWaypointIdx = this.waypoints.findIndex(o => o.id == newWaypointId);
if (this.waypoints[newWaypointIdx + 1]){
this.waypoints[newWaypointIdx + 1].startPathId = newPathId;
}
}
moveWaypoint(id, x, y) {
const wp = this.waypoints.find(o => o.id == id);
if (wp){
wp.pos.centerX = x;
wp.pos.centerY = y;
const startPath = this.paths.find(o => o.id == wp.startPathId);
startPath.pos.endX = x;
startPath.pos.endY = y;
const endPath = this.paths.find(o => o.id == wp.endPathId);
endPath.pos.startX = x;
endPath.pos.startY = y;
}
}
}
export class Port extends NodeUiBase {
constructor(id, nodeId, type, maxLinks){
super(id);
this.nodeId = nodeId;
this.type = type;
this.maxLinks = maxLinks;
this.links = [];
}
canAddLink() {
return this.links.length < this.maxLinks
}
addLink(cnn) {
if (!this.canAddLink()){
return;
}
if (cnn.id in this.links){
return;
}
this.links.push(cnn);
}
removeLink(id){
removeItem(this.links, id);
}
clearLinks(){
this.links = [];
}
}
export class Node extends TitledUiBase {
constructor(id, title, component, left, top){
super(id, title);
this.component = component;
this.left = left;
this.top = top;
this.inPorts = [];
this.outPorts = [];
}
move(left, top, deltaX, deltaY){
this.left = left;
this.top = top;
this.inPorts.forEach(port => {
port.links.forEach(cnn => {
cnn.pos.endX = cnn.pos.endX + deltaX;
cnn.pos.endY = cnn.pos.endY + deltaY;
});
});
this.outPorts.forEach(port => {
port.links.forEach(cnn => {
cnn.pos.startX = cnn.pos.startX + deltaX;
cnn.pos.startY = cnn.pos.startY + deltaY;
});
})
}
addInPorts(id, maxLinks){
const port = new Port(id, this.id, "in", maxLinks);
this.inPorts.push(port)
return port;
}
addOutPorts(id, maxLinks){
const port = new Port(id, this.id, "out", maxLinks);
this.outPorts.push(port)
return port;
}
canAddInput(portId){
const port = this.inPorts.find(o => o.id == portId);
return port.canAddLink()
}
addInput(portId, cnn){
const port = this.inPorts.find(o => o.id == portId);
port.addLink(cnn)
}
removeInput(portId, cnnId){
const port = this.inPorts.find(o => o.id == portId);
port.removeLink(cnnId);
}
canAddOutput(portId){
const port = this.outPorts.find(o => o.id == portId);
return port.canAddLink()
}
addOutput(portId, cnn){
const port = this.outPorts.find(o => o.id == portId);
port.addLink(cnn)
}
removeOutput(portId, cnnId){
const port = this.outPorts.find(o => o.id == portId);
port.removeLink(cnnId);
}
getConnections(){
let res = [];
this.inPorts.forEach(port => {
res = res.concat(port.links)
});
this.outPorts.forEach(port => {
res = res.concat(port.links)
});
return res;
}
resetConnections(){
this.inPorts.forEach(port => {
port.clearLinks();
});
this.outPorts.forEach(port => {
port.clearLinks();
});
}
}
export class Document extends TitledUiBase {
constructor(id, sessionId, title){
super(id, title);
this.sessionId = sessionId;
this.nodes = [];
this.connections = [];
this.newConnection = undefined;
this.selected = [];
}
addNode(id, title, component, left, top) {
const node = new Node(id, title, component, left, top);
this.nodes.push(node);
return node;
}
removeConnection(id){
const cnnIdx = this.connections.findIndex(o => o.id === id);
if (cnnIdx > -1){
const cnn = this.connections[cnnIdx];
const outNode = this.nodes.find(o => o.id === cnn.outNodeId);
const inNode = this.nodes.find(o => o.id === cnn.inNodeId);
outNode.removeOutput(cnn.outPortId, id);
inNode.removeInput(cnn.inPortId, id);
removeItem(this.connections, id);
}
}
addConnection(cnn){
this.connections.push(cnn);
return cnn;
}
toggleSelect(type, id){
const i = this.selected.findIndex(o => o.id == id);
if (i > -1){
this.selected.splice(i, 1);
}else {
this.selected.push({
id: id,
type: type
})
}
}
clearSelected(){
this.selected = [];
}
removeNode(id){
const node = this.nodes.find(o => o.id === id);
const cnns = node.getConnections();
node.resetConnections();
cnns.forEach(cnn => {
this.removeConnection(cnn.id);
});
removeItem(this.nodes, id);
}
deleteSelected(){
for(let i = 0; i < this.selected.length; i++){
const sel = this.selected[i];
if (sel.type === "connection"){
this.removeConnection(sel.id);
} else if (sel.type === 'node'){
this.removeNode(sel.id);
}
}
this.clearSelected();
}
reset() {
while (this.nodes.length > 0){
const node = this.nodes[0];
this.removeNode(node.id);
}
}
prepareConnection(id, portId, nodeId, x, y){
this.clearSelected();
const node = this.nodes.find(o => o.id === nodeId);
if (node.canAddOutput(portId)){
this.newConnection = new Connection(
id,
x,
y,
x,
y,
null,
null,
portId,
nodeId
);
return this.newConnection;
}
return null;
}
updateNewConnection(x,y){
this.newConnection.updateEndPos(x, y);
}
completeConnection(portId, nodeId, x, y){
if(this.newConnection !== undefined){
const node = this.nodes.find(o => o.id === nodeId);
if (node.canAddInput(portId)){
const cnn = this.newConnection;
cnn.pos.endX = x;
cnn.pos.endY = y;
cnn.inPortId = portId;
cnn.inNodeId = nodeId;
const outNode = this.nodes.find(o => o.id === cnn.outNodeId);
outNode.addOutput(cnn.outPortId, cnn);
const inNode = this.nodes.find(o => o.id === cnn.inNodeId);
inNode.addInput(cnn.inPortId, cnn);
this.connections.push(cnn);
this.clearNewConnection();
}
}
}
clearNewConnection(){
this.newConnection = undefined;
}
toJson(){
return JSON.stringify(this, (key, value) =>{
if (key === "component") {
if (value.prototype instanceof BaseNode) return value.name
else return value;
} else if (key === "links") {
return undefined;
} else{
return value;
}
});
}
fromJson(json){
this.reset();
const jsonObj = JSON.parse(json, (key, value) => {
if (key === "component") {
return CLASS_MAP[value];
} else {
return value;
}
});
this.id = jsonObj.id;
this.title = jsonObj.title;
this.loadedFromFile = true;
jsonObj["nodes"].forEach(o => {
const node = this.addNode(o.id, o.title, o.component, o.left, o.top);
node.loadedFromFile = true;
o.inPorts.forEach( p => {
const port = node.addInPorts(p.id, p.maxLinks);
port.loadedFromFile = true;
});
o.outPorts.forEach( p => {
const port = node.addOutPorts(p.id, p.maxLinks);
port.loadedFromFile = true;
});
});
jsonObj["connections"].forEach( c =>{
const paths = [];
c.paths.forEach(p => {
const path = new Path(p.id, p.pos.startX, p.pos.startY, p.pos.endX, p.pos.endY);
path.loadedFromFile = true;
paths.push(path);
})
const waypoints = []
c.waypoints.forEach(wp => {
const waypoint = new Waypoint(wp.id, wp.pos.centerX, wp.pos.centerY, wp.pos.radius,
wp.startPathId, wp.endPathId);
waypoint.loadedFromFile = true;
waypoints.push(waypoint);
});
const cnn = new Connection(
c.id, c.pos.startX, c.pos.startY, c.pos.endX, c.pos.endY,
c.inPortId, c.inNodeId, c.outPortId, c.outNodeId, {
paths: paths,
waypoints: waypoints
}
);
cnn.loadedFromFile = true;
const outNode = this.nodes.find(o => o.id === cnn.outNodeId);
outNode.addOutput(cnn.outPortId, cnn);
const inNode = this.nodes.find(o => o.id === cnn.inNodeId);
inNode.addInput(cnn.inPortId, cnn);
this.addConnection(cnn);
});
}
}
+239
View File
@@ -0,0 +1,239 @@
import { Component, useRef, useState } from "@odoo/owl";
import { useService, useBus } from "@web/core/utils/hooks";
import { useDraggable } from "@node_ui/node_ui/utils";
export class Port extends Component {
static template = "node_ui.port";
static props = {
port: Object
};
setup(){
this.rootRef = useRef("root");
useBus(this.env.bus, this.env.channel + "/debug", this.onDebug.bind(this));
}
onMouseDown(event) {
if ((this.props.port.type === "out") && this.props.port.canAddLink()){
const docElement = document.querySelector(".node-ui-doc");
const docRect = docElement.getBoundingClientRect();
const elRect = event.target.getBoundingClientRect();
const x = ((elRect.left - docRect.left) + elRect.width / 2) / this.env.translation.zoom;
const y = ((elRect.top - docRect.top) + elRect.height / 2) / this.env.translation.zoom;
this.env.bus.trigger(this.env.channel + "/out-connect", {
id: this.props.port.id,
nodeId: this.props.port.nodeId,
x: x,
y: y
});
}
}
onMouseUp(event) {
if ((this.props.port.type === "in") && this.props.port.canAddLink()){
const docElement = document.querySelector(".node-ui-doc");
const docRect = docElement.getBoundingClientRect();
const elRect = event.target.getBoundingClientRect();
const x = ((elRect.left - docRect.left) + elRect.width / 2) / this.env.translation.zoom;
const y = ((elRect.top - docRect.top) + elRect.height / 2) / this.env.translation.zoom;
this.env.bus.trigger(this.env.channel + "/in-connect", {
id: this.props.port.id,
nodeId: this.props.port.nodeId,
x: x,
y: y
});
}
}
onDebug(ev){
console.log("debug")
}
}
export class BaseNode extends Component {
static template = "node_ui.node";
static components = { Port };
static props = {
node: Object
};
setup() {
this.ui = useService("ui");
this.rootRef = useRef("root")
this.position = useState({
left: `${this.props.node.left}px`,
top: `${this.props.node.top}px`,
})
useDraggable({
ref: this.rootRef,
handle: ".node-title",
elements: ".node-container",
// @ts-ignore
onWillStartDrag: ({ element: ctx, getRect, x, y}) => {
const elRect = getRect(ctx);
const docRect = getRect(ctx.closest(".node-ui-doc"));
const left = elRect.left - docRect.left;
const top = elRect.top - docRect.top;
ctx.startLeft = left / this.env.translation.zoom;
ctx.startTop = top / this.env.translation.zoom;
ctx.startPointerX = x;
ctx.startPointerY = y;
ctx.lastPointerX = x;
ctx.lastPointerY = y;
},
onDrag: ({ element: ctx, x, y}) => {
const deltaX = (x - ctx.startPointerX) / this.env.translation.zoom;
const deltaY = (y - ctx.startPointerY) / this.env.translation.zoom;
const left = (ctx.startLeft + deltaX);
const top = (ctx.startTop + deltaY);
const trueDeltaX = (x - ctx.lastPointerX) / this.env.translation.zoom;
const trueDeltaY = (y - ctx.lastPointerY) / this.env.translation.zoom;
ctx.lastPointerX = x;
ctx.lastPointerY = y;
ctx.style.left = `${left}px`;
ctx.style.top = `${top}px`;
this.notifyUpdate(left, top, trueDeltaX, trueDeltaY);
},
});
useBus(this.env.bus, this.env.channel + "/debug", this.onDebug.bind(this));
}
notifyUpdate(left, top, deltaX=0, deltaY=0){
const node = this.props.node;
node.move(left, top, deltaX, deltaY);
}
onClick(event){
this.env.bus.trigger(this.env.channel + "/selected", {
id: this.props.node.id,
type: "node"
});
}
get contentStyle() {
if (this.ui.isSmall) {
return "width: 128px;"
} else {
return "";
}
}
onDebug(ev){
console.log("debug");
}
}
export class DumbNode extends BaseNode {
static template = "node_ui.dumb-node";
setup(){
super.setup();
const node = this.props.node;
if (!node.loadedFromFile){
const inId = "in-" + this.props.node.id + "-1";
node.addInPorts(inId, 1);
const outId = "out-" + this.props.node.id + "-1";
node.addOutPorts(outId, 1);
}
}
}
export class DumbNodeNoInput extends BaseNode {
static template = "node_ui.dumb-node-no-input";
setup(){
super.setup();
const node = this.props.node;
if (!node.loadedFromFile){
const outId = "out-" + this.props.node.id + "-1";
node.addOutPorts(outId, 1);
}
}
}
export class DumbNodeMultipleOutputs extends BaseNode {
static template = "node_ui.dumb-node-multiple-outputs";
setup(){
super.setup();
const node = this.props.node;
if (!node.loadedFromFile){
const outId1 = "out-" + this.props.node.id + "-1";
node.addOutPorts(outId1, 1);
const outId2 = "out-" + this.props.node.id + "-2";
node.addOutPorts(outId2, 2);
}
}
}
export class DumbNodeMultipleInputs extends BaseNode {
static template = "node_ui.dumb-node-multiple-inputs";
setup(){
super.setup();
const node = this.props.node;
if (!node.loadedFromFile){
const inId1 = "in-" + this.props.node.id + "-1";
node.addInPorts(inId1, 1);
const inId2 = "in-" + this.props.node.id + "-2";
node.addInPorts(inId2, 2);
}
}
}
export class DumbNodeNoOutput extends BaseNode {
static template = "node_ui.dumb-node-no-output";
setup(){
super.setup();
const node = this.props.node;
if (!node.loadedFromFile){
const inId1 = "in-" + this.props.node.id + "-1";
node.addInPorts(inId1, 1);
}
}
}
export class DumbNodeWithTextArea extends BaseNode {
static template = "node_ui.dumb-node-with-textarea";
setup(){
super.setup();
const node = this.props.node;
if (!node.loadedFromFile){
const outId1 = "out-" + this.props.node.id + "-1";
node.addOutPorts(outId1, 1);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="node_ui.node">
<div t-ref="root" tabstop="0" class="node-container" t-attf-style="left: {{ position.left }}; top: {{ position.top }};"
t-on-click.stop.prevent="onClick">
<div t-att-id="props.node.id" class="node">
<div class="input-ports">
<t t-foreach="props.node.inPorts" t-as="inPort" t-key="inPort.id" >
<Port port="inPort"/>
</t>
</div>
<div class="content overflow-hidden">
<div class="node-title">
<span><t t-out="props.node.title"/></span>
</div>
<div class="node-content">
</div>
<div>
<div>Left: <t t-out="props.node.left"/></div>
<div>Top: <t t-out="props.node.top"/></div>
</div>
</div>
<div class="output-ports">
<t t-foreach="props.node.outPorts" t-as="outPort" t-key="outPort.id" >
<Port port="outPort"/>
</t>
</div>
</div>
</div>
</t>
<t t-name="node_ui.port">
<div t-ref="root" t-att-id="props.id" t-attf-class="'{{props.port.id}}' {{props.port.type === 'out' ? 'output' : 'input'}} my-1"
t-on-mousedown.stop.prevent="onMouseDown" t-on-mouseup.stop.prevent="onMouseUp" >
</div>
</t>
<t t-name="node_ui.dumb-node" t-inherit="node_ui.node" t-inherit-mode="primary">
<xpath expr="//div[hasclass('node-content')]" position="replace">
<div class="node-content" t-att-style="contentStyle">
<div class="d-block text-truncate">Id: <t t-out="props.node.id"/></div>
</div>
</xpath>
</t>
<t t-name="node_ui.dumb-node-no-input" t-inherit="node_ui.dumb-node" t-inherit-mode="primary">
</t>
<t t-name="node_ui.dumb-node-multiple-outputs" t-inherit="node_ui.dumb-node" t-inherit-mode="primary">
</t>
<t t-name="node_ui.dumb-node-multiple-inputs" t-inherit="node_ui.dumb-node" t-inherit-mode="primary">
</t>
<t t-name="node_ui.dumb-node-no-output" t-inherit="node_ui.dumb-node" t-inherit-mode="primary">
</t>
<t t-name="node_ui.dumb-node-with-textarea" t-inherit="node_ui.node" t-inherit-mode="primary">
<xpath expr="//div[hasclass('node-content')]" position="inside">
<div>Id: <t t-out="props.node.id"/></div>
<div class="mb-2"><textarea></textarea></div>
</xpath>
</t>
</templates>
+338
View File
@@ -0,0 +1,338 @@
import { Component, EventBus, useRef, useState, useSubEnv } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { Document } from "@node_ui/node_ui/document"
import { Document as NuiDoc } from "@node_ui/node_ui/models";
import { uuidv4, data2blob, loadFile, useMovable, useMouseListener } from "@node_ui/node_ui/utils";
class NodeMenu extends Component {
static template = "node_ui.node-menu";
static props = {
title: { type: String, optional: true },
icon: { type: String, optional: true },
action: Function,
}
setup() {
this.rootRef = useRef("root");
useMovable({
ref: this.rootRef,
elements: ".node-menu-container",
// @ts-ignore
onDrop: ({ x, y}) => {
const doc = document.querySelector(".node-ui-doc");
const docRect = doc.getBoundingClientRect();
const docParent = doc.parentElement;
const docRectParent = docParent.getBoundingClientRect();
const docX = (x - docRect.left) / this.env.translation.zoom;
const docY = (y - docRect.top) / this.env.translation.zoom;
if (this._contains(docRectParent.left, docRectParent.top, docRectParent.width, docRectParent.height, x, y)) {
this.props.action(docX, docY);
}
}
});
}
_contains(x1, y1, w, h, x, y){
return x1 <= x && x <= (x1 + w) && y1 <= y && y <= (y1 + h);
}
}
class NodeUi extends Component {
static template = "node_ui.node-ui";
static components = { Document, Dropdown, DropdownItem, NodeMenu };
static props = {
...standardActionServiceProps
};
setup() {
this.actions = [
{
title: "Node",
icon: "/node_ui/static/images/align-center.svg",
action: this.addDumbNode.bind(this)
},
{
title: "Node - No Input",
icon: "/node_ui/static/images/align-start.svg",
action: this.addDumbNodeNoInput.bind(this)
},
{
title: "Node - No Output",
icon: "/node_ui/static/images/align-end.svg",
action: this.addDumbNodeNoOutput.bind(this)
},
{
title: "Node - Text Area",
icon: "/node_ui/static/images/textbox.svg",
action: this.addDumbNodeWithTextArea.bind(this)
},
{
title: "Node - Multi Outputs",
icon: "/node_ui/static/images/share.svg",
action: this.addDumbNodeMultipleOutputs.bind(this)
},
{
title: "Node - Multi Inputs",
icon: "/node_ui/static/images/share-no-fill.svg",
action: this.addDumbNodeMultipleInputs.bind(this)
}
];
const docId = uuidv4();
const sessionId = uuidv4();
const node_ui_env = {
translation: {
zoom_max: 2,
zoom_min: 0.6,
zoom_value: 0.1,
last_zoom: 1,
translateX: 0,
translateY: 0,
zoom: 1
},
channel: "node-ui",
bus: new EventBus(),
documents: [new NuiDoc(docId, sessionId, docId)]
}
useSubEnv(node_ui_env);
this.state = useState({
currentDoc: "",
loading: false,
env: node_ui_env
})
this.ui = useService("ui");
this.nodeUiRef = useRef("node-ui");
this.onHandleMouseDown = useMouseListener({
onMouseMove: this.onMouseMove,
onMouseUp: this.onMouseUp,
});
this.lastPointerPos = undefined;
this.isMoving = false;
this.dialog = useService("dialog");
}
get documents() {
return this.state.env.documents;
}
get currentDocSessionId() {
const currentDocId = this.state.env.documents[this.state.env.documents.length - 1].id;
const currentSessionId = this.state.env.documents[this.state.env.documents.length - 1].sessionId;
return currentDocId + "-" + currentSessionId;
}
saveDoc(){
const jsonDoc = this.state.env.documents[0].toJson();
// @ts-ignore
saveAs(data2blob(jsonDoc), "doc.txt" );
}
openFile(file){
const reader = new FileReader();
reader.addEventListener("load", () => {
const docId = uuidv4();
const sessionId = uuidv4();
const newDoc = new NuiDoc(docId, sessionId, docId);
newDoc.fromJson(reader.result);
this.state.env.documents.push(newDoc);
setTimeout(() => {
this.state.env.documents.shift();
}, 20); // give time before destroying component
});
reader.readAsText(file);
}
async openDoc(){
this.zoom_reset();
this.state.loading = true;
await new Promise(resolve => setTimeout(resolve, 250));
const file = await loadFile();
if (file != undefined && file != false) {
this.openFile(file)
};
await new Promise(resolve => setTimeout(resolve, 500));
this.state.loading = false;
}
addDumbNode(x, y) {
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb", x: x, y: y });
}
addDumbNodeNoInput(x, y) {
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-no-input", x: x, y: y });
}
addDumbNodeNoOutput(x, y) {
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-no-output", x: x, y: y });
}
addDumbNodeWithTextArea(x, y) {
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-textarea", x: x, y: y });
}
addDumbNodeMultipleOutputs(x, y) {
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-multiple-outputs", x: x, y: y });
}
addDumbNodeMultipleInputs(x, y) {
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-multiple-inputs", x: x, y: y });
}
deleteSelected() {
this.state.env.bus.trigger(this.state.env.channel + "/delete");
}
reset() {
this.state.env.bus.trigger(this.state.env.channel + "/reset");
}
onDoubleClick(){
this.state.env.bus.trigger(this.state.env.channel + "/clear-selected");
}
onMouseMove(event) {
if (event.ctrlKey){
event.stopPropagation();
event.preventDefault();
if (this.lastPointerPos){
if (this.isMoving || Math.hypot(event.x - this.lastPointerPos.x, event.y - this.lastPointerPos.y) >= 20){
document.documentElement.style.cursor = "move";
this.state.env.translation.translateX = this.state.env.translation.translateX + event.movementX;
this.state.env.translation.translateY = this.state.env.translation.translateY + event.movementY;
const doc = document.querySelector(".node-ui-doc");
// @ts-ignore
doc.style.transform =
"translate(" +
this.state.env.translation.translateX +
"px, " +
this.state.env.translation.translateY +
"px) scale(" +
this.state.env.translation.zoom +
")";
this._notifyZoomChanged();
this.isMoving = true;
}
}
else {
this.lastPointerPos = {
x: event.x,
y: event.y
}
}
}
}
onMouseUp(event) {
event.stopPropagation();
event.preventDefault();
document.documentElement.style.cursor = "default";
this.lastPointerPos = undefined;
this.isMoving = false;
}
_notifyZoomChanged(){
this.state.env.bus.trigger(this.state.env.channel + "/zoom", {
translateX: this.state.env.translation.translateX,
translateY: this.state.env.translation.translateY,
zoom: this.state.env.translation.zoom,
});
}
onWheel(event){
if (event.ctrlKey) {
if (event.deltaY > 0) {
this.zoom_out();
} else {
this.zoom_in();
}
}
}
zoom_refresh() {
this.state.env.translation.translateX = (this.state.env.translation.translateX / this.state.env.translation.last_zoom)
* this.state.env.translation.zoom;
this.state.env.translation.translateY = (this.state.env.translation.translateY / this.state.env.translation.last_zoom)
* this.state.env.translation.zoom;
this.state.env.translation.last_zoom = this.state.env.translation.zoom;
const doc = document.querySelector(".node-ui-doc");
// @ts-ignore
doc.style.transform =
"translate(" +
this.state.env.translation.translateX +
"px, " +
this.state.env.translation.translateY +
"px) scale(" +
this.state.env.translation.zoom +
")";
this._notifyZoomChanged();
}
zoom_in() {
if (this.state.env.translation.zoom < this.state.env.translation.zoom_max) {
this.state.env.translation.zoom += this.state.env.translation.zoom_value;
this.zoom_refresh();
}
}
zoom_out() {
if (this.state.env.translation.zoom > this.state.env.translation.zoom_min) {
this.state.env.translation.zoom -= this.state.env.translation.zoom_value;
this.zoom_refresh();
}
}
zoom_reset() {
if (this.state.env.translation.zoom != 1 || this.state.env.translation.translateX != 0 || this.state.env.translation.translateY != 0) {
this.state.env.translation.translateX = 0;
this.state.env.translation.translateY = 0;
this.state.env.translation.zoom = 1;
this.zoom_refresh();
}
}
get zoom(){
// @ts-ignore
return Math.round(this.state.env.translation.zoom + "e+2");
}
get nodeMenuStyle() {
if (this.ui.isSmall){
return "width: 60px;";
} else {
return "width: 225px;";
}
}
debug() {
this.state.env.bus.trigger(this.state.env.channel + "/debug");
}
}
registry.category("actions").add("NodeUi", NodeUi);
+169
View File
@@ -0,0 +1,169 @@
$nui-background: $gray-900;
$nui-background-color: $nui-background;
$nui-node-color: $gray-500;
$nui-node-border-color: $gray-500;
$nui-node-background-color: $gray-800;
$nui-node-title-bottom-border-color: $gray-600;
$nui-node-selected-border-color: $primary;
$nui-input-background-color: $nui-node-background-color;
$nui-input-border-color: $nui-node-border-color;
$nui-output-background-color: $nui-node-background-color;
$nui-output-border-color: $nui-node-border-color;
$nui-path-color: $gray-500;
$nui-path-selected-color: $primary;
$nui-point-border-color: $nui-node-border-color;
$nui-point-background-color: $gray-500;
.node-ui {
background-color: $nui-background-color;
}
.node-ui-doc-container {
display: flex;
overflow: hidden;
touch-action: none;
.node-ui-doc {
width: 100%;
height: 100%;
position: relative;
user-select: none;
perspective: 0;
// border: 1px dashed red;
.node-container {
position: relative;
}
.node {
display: flex;
align-items: center;
position: absolute;
background: $nui-node-background-color;
min-width: 160px;
min-height: 40px;
border-radius: 4px;
border: 3px solid $nui-node-border-color;
color: $nui-node-color;
z-index: 2;
padding: 10px;
.content {
.node-title {
padding-bottom: 3px;
border-bottom: $nui-node-title-bottom-border-color solid 1px;
&:hover {
cursor: move;
}
}
.node-content {
padding-top: 3px;
padding-bottom: 3px;
}
}
.input-ports {
width: 0px;
.input {
position: relative;
width: 20px;
height: 20px;
background: $nui-node-background-color;
border: 3px solid $nui-node-border-color;
border-right: 0px;
border-radius: 4px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
cursor: crosshair;
z-index: 1;
left: -30px;
}
}
.output-ports {
width: 0px;
.output {
position: relative;
width: 20px;
height: 20px;
background: $nui-node-background-color;
border: 3px solid $nui-node-border-color;
border-radius: 4px;
border-left: 0px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
cursor: crosshair;
z-index: 1;
right: -10px;
}
}
.content {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
}
.node.selected {
border-color: $nui-node-selected-border-color;
.output-ports {
.output {
border-color: $nui-node-selected-border-color;
}
}
.input-ports {
.input {
border-color: $nui-node-selected-border-color;
}
}
}
svg.connection {
z-index: 0;
overflow: visible !important;
position: absolute;
pointer-events: none;
aspect-ratio: 1 / 1;
.path {
fill: none;
stroke-width: 3px;
stroke: $nui-path-color;
pointer-events: all;
&:hover {
cursor: pointer;
}
}
.path.selected {
stroke: $nui-path-selected-color
}
.point {
cursor: move;
stroke: $nui-point-border-color;
stroke-width: 2;
fill: $nui-point-background-color;
pointer-events: all;
&:hover {
cursor: pointer;
}
}
}
}
}
+89
View File
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="node_ui.node-ui">
<div class="d-flex h-100 overflow-hidden" style="min-height:-webkit-fill-available;">
<div class="d-flex flex-column flex-shrink-0 p-3 text-bg-dark" t-att-style="nodeMenuStyle">
<ul class="nav nav-pills flex-column mb-auto">
<t t-foreach="actions" t-as="action" t-key="action_index">
<li class="nav-item">
<NodeMenu action="action.action" title="action.title" icon="action.icon"/>
</li>
</t>
</ul>
</div>
<div class="d-flex flex-column w-100 h-100">
<div class="o-control-panel container-fluid d-flex align-items-center justify-content-between">
<div class="btn-toolbar w-100 w-md-auto my-2 d-flex" role="toolbar">
<div class="btn-group me-2">
<button id="saveDoc" type="button" class="btn btn-secondary" t-on-click="saveDoc">
<i class="fa fa-floppy-o fa-lg align-self-start me-2"></i>
<span class="d-none d-md-inline">Save</span>
</button>
<button id="openDoc" type="button" class="btn btn-secondary" t-on-click="openDoc">
<i class="fa fa-folder-open-o fa-lg align-self-start me-2"></i>
<span class="d-none d-md-inline">Open</span>
</button>
</div>
<Dropdown>
<button class="btn btn-secondary me-2">
<i class="fa fa-plus fa-lg align-self-start me-2"></i>
<span class="d-none d-md-inline">Add</span>
</button>
<t t-set-slot="content">
<t t-foreach="actions" t-as="action" t-key="action_index">
<DropdownItem onSelected="() => { action.action(50, 50) }">
<img class="menu-icon me-2" width="24" height="24" t-att-src="action.icon"></img>
<t t-out="action.title"/>
</DropdownItem>
</t>
</t>
</Dropdown>
<div class="btn-group me-2">
<button id="deleteSelected" type="button" class="btn btn-secondary" t-on-click="deleteSelected">
<i class="fa fa-minus fa-lg align-self-start me-2"></i>
<span class="d-none d-md-inline">Delete</span>
</button>
<button id="reset" type="button" class="btn btn-secondary" t-on-click="reset">
<i class="fa fa-refresh fa-lg align-self-start me-2"></i>
<span class="d-none d-md-inline">Reset</span>
</button>
</div>
<button id="debug" type="button" class="btn me-2" t-on-click="debug">
<i class="fa fa-bug me-2 " />
<span class="d-none d-md-inline">Debug</span>
</button>
</div>
<div class="d-flex flex-fill my-auto mb-2">
</div>
<div class="d-flex align-items-center">
<button id="debug" type="button" class="btn me-2" t-on-click="zoom_reset">
Zoom
</button>
<span class="text-end" style="width: 20px"><t t-out="zoom"/></span>
</div>
</div>
<div t-ref="node-ui" class="node-ui w-100 h-100">
<t t-foreach="documents" t-as="doc" t-key="doc_index">
<t t-if="doc.id + '-' + doc.sessionId == currentDocSessionId">
<div class="node-ui-doc-container h-100 w-100 p-10" t-on-wheel.prevent="onWheel"
t-on-mousedown="onHandleMouseDown" t-on-dblclick="onDoubleClick" >
<Document document="doc" />
</div>
</t>
</t>
</div>
</div>
</div>
</t>
<t t-name="node_ui.node-menu">
<div t-ref="root" class="node-menu w-100 my-2">
<div class="node-menu-container text-start">
<t t-if="props.icon">
<img class="menu-icon me-2" width="24" height="24" t-att-src="props.icon"></img>
</t>
<span class="d-none d-md-inline"><t t-out="props.title"/></span>
</div>
</div>
</t>
</templates>
+209
View File
@@ -0,0 +1,209 @@
import {
useState,
onWillDestroy,
onWillUnmount,
useComponent,
useEffect,
reactive
} from "@odoo/owl";
import { useDebounced } from "@web/core/utils/timing";
import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder_owl";
import { pick } from "@web/core/utils/objects";
/*
* comes from o_spreadsheet.js
* https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
* */
export function uuidv4() {
// mainly for jest and other browsers that do not have the crypto functionality
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
function (c) {
const r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
}
);
}
export function createImperativeHandle() {
return { current: null };
}
export function useImperativeHandle(value) {
const component = useComponent();
useEffect(() => {
if (component.props.handle) {
component.props.handle.current = value;
return () => {
component.props.handle.current = null;
};
} else {
return () => {};
}
});
}
export function useMousePosition() {
const position = useState({ x: 0, y: 0 });
function update(e) {
position.x = e.clientX;
position.y = e.clientY;
}
window.addEventListener("mousemove", update);
onWillDestroy(() => {
window.removeEventListener("mousemove", update);
});
return position;
}
export function useMouseListener(options) {
const component = useComponent();
options.onMouseUp = (options.onMouseUp || (() => {})).bind(component);
options.onMouseDown = (options.onMouseDown || (() => {})).bind(component);
const onMouseMove = useDebounced(
options.onMouseMove || (() => {}),
"animationFrame"
);
const onMouseUp = (event) => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
onMouseMove.cancel(true);
options.onMouseUp(event);
};
onWillUnmount(() => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
});
return (event) => {
options.onMouseDown(event);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp, { once: true });
};
}
// @ts-ignore
export const useDraggable = makeDraggableHook({
name: "useDraggable",
onComputeParams({ ctx }) {
ctx.followCursor = false;
},
onWillStartDrag: ({ ctx }) => pick(ctx.current, "element"),
onDragStart: ({ ctx }) => pick(ctx.current, "element"),
onDrag: ({ ctx }) => pick(ctx.current, "element"),
onDrop: ({ ctx }) => pick(ctx.current, "element"),
});
// @ts-ignore
export const useMovable = makeDraggableHook({
name: "useMovable",
onWillStartDrag: ({ ctx, addCleanup, addStyle, addClass }) => {
addClass(ctx.current.element, "dragging");
ctx.current.container = document.createElement("div");
addStyle(ctx.current.container, {
position: "fixed",
top: 0,
bottom: 0,
left: 0,
right: 0,
});
ctx.current.element.after(ctx.current.container);
addCleanup(() => {
ctx.current.container.remove();
});
return { ctx };
},
onDragStart: ({ ctx, addCleanup, addClass }) => {
// @ts-ignore
ctx.current.placeholder = document.createElement("div");
// @ts-ignore
addClass(ctx.current.placeholder, "dragged-placeholder");
// @ts-ignore
ctx.current.element.before(ctx.current.placeholder);
addCleanup(() => {
// @ts-ignore
ctx.current.placeholder.remove();
});
},
onDrop: ({ ctx, getRect }) => {
const { top, left } = getRect(ctx.current.element);
return { top, left };
},
});
export function createDiv(l, t, w, h, c) {
const el = document.createElement("div");
el.style.position = "fixed";
el.style.pointerEvents = "none";
el.style.left = `${l}px`;
el.style.top = `${t}px`;
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.background = c;
return document.body.appendChild(el);
}
// https://stackoverflow.com/a/29650941
export function data2blob(data, isBase64) {
var chars = "";
if (isBase64) chars = atob(data);
else chars = data;
var bytes = new Array(chars.length);
for (var i = 0; i < chars.length; i++) {
bytes[i] = chars.charCodeAt(i);
}
var blob = new Blob([new Uint8Array(bytes)]);
return blob;
}
export function loadFile(){
return new Promise((resolve, reject) => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", "text/*");
input.addEventListener("change", async () => {
if (input.files === null || input.files.length != 1) {
resolve(false);
}
else {
resolve(input.files[0]);
}
});
input.addEventListener("cancel", async () => {
resolve(false);
});
input.click();
});
}
export function makeReactive(owner, initialState, options = {}) {
const handler = (options.onChangedHandler || (() => {}));
// @ts-ignore
const reactiveState = reactive(initialState, () => handler(owner, reactiveState));
handler(owner, reactiveState);
return reactive(initialState);
}
export function removeItem(array, id) {
const idx = array.findIndex(o => o.id === id);
if (idx > -1) {
const removed = array.splice(idx, 1);
return removed;
}
return null;
}
+13
View File
@@ -0,0 +1,13 @@
<odoo>
<data>
<record model="ir.actions.client" id="node_ui">
<field name="name">Node UI</field>
<field name="tag">NodeUi</field>
</record>
<menuitem name="Node UI" id="node_ui.menu_root" />
<menuitem name="Node UI" id="node_ui.node_ui_menu"
parent="node_ui.menu_root" action="node_ui" sequence="1"/>
</data>
</odoo>
+21
View File
@@ -0,0 +1,21 @@
# Node UI Basics
> [!WARNING]
> This module is purely experimental and for educational purpose use only.
>
> Do not use it in any environment but in an experimental one, definitely not in a production environment.
>
> I'm not responsible for any damage or harm by the use of anything from this repo.
>
> Use it at your own risk.
> [!CAUTION]
> Do not use this module unless you have reviewed the source codes thoroughly, understand what it does and in an experimental environment.
Contains the underlying basic concepts to create Node based UI.
Please watch these videos for more details:
[![EXPLORING_ODOO](https://img.youtube.com/vi/sMDIly3bddo/0.jpg)](https://youtu.be/sMDIly3bddo)
[![EXPLORING_ODOO](https://img.youtube.com/vi/iFPyQjJ2Uyw/0.jpg)](https://youtu.be/iFPyQjJ2Uyw)
[![EXPLORING_ODOO](https://img.youtube.com/vi/knC4BaGbWGo/0.jpg)](https://youtu.be/knC4BaGbWGo)
[![EXPLORING_ODOO](https://img.youtube.com/vi/v9TKiaKA_SE/0.jpg)](https://youtu.be/v9TKiaKA_SE)
View File
+24
View File
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name': "Node UI Basics",
'summary': """Node UI Basics""",
'description': """
Tech stacks for developing Node UI
""",
'author': "Yoni Tjio",
'category': 'Productivity',
'version': '18.0.1.0.0',
'depends': ['web'],
'data': [
'views/node_ui_basics_views.xml',
],
'assets': {
"web.assets_backend": [
"node_ui_basics/static/src/**/*",
],
},
"license":"Other proprietary",
"application": True,
"installable": True,
"auto_install": False
}
+242
View File
@@ -0,0 +1,242 @@
import { Component, useRef, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class CanvasBasics extends Component {
static template = "canvas-basics";
static components = { };
static props = {
...standardActionServiceProps
};
setup() {
this.canvasRef = useRef("interactive-canvas");
this.state = {
startX: 100,
startY: 50,
endX: 1550,
endY: 150,
controlX: 700,
controlY: 300,
};
onMounted(() => {
this._drawShapes();
this.canvasRef.el.width = this.canvasRef.el.parentElement.offsetWidth;
this.canvasRef.el.height = this.canvasRef.el.parentElement.offsetHeight;
this._drawInteractiveCanvas();
})
this.dragging = undefined;
}
onMouseDown(event){
event.preventDefault();
event.stopPropagation();
const cRect = this.canvasRef.el.getBoundingClientRect();
const ctx = this.canvasRef.el.getContext("2d");
const x = event.clientX - cRect.left;
const y = event.clientY - cRect.top;
if (ctx.isPointInPath(this.startPoint, x, y)){
this.dragging = this.startPoint;
} else if (ctx.isPointInPath(this.endPoint, x, y)){
this.dragging = this.endPoint;
} else if (ctx.isPointInPath(this.controlPoint, x, y)){
this.dragging = this.controlPoint;
} else {
this.dragging = undefined;
}
}
onMouseUp(event){
this.dragging = undefined;
}
onMouseMove(event){
if (!this.dragging){
const cRect = this.canvasRef.el.getBoundingClientRect();
const ctx = this.canvasRef.el.getContext("2d");
const x = event.clientX - cRect.left;
const y = event.clientY - cRect.top;
if (ctx.isPointInPath(this.startPoint, x, y)){
document.body.style.cursor = "pointer";
} else if (ctx.isPointInPath(this.endPoint, x, y)){
document.body.style.cursor = "pointer";
} else if (ctx.isPointInPath(this.controlPoint, x, y)){
document.body.style.cursor = "pointer";
} else {
document.body.style.cursor = "auto";
}
}else if (this.dragging){
const cRect = this.canvasRef.el.getBoundingClientRect();
const el = this.dragging;
if (event.x > cRect.left + 5 && event.y > cRect.top + 5
&& event.x < cRect.right - 20 && event.y < cRect.bottom - 20){
let dragging;
if(el == this.startPoint){
this.state.startX += event.movementX;
this.state.startY += event.movementY;
dragging = "s";
} else if (el == this.endPoint){
this.state.endX += event.movementX;
this.state.endY += event.movementY;
dragging = "e";
} else if (el == this.controlPoint){
this.state.controlX += event.movementX;
this.state.controlY += event.movementY;
dragging = "c"
} else {
return;
}
this._drawInteractiveCanvas();
if (dragging === "s") {
this.dragging = this.startPoint;
} else if (dragging === "e"){
this.dragging = this.endPoint;
} else if (dragging === "c"){
this.dragging = this.controlPoint;
}
}
}
}
_drawInteractiveCanvas(){
let ctx = this.canvasRef.el.getContext("2d");
this.startPoint = new Path2D();
this.endPoint = new Path2D();
this.controlPoint = new Path2D();
this.mainPath = new Path2D();
this.controlPath = new Path2D();
ctx.clearRect(0, 0, this.canvasRef.el.width, this.canvasRef.el.height);
ctx.beginPath();
ctx.lineWidth = 3;
ctx.fillStyle = "#b58900";
let path = this.startPoint;
path.arc(this.state.startX, this.state.startY, 6, 0, 2 * Math.PI);
ctx.strokeStyle = "grey";
ctx.fill(path);
ctx.stroke(path);
path = this.mainPath;
path.moveTo(this.state.startX, this.state.startY);
path.quadraticCurveTo(this.state.controlX, this.state.controlY, this.state.endX, this.state.endY);
ctx.strokeStyle = "#b58900";
ctx.stroke(path);
path = this.endPoint;
path.arc(this.state.endX, this.state.endY, 6, 0, 2 * Math.PI);
ctx.strokeStyle = "grey";
ctx.fill(path);
ctx.stroke(path);
path = this.controlPoint;
path.arc(this.state.controlX, this.state.controlY, 6, 0, 2 * Math.PI);
ctx.fill(path);
ctx.stroke(path);
path = this.controlPath;
path.moveTo(this.state.startX, this.state.startY);
path.lineTo(this.state.controlX, this.state.controlY);
path.moveTo(this.state.controlX, this.state.controlY);
path.lineTo(this.state.endX, this.state.endY);
ctx.lineWidth = 1;
ctx.stroke(path);
}
_drawShapes(){
let cvs = document.getElementById("cvsRectangle");
let ctx = cvs.getContext("2d");
ctx.fillStyle = "#b58900";
ctx.fillRect(20, 20, 260, 100);
cvs = document.getElementById("cvsCircle");
ctx = cvs.getContext("2d");
ctx.beginPath();
ctx.arc(150, 72.5, 50, 0, 2 * Math.PI);
ctx.fillStyle = "#b58900";
ctx.fill();
ctx.strokeStyle = "grey";
ctx.stroke();
cvs = document.getElementById("cvsEllipse");
ctx = cvs.getContext("2d");
ctx.beginPath();
ctx.ellipse(150, 72.5, 100, 50, 0, 0, 2 * Math.PI)
ctx.fillStyle = "#b58900";
ctx.fill();
ctx.strokeStyle = "grey";
ctx.stroke();
cvs = document.getElementById("cvsLine");
ctx = cvs.getContext("2d");
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(30, 62.5);
ctx.lineTo(270, 62.5);
ctx.moveTo(30, 72.5);
ctx.lineTo(270, 72.5);
ctx.moveTo(30, 82.5);
ctx.lineTo(270, 82.5);
ctx.strokeStyle = "#b58900";
ctx.stroke();
cvs = document.getElementById("cvsRoundRect");
ctx = cvs.getContext("2d");
ctx.beginPath();
ctx.roundRect(20, 20, 260, 100, [10]);
ctx.fillStyle = "#b58900";
ctx.fill();
ctx.strokeStyle = "grey";
ctx.stroke();
cvs = document.getElementById("cvsCubicCurve");
ctx = cvs.getContext("2d");
ctx.lineWidth = 4;
let start = { x: 20, y: 20 };
let cp1 = { x: 200, y: 50 };
let cp2 = { x: 50, y: 80 };
let end = { x: 270, y: 120 };
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y);
ctx.strokeStyle = "#b58900";
ctx.stroke();
cvs = document.getElementById("cvsQuadraticCurve");
ctx = cvs.getContext("2d");
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.quadraticCurveTo(20, 120, 270, 120);
ctx.strokeStyle = "#b58900";
ctx.stroke();
cvs = document.getElementById("cvsMiscShape");
ctx = cvs.getContext("2d");
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(270, 20);
ctx.lineTo(135, 120);
ctx.closePath();
ctx.strokeStyle = "#b58900";
ctx.stroke();
}
}
registry.category("actions").add("canvas_basics", CanvasBasics);
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="canvas-basics">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex">
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Rectangle
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsRectangle">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Circle
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsCircle">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Ellipse
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsEllipse">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Line
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsLine">
</canvas>
</div>
</div>
</div>
</div>
<div class="d-flex">
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Round Rect
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsRoundRect">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Cubic Bézier Curve
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsCubicCurve">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Quadratic Bézier Curved
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsQuadraticCurve">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Misc. Shape
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsMiscShape">
</canvas>
</div>
</div>
</div>
</div>
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Interactive Curve
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsInteractivePath"
t-ref="interactive-canvas"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove"
>
</canvas>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,134 @@
import { Component, useRef, useState, onWillStart, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class CanvasConnection extends Component {
static template = "canvas-connection";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.canvasRef = useRef("canvas");
const startX = 600;
const startY = 400;
const midX = 800;
const midY = 100;
const endX = 1000;
const endY = 400;
const rad = 25;
this.state = useState({
orientation: "auto",
startNode: {
cx: startX,
cy: startY,
r: rad,
},
midNode: {
cx: midX,
cy: midY,
r: rad,
},
endNode: {
cx: endX,
cy: endY,
r: rad,
},
});
onMounted(() => {
this.canvasRef.el.width = this.canvasRef.el.parentElement.offsetWidth;
this.canvasRef.el.height = this.canvasRef.el.parentElement.offsetHeight;
this._drawCanvas();
});
}
onMouseDown(event) {
if (event.target.id === "startNode" || event.target.id === "midNode" || event.target.id === "endNode") {
this.dragging = event.target.id;
} else {
this.dragging = undefined;
}
}
onMouseUp(event) {
this.dragging = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.canvasRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5 && event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
if (this.dragging === "startNode") {
this.state.startNode.cx += event.movementX;
this.state.startNode.cy += event.movementY;
} else if (this.dragging === "midNode") {
this.state.midNode.cx += event.movementX;
this.state.midNode.cy += event.movementY;
} else if (this.dragging === "endNode") {
this.state.endNode.cx += event.movementX;
this.state.endNode.cy += event.movementY;
}
}
this._drawCanvas();
}
}
_drawCanvas() {
const ctx = this.canvasRef.el.getContext("2d");
ctx.clearRect(0, 0, this.canvasRef.el.width, this.canvasRef.el.height);
ctx.beginPath();
ctx.lineWidth = 3;
ctx.strokeStyle = "#b58900";
ctx.fillStyle = "#b58900";
let path1 = new Path2D();
let path2 = new Path2D();
this._drawPath(path1, this.state.startNode, this.state.midNode, this.state.orientation);
this._drawPath(path2, this.state.midNode, this.state.endNode, this.state.orientation);
ctx.stroke(path1);
ctx.stroke(path2);
}
_drawPath(path, firstNode, secondNode, orient = "auto") {
let hDistance = Math.abs(firstNode.cx - secondNode.cx);
let vDistance = Math.abs(firstNode.cy - secondNode.cy);
let orientation = "v";
if (orient == "auto"){
orientation = hDistance > vDistance ? "v" : "h";
} else {
orientation = orient === "vertical" ? "v" : "h"
}
let firstX = firstNode.cx;
let firstY = firstNode.cy;
let secondX = secondNode.cx;
let secondY = secondNode.cy;
let midX = (secondX + firstX) / 2;
let midY = (secondY + firstY) / 2;
if (orientation === "v") {
path.moveTo(firstX, firstY);
path.lineTo(firstX, midY);
path.lineTo(secondX, midY);
path.lineTo(secondX, secondY);
} else {
path.moveTo(firstX, firstY);
path.lineTo(midX, firstY);
path.lineTo(midX, secondY);
path.lineTo(secondX, secondY);
}
}
}
registry.category("actions").add("canvas_connection", CanvasConnection);
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="canvas-connection">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Canvas Connection
</div>
<div id="card-body" class="card-body p-1 overflow-hidden position-relative"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove">
<canvas id="canvas"
t-ref="canvas"
style="left: 0px; top: 0px; position: absolute; z-index: -1000;"/>
<div id="container" class="h-100 w-100"
style="left: 0px; top: 0px; position: absolute;">
<div id="startNode" class="circle"
t-attf-style="left:{{state.startNode.cx - state.startNode.r}}px;
top:{{state.startNode.cy - state.startNode.r}}px;">
</div>
<div id="midNode" class="circle"
t-attf-style="left:{{state.midNode.cx - state.midNode.r}}px;
top:{{state.midNode.cy - state.midNode.r}}px;">
</div>
<div id="endNode" class="circle"
t-attf-style="left:{{state.endNode.cx - state.endNode.r}}px;
top:{{state.endNode.cy - state.endNode.r}}px;">
</div>
</div>
<div class="btn-group" role="group" style="top:10px; right:10px; position: absolute;">
<input id="optOrientationVertical"
autocomplete="off"
name="optOrientation"
class="btn-check"
type="radio"
value="vertical"
t-model="state.orientation"
t-on-change="_drawCanvas">
</input>
<label class="btn btn-primary" for="optOrientationVertical">Vertical</label>
<input id="optOrientationHorizontal"
autocomplete="off"
name="optOrientation"
class="btn-check"
type="radio"
value="horizontal"
t-model="state.orientation"
t-on-change="_drawCanvas">
</input>
<label class="btn btn-primary" for="optOrientationHorizontal">Horizontal</label>
<input id="optOrientationAuto"
autocomplete="off"
name="optOrientation"
class="btn-check"
type="radio"
value="auto"
t-model="state.orientation"
t-on-change="_drawCanvas">
</input>
<label class="btn btn-primary" for="optOrientationAuto">Auto</label>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+171
View File
@@ -0,0 +1,171 @@
import { Component, useRef, useState, onWillStart, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class CanvasKonva extends Component {
static template = "canvas-konva";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.konvaRef = useRef("konva");
onWillStart(async () => {
await import("/node_ui_basics/static/lib/konva.js");
});
onMounted(() => {
var width = window.innerWidth;
var height = window.innerHeight;
// function to build anchor point
function buildAnchor(x, y) {
var anchor = new Konva.Circle({
x: x,
y: y,
radius: 6,
stroke: "grey",
fill: "#b58900",
strokeWidth: 2,
draggable: true,
});
layer.add(anchor);
// add hover styling
anchor.on("mouseover", function () {
document.body.style.cursor = "pointer";
this.strokeWidth(4);
});
anchor.on("mouseout", function () {
document.body.style.cursor = "default";
this.strokeWidth(2);
});
anchor.on("dragmove", function () {
updateDottedLines();
});
return anchor;
}
var stage = new Konva.Stage({
container: "konva-container",
width: width,
height: height,
});
var layer = new Konva.Layer();
stage.add(layer);
// function to update line points from anchors
function updateDottedLines() {
var q = quad;
var b = bezier;
var quadLinePath = layer.findOne("#quadLinePath");
var bezierLinePath = layer.findOne("#bezierLinePath");
quadLinePath.points([
q.start.x(),
q.start.y(),
q.control.x(),
q.control.y(),
q.end.x(),
q.end.y(),
]);
bezierLinePath.points([
b.start.x(),
b.start.y(),
b.control1.x(),
b.control1.y(),
b.control2.x(),
b.control2.y(),
b.end.x(),
b.end.y(),
]);
}
// we will use custom shape for curve
var quadraticLine = new Konva.Shape({
stroke: "pink",
strokeWidth: 4,
sceneFunc: (ctx, shape) => {
ctx.beginPath();
ctx.moveTo(quad.start.x(), quad.start.y());
ctx.quadraticCurveTo(
quad.control.x(),
quad.control.y(),
quad.end.x(),
quad.end.y()
);
ctx.fillStrokeShape(shape);
},
});
layer.add(quadraticLine);
// we will use custom shape for curve
var bezierLine = new Konva.Shape({
stroke: "#b58900",
strokeWidth: 5,
sceneFunc: (ctx, shape) => {
ctx.beginPath();
ctx.moveTo(bezier.start.x(), bezier.start.y());
ctx.bezierCurveTo(
bezier.control1.x(),
bezier.control1.y(),
bezier.control2.x(),
bezier.control2.y(),
bezier.end.x(),
bezier.end.y()
);
ctx.fillStrokeShape(shape);
},
});
layer.add(bezierLine);
var quadLinePath = new Konva.Line({
dash: [10, 10, 0, 10],
strokeWidth: 3,
stroke: "grey",
lineCap: "round",
id: "quadLinePath",
opacity: 0.3,
points: [0, 0],
});
layer.add(quadLinePath);
var bezierLinePath = new Konva.Line({
dash: [10, 10, 0, 10],
strokeWidth: 3,
stroke: "grey",
lineCap: "round",
id: "bezierLinePath",
opacity: 0.3,
points: [0, 0],
});
layer.add(bezierLinePath);
// special objects to save references to anchors
var quad = {
start: buildAnchor(60, 30),
control: buildAnchor(240, 500),
end: buildAnchor(100, 600),
};
var bezier = {
start: buildAnchor(280, 20),
control1: buildAnchor(530, 500),
control2: buildAnchor(1250, 150),
end: buildAnchor(1580, 600),
};
updateDottedLines();
});
}
}
registry.category("actions").add("canvas_konva", CanvasKonva);
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="canvas-konva">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Konva
</div>
<div id="konva-container" class="card-body p-1 overflow-hidden" t-ref="konva">
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+148
View File
@@ -0,0 +1,148 @@
import { Component, useRef, useState, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { uuidv4 } from "@node_ui_basics/utils/utils";
class CanvasNodes extends Component {
static template = "canvas-nodes";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.canvasRef = useRef("canvas");
this.state = useState({
nodes: [],
selected: undefined,
});
onMounted(() => {
this.canvasRef.el.width = this.canvasRef.el.parentElement.offsetWidth;
this.canvasRef.el.height = this.canvasRef.el.parentElement.offsetHeight;
this._drawCanvas();
});
}
onAddButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cx: 50,
cy: 50,
r: 25,
});
this._drawCanvas();
}
onAdd7NodesButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cx: 1000,
cy: 200,
r: 25,
},{
id: uuidv4(),
cx: 750,
cy: 100,
r: 25,
},{
id: uuidv4(),
cx: 500,
cy: 200,
r: 25,
},{
id: uuidv4(),
cx: 500,
cy: 500,
r: 25,
},{
id: uuidv4(),
cx: 750,
cy: 600,
r: 25,
},{
id: uuidv4(),
cx: 1000,
cy: 500,
r: 25,
},{
id: uuidv4(),
cx: 750,
cy: 325,
r: 25,
}
);
this._drawCanvas();
}
onRemoveButtonClick(event) {
if (this.state.selected){
let nodeIdx = this.state.nodes.findIndex(o => o.id === this.state.selected);
if (nodeIdx > -1){
this.state.nodes.splice(nodeIdx, 1);
this._drawCanvas();
}
this.state.selected = undefined;
}
}
onNodeSelected(event){
this.state.selected = event.target.id;
}
onMouseDown(event) {
if (event.target.classList.contains("node")) {
this.dragging = event.target.id;
this.selected = event.target.id;
} else {
this.dragging = undefined;
}
}
onMouseUp(event) {
this.dragging = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.canvasRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5 && event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
const node = this.state.nodes.find((o) => o.id == this.dragging);
if (node){
node.cx += event.movementX;
node.cy += event.movementY;
}
}
this._drawCanvas();
}
}
_drawCanvas() {
if (this.state.nodes.length > 0) {
const nodes = this.state.nodes;
const ctx = this.canvasRef.el.getContext("2d");
ctx.clearRect(0, 0, this.canvasRef.el.width, this.canvasRef.el.height);
ctx.beginPath();
ctx.lineWidth = 3;
ctx.strokeStyle = "#b58900";
ctx.fillStyle = "#b58900";
let path = new Path2D();
path.moveTo(nodes[0].cx, nodes[0].cy);
for (let i = 1; i < this.state.nodes.length; i++) {
path.lineTo(nodes[i].cx, nodes[i].cy);
}
ctx.stroke(path);
}
}
}
registry.category("actions").add("canvas_nodes", CanvasNodes);
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="canvas-nodes">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Canvas With DIV Nodes
</div>
<div id="card-body" class="card-body p-1 overflow-hidden position-relative"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove">
<canvas id="canvas" t-ref="canvas"
style="left: 0px; top: 0px; position: absolute; z-index: -1000;"/>
<div id="container" class="h-100 w-100"
style="left: 0px; top: 0px; position: absolute;">
<t t-foreach="state.nodes" t-as="node" t-key="node.id">
<div class="node circle"
t-att-id="node.id"
t-attf-style="left:{{node.cx - node.r}}px;
top:{{node.cy - node.r}}px;
border-color: {{ node.id === state.selected ? '#5f5' : '#999'}}
"
t-on-mousedown.prevent="onNodeSelected">
</div>
</t>
</div>
<div class="btn-group" style="top:10px; right:10px; position: absolute;">
<button class="btn btn-primary my-0" t-on-click="onAddButtonClick">
Add
</button>
<button class="btn btn-primary my-0" t-on-click="onAdd7NodesButtonClick">
Add Seven Nodes
</button>
<button class="btn btn-primary my-0" t-on-click="onRemoveButtonClick">
Remove
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+49
View File
@@ -0,0 +1,49 @@
import { Component, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class MovableDiv extends Component {
static template = "movable-div";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.containerRef = useRef("container");
this.state = useState({
cdLeft: 800,
cdTop: 100,
cLeft: 700,
cTop: 100
});
this.dragging = undefined;
}
onMouseDown(event){
if (event.target.classList.contains("diamond")){
this.dragging = "cd";
} else if (event.target.classList.contains("circle")){
this.dragging = "c";
}
}
onMouseUp(event){
this.dragging = undefined;
}
onMouseMove(event){
if (this.dragging === "cd"){
this.state.cdLeft += event.movementX;
this.state.cdTop += event.movementY;
} else if (this.dragging === "c"){
this.state.cLeft += event.movementX;
this.state.cTop += event.movementY;
}
}
}
registry.category("actions").add("movable_div", MovableDiv);
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="movable-div">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Movable DIV
</div>
<div class="card-body p-1 overflow-hidden" t-ref="container"
t-on-mousedown="onMouseDown" t-on-mousemove="onMouseMove" t-on-mouseup="onMouseUp">
<div class="diamond" t-attf-style="left:{{state.cdLeft}}px; top:{{state.cdTop}}px;">
</div>
<div class="circle" t-attf-style="left:{{state.cLeft}}px; top:{{state.cTop}}px;">
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,68 @@
.diamond {
position: relative;
height: 100px;
width: 100px;
line-height: 200px;
text-align: center;
margin: 10px 40px;
cursor: pointer;
}
.diamond:after {
position: absolute;
top: 10px;
left: 10px;
content: '';
height: calc(100% - 22px);
width: calc(100% - 22px);
background: #b58900;
border: 1px solid #b58900;
transform: rotateX(45deg) rotateZ(45deg);
}
.circle {
position: absolute;
width: 80px;
height: 80px;
background: #b58900;
border-radius: 50%;
cursor: pointer;
border-style: solid;
border-width: 4px;
border-color: #999;
}
.small-circle {
position: absolute;
width: 30px;
height: 30px;
background: #b58900;
border-radius: 50%;
cursor: pointer;
border-style: solid;
border-width: 4px;
border-color: #999;
}
.square {
position: absolute;
width: 80px;
height: 80px;
background: #b58900;
border-radius: 5px;
cursor: pointer;
border-style: solid;
border-width: 4px;
border-color: #999;
}
.node .icon {
color: #f6f6f6;
}
.path {
cursor: pointer;
}
+348
View File
@@ -0,0 +1,348 @@
import { Component, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { uuidv4, createDiv } from "@node_ui_basics/utils/utils";
const icons = [
"fa-envelope-open",
"fa-clone",
"fa-cog",
"fa-database",
"fa-folder-o",
"fa-link",
"fa-lock",
"fa-pencil",
"fa-plus",
"fa-square-o",
]
function getRandomIcon() {
return icons[Math.floor(Math.random() * 10)];
}
const SQUARE_HALF = 40;
const CIRCLE_RAD = 40
class NodeUiSvg extends Component {
static template = "node-ui-svg";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.containerRef = useRef("container");
this.dragging = undefined;
this.state = useState({
nodes: [],
connections: [],
selected: undefined,
connecting: undefined
});
}
onAddCircleButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cX: 50,
cY: 50,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
});
}
onAddSquareButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cX: 50,
cY: 50,
width: SQUARE_HALF * 2,
height: SQUARE_HALF * 2,
icon: getRandomIcon(),
type: "square"
});
}
onAdd7NodesButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cX: 1000,
cY: 200,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
},{
id: uuidv4(),
cX: 750,
cY: 100,
width: SQUARE_HALF * 2,
height: SQUARE_HALF * 2,
icon: getRandomIcon(),
type: "square"
},{
id: uuidv4(),
cX: 500,
cY: 200,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
},{
id: uuidv4(),
cX: 500,
cY: 500,
width: SQUARE_HALF * 2,
height: SQUARE_HALF * 2,
icon: getRandomIcon(),
type: "square"
},{
id: uuidv4(),
cX: 750,
cY: 600,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
},{
id: uuidv4(),
cX: 1000,
cY: 500,
width: SQUARE_HALF * 2,
height: SQUARE_HALF * 2,
icon: getRandomIcon(),
type: "square"
},{
id: uuidv4(),
cX: 750,
cY: 325,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
}
);
}
onRemoveButtonClick(event) {
if (this.state.selected){
const nodeIdx = this.state.nodes.findIndex(o => o.id === this.state.selected);
if (nodeIdx > -1){
const node = this.state.nodes.at(nodeIdx);
const cnns = this.state.connections.filter(c => c.sourceId === node.id || c.targetId === node.id)
for (const cnn of cnns) {
const cnnIdx = this.state.connections.findIndex(c => c.id === cnn.id);
this.state.connections.splice(cnnIdx, 1);
}
this.state.nodes.splice(nodeIdx, 1);
} else {
const cnnIdx = this.state.connections.findIndex(c => c.id === this.state.selected);
if (cnnIdx > -1) {
this.state.connections.splice(cnnIdx, 1);
}
}
this.state.selected = undefined;
}
}
calcIntersectionPosForCircle(x1, y1, x2, y2, r){
const xDist = x2 - x1;
const yDist = y2 - y1;
const diagDist = Math.sqrt(Math.pow(xDist, 2) + Math.pow(yDist, 2));
const ratio = r/diagDist
const mposX = ratio * xDist;
const mposY = ratio * yDist;
return { x: x1 + mposX, y: y1 + mposY }
}
calcIntersectionPosForSquare(x1, y1, w, h, x2, y2){
const xDist = Math.abs(x2 - x1);
const yDist = Math.abs(y2 - y1);
const signY = Math.sign(y2 - y1)
const signX = Math.sign(x2 - x1)
let res;
if (yDist <= xDist) {
const ratio = Math.abs((w/2)/xDist);
const mposX = (w/2) * signX;
const mposY = ratio * yDist * signY;
res = { x: x1 + mposX, y: y1 + mposY }
} else {
const ratio = Math.abs((h/2)/yDist);
const mposX = ratio * xDist * signX;
const mposY = (h/2) * signY;
res = { x: x1 + mposX, y: y1 + mposY }
}
return res;
}
calcMarkerPosByTargetPos(sourceEl, x, y) {
const cRect = this.containerRef.el.getBoundingClientRect();
const sourceRect = sourceEl.getBoundingClientRect();
const funcForCircle = this.calcIntersectionPosForCircle;
const funcForSquare = this.calcIntersectionPosForSquare;
const pos = sourceEl.classList.contains("circle") ? funcForCircle(
(sourceRect.left + sourceRect.width / 2) - cRect.left,
(sourceRect.top + sourceRect.height / 2) - cRect.top,
x,
y,
CIRCLE_RAD
) : funcForSquare(
(sourceRect.left + sourceRect.width / 2) - cRect.left,
(sourceRect.top + sourceRect.height / 2) - cRect.top,
sourceRect.width,
sourceRect.height,
x,
y
);
return pos;
}
calcMarkerPosByTargetEl(sourceEl, targetEl){
const cRect = this.containerRef.el.getBoundingClientRect();
const sourceRect = sourceEl.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const funcForCircle = this.calcIntersectionPosForCircle;
const funcForSquare = this.calcIntersectionPosForSquare;
const pos = sourceEl.classList.contains("circle") ? funcForCircle(
(sourceRect.left + sourceRect.width / 2) - cRect.left,
(sourceRect.top + sourceRect.height / 2) - cRect.top,
(targetRect.left + targetRect.width / 2) - cRect.left,
(targetRect.top + targetRect.height / 2) - cRect.top,
CIRCLE_RAD
) : funcForSquare(
(sourceRect.left + sourceRect.width / 2) - cRect.left,
(sourceRect.top + sourceRect.height / 2) - cRect.top,
sourceRect.width,
sourceRect.height,
(targetRect.left + targetRect.width / 2) - cRect.left,
(targetRect.top + targetRect.height / 2) - cRect.top,
);
return pos;
}
onNodeMouseDown(event){
if (event.ctrlKey) {
event.stopPropagation();
event.preventDefault();
const el = event.target;
const cRect = this.containerRef.el.getBoundingClientRect();
const pos = this.calcMarkerPosByTargetPos(el, event.x - cRect.left, event.y - cRect.top);
this.state.connecting = {
id: uuidv4(),
startX: pos.x,
startY: pos.y,
endX: event.x - cRect.left,
endY: event.y - cRect.top,
sourceId: el.id,
};
} else {
this.state.selected = event.target.id;
}
}
onNodeMouseUp(event){
if (this.state.connecting !== undefined){
event.stopPropagation();
if (event.target.classList.contains("node") && event.target.id !== this.state.connecting.sourceId) {
const targetEl = event.target;
const sourceEl = document.getElementById(this.state.connecting.sourceId);
const targetPos = this.calcMarkerPosByTargetEl(targetEl, sourceEl);
this.state.connecting.endX = targetPos.x;
this.state.connecting.endY = targetPos.y;
this.state.connecting.targetId = targetEl.id;
const cnns = this.state.connections.filter(c =>
c.sourceId === this.state.connecting.id || c.targetId === this.state.connecting.id)
if (cnns.length == 0){
this.state.connections.push(this.state.connecting);
}
}
}
this.state.connecting = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.containerRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5
&& event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
const node = this.state.nodes.find((o) => o.id == this.dragging);
if (node){
node.cX += event.movementX;
node.cY += event.movementY;
const cnns = this.state.connections.filter(c => c.sourceId === node.id || c.targetId === node.id)
for (const cnn of cnns) {
this.updateConnectionPos(cnn);
}
}
}
} else if (this.state.connecting){
const cRect = this.containerRef.el.getBoundingClientRect();
const sourceEl = document.getElementById(this.state.connecting.sourceId);
const pos = this.calcMarkerPosByTargetPos(sourceEl, event.x - cRect.left, event.y - cRect.top);
const cnn = this.state.connecting;
cnn.startX = pos.x;
cnn.startY = pos.y;
cnn.endX = event.x - cRect.left;
cnn.endY = event.y - cRect.top;
}
}
onMouseDown(event){
if (!event.ctrlKey) {
if (event.target.classList.contains("node")) {
this.dragging = event.target.id;
this.selected = event.target.id;
} else {
this.dragging = undefined;
}
}
}
onMouseUp(event){
this.dragging = undefined;
this.state.connecting = undefined;
}
updateConnectionPos(cnn){
const startNode = this.state.nodes.find(n => n.id === cnn.sourceId);
const endNode = this.state.nodes.find(n => n.id === cnn.targetId);
if (startNode !== undefined && endNode !== undefined) {
const startEl = document.getElementById(startNode.id);
const endEl = document.getElementById(endNode.id);
let pos = this.calcMarkerPosByTargetEl(startEl, endEl);
cnn.startX = pos.x;
cnn.startY = pos.y;
pos = this.calcMarkerPosByTargetEl(endEl, startEl);
cnn.endX = pos.x;
cnn.endY = pos.y;
}
}
onLineSelected(event){
this.state.selected = event.target.id;
}
onDeselectButtonClick(event) {
this.state.selected = undefined;
}
}
registry.category("actions").add("node_ui_svg", NodeUiSvg);
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="node-ui-svg">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Node UI Basic with SVG
</div>
<div id="card-body"
class="card-body p-1 overflow-hidden position-relative"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove">
<div t-ref="container"
id="container"
class="h-100 w-100"
style="left: 0px; top: 0px; position: absolute;">
<svg xmlns="http://www.w3.org/2000/svg"
style="width:100%; height:100%">
<defs>
<marker id="arrow"
viewBox="0 0 15 15"
refX="5"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse">
<path d="M 0 0 L 5 5 L 0 10 z"
stroke="context-stroke"
fill="context-stroke"/>
</marker>
<marker id="dot"
viewBox="0 0 30 30"
refX="5"
refY="5"
markerWidth="15"
markerHeight="10"
orient="auto-start-reverse">
<circle cx="0"
cy="5"
r="5"
fill="context-stroke"/>
</marker>
</defs>
<t t-foreach="state.connections"
t-as="cnn"
t-key="cnn.id">
<line class="path" t-attf-style="stroke-width:3;
stroke:{{ cnn.id === state.selected ? '#5f5' : '#b58900'}};"
marker-end="url(#arrow)"
marker-start="url(#dot)"
t-att-id="cnn.id"
t-att-x1="cnn.startX"
t-att-y1="cnn.startY"
t-att-x2="cnn.endX"
t-att-y2="cnn.endY"
t-on-mousedown.prevent="onLineSelected"/>
</t>
<line t-if="state.connecting !== undefined"
style="stroke-width:3;
stroke:#b58900;"
t-att-id="state.connecting.id"
t-att-x1="state.connecting.startX"
t-att-y1="state.connecting.startY"
t-att-x2="state.connecting.endX"
t-att-y2="state.connecting.endY"/>
</svg>
<t t-foreach="state.nodes"
t-as="node"
t-key="node.id">
<div t-attf-class="node {{ node.type }}"
t-att-id="node.id"
t-attf-style="
left:{{node.type === 'circle' ? node.cX - node.r : node.cX - node.width / 2}}px;
top:{{node.type === 'circle' ? node.cY - node.r : node.cY - node.height / 2}}px;
border-color: {{ node.id === state.selected ? '#5f5' : '#999'}}
"
t-on-mousedown="onNodeMouseDown"
t-on-mouseup.prevent="onNodeMouseUp">
<div class="pe-none h-100 w-100 d-flex justify-content-center align-items-center">
<i t-attf-class="icon fa {{node.icon}} fa-3x align-self-center"></i>
</div>
</div>
</t>
</div>
<div class="btn-group"
style="top:10px; right:10px; position: absolute;">
<button class="btn btn-primary my-0"
t-on-click="onAddCircleButtonClick">
<i t-attf-class="icon fa fa-plus fa-lg align-self-center"></i>
<i t-attf-class="icon fa fa-circle fa-lg align-self-center"></i>
</button>
<button class="btn btn-primary my-0"
t-on-click="onAddSquareButtonClick">
<i t-attf-class="icon fa fa-plus fa-lg align-self-center"></i>
<i t-attf-class="icon fa fa-square fa-lg align-self-center"></i>
</button>
<button class="btn btn-primary my-0"
t-on-click="onAdd7NodesButtonClick">
<i t-attf-class="icon fa fa-plus fa-lg align-self-center"></i>
<i t-attf-class="icon fa fa-stop-circle-o fa-lg align-self-center"></i>
</button>
<button class="btn btn-primary my-0"
t-on-click="onDeselectButtonClick">
<i t-attf-class="icon fa fa-times fa-lg align-self-center"></i>
</button>
<button class="btn btn-primary my-0"
t-on-click="onRemoveButtonClick">
<i t-attf-class="icon fa fa-minus fa-lg align-self-center"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+55
View File
@@ -0,0 +1,55 @@
import { Component, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class SvgBasics extends Component {
static template = "svg-basics";
static components = { };
static props = {
...standardActionServiceProps
};
setup() {
this.svgRef = useRef("svg");
this.state = useState({
startX: 100,
startY: 50,
endX: 1550,
endY: 300,
controlX: 200,
controlY: 200,
})
this.dragging = undefined;
}
onMouseDown(event){
this.dragging = event.target;
}
onMouseUp(event){
this.dragging = undefined;
}
onMouseMove(event){
if (this.dragging){
const svgRect = this.svgRef.el.getBoundingClientRect();
const el = this.dragging;
if (event.x > svgRect.left + 5 && event.y > svgRect.top + 5
&& event.x < svgRect.right - 5 && event.y < svgRect.bottom - 5){
if(el.id === "startPoint"){
this.state.startX += event.movementX;
this.state.startY += event.movementY;
} else if (el.id === "endPoint"){
this.state.endX += event.movementX;
this.state.endY += event.movementY;
} else if (el.id === "controlPoint"){
this.state.controlX += event.movementX;
this.state.controlY += event.movementY;
}
}
}
}
}
registry.category("actions").add("svg_basics", SvgBasics);
+178
View File
@@ -0,0 +1,178 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="svg-basics">
<div class="d-flex flex-column h-100 w-100 p-1"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove"
>
<div class="d-flex">
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Rectangle
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<rect width="100" height="100" x="10" y="10" rx="10" ry="10" fill="grey"/>
<rect width="100" height="100" x="120" y="10"
style="fill:#b58900;stroke-width:3;stroke:grey"/>
<rect width="100" height="100" x="230" y="10"
style="fill:grey;stroke:#b58900;stroke-width:3;fill-opacity:0.5;stroke-opacity:0.5"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Circle
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<circle r="47.5" cx="55" cy="60" fill="#b58900"/>
<circle r="47.5" cx="167.5" cy="60" fill="#123456" stroke="grey" stroke-width="3"/>
<circle r="47.5" cx="280" cy="60" fill="#b58900" stroke="grey" stroke-width="3" opacity="0.5"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Ellipse
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<ellipse rx="50" ry="20" cx="55" cy="60" style="fill:#b58900;stroke:grey;stroke-width:3"/>
<ellipse rx="20" ry="50" cx="167.5" cy="60" style="fill:#b58900;stroke:grey;stroke-width:3"/>
<ellipse rx="50" ry="20" cx="280" cy="60" style="fill:#b58900;stroke:grey;stroke-width:3"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Line
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<line x1="55" y1="10" x2="55" y2="115" style="stroke:#b58900;stroke-width:2"/>
<line x1="125" y1="10" x2="225" y2="115" style="stroke:#b58900;stroke-width:2"/>
<line x1="280" y1="55" x2="330" y2="55" style="stroke:#b58900;stroke-width:2"/>
<line x1="280" y1="60" x2="330" y2="60" style="stroke:#b58900;stroke-width:2"/>
<line x1="280" y1="65" x2="330" y2="65" style="stroke:#b58900;stroke-width:2"/>
</svg>
</div>
</div>
</div>
</div>
<div class="d-flex">
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Polygon
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<polygon points="225,10 320,120 10,120" style="fill:#b58900;stroke:grey;stroke-width:3"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Polyline
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<polyline points="10,10 50,50 55,55 100,60, 200,100 250,80 320,100"
style="fill:none;stroke:#b58900;stroke-width:3"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Simple Path
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<path d="M 10 10 l 330 110" stroke="#b58900" stroke-width="3"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Curved Path
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<path d="M 10 100 Q 0 0 330 110" stroke="#b58900" stroke-width="3" fill="none"/>
</svg>
</div>
</div>
</div>
</div>
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Interactive Path
</div>
<div class="card-body p-1 overflow-hidden" t-ref="svg">
<svg xmlns="http://www.w3.org/2000/svg" style="width:100%; height:100%">
<style>
circle { cursor: pointer; }
</style>
<t t-set="startX" t-value="state.startX"/>
<t t-set="startY" t-value="state.startY"/>
<t t-set="endX" t-value="state.endX"/>
<t t-set="endY" t-value="state.endY"/>
<t t-set="controlX" t-value="state.controlX"/>
<t t-set="controlY" t-value="state.controlY"/>
<t t-set="midX" t-value="(startX + endX)/2"/>
<t t-set="midY" t-value="(startY + endY)/2"/>
<t t-call="qbc"/>
</svg>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-name="qbc">
<g xmlns="http://www.w3.org/2000/svg" >
<path id="qBC" t-attf-d="
M {{startX}},{{startY}}
Q {{controlX}},{{controlY}} {{midX}},{{midY}}
T {{endX}},{{endY}}"
stroke="#b58900" stroke-width="3" fill="none"/>
<circle id="startPoint" class="point" style="fill:grey"
t-att-cx="startX"
t-att-cy="startY"
r="6"/>
<circle id="endPoint" class="point" style="fill:grey"
t-att-cx="endX"
t-att-cy="endY"
r="6"/>
<circle id="controlPoint" class="point" style="fill:grey"
t-att-cx="controlX"
t-att-cy="controlY"
r="6"/>
<line t-att-x1="startX" t-att-y1="startY"
t-att-x2="controlX" t-att-y2="controlY"
style="stroke:#b58900;stroke-width:2"
stroke-dasharray="10,10"/>
<line t-att-x1="controlX" t-att-y1="controlY"
t-att-x2="midX" t-att-y2="midY"
style="stroke:#b58900;stroke-width:2"
stroke-dasharray="10,10"/>
</g>
</t>
</templates>
+125
View File
@@ -0,0 +1,125 @@
import { Component, useRef, useState, onWillStart, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class SvgBezier extends Component {
static template = "svg-bezier";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.containerRef = useRef("container");
const startX = 600;
const startY = 400;
const midX = 800;
const midY = 100;
const endX = 1000;
const endY = 400;
const rad = 15;
this.state = useState({
orientation: "auto",
startNode: {
cx: startX,
cy: startY,
r: rad,
},
midNode: {
cx: midX,
cy: midY,
r: rad,
},
endNode: {
cx: endX,
cy: endY,
r: rad,
},
path: "",
});
onMounted(() => {
this.updatePath();
});
}
onMouseDown(event) {
if (event.target.id === "startNode" || event.target.id === "midNode" || event.target.id === "endNode") {
this.dragging = event.target.id;
} else {
this.dragging = undefined;
}
}
onMouseUp(event) {
this.dragging = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.containerRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5 && event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
if (this.dragging === "startNode") {
this.state.startNode.cx += event.movementX;
this.state.startNode.cy += event.movementY;
} else if (this.dragging === "midNode") {
this.state.midNode.cx += event.movementX;
this.state.midNode.cy += event.movementY;
} else if (this.dragging === "endNode") {
this.state.endNode.cx += event.movementX;
this.state.endNode.cy += event.movementY;
}
this.updatePath();
}
}
}
updatePath(){
this.state.path = this._createPaths();
}
_createPaths(){
let res = "";
const startNode = this.state.startNode;
const midNode = this.state.midNode;
const endNode = this.state.endNode;
res = res + this._createPath(startNode.cx, startNode.cy, midNode.cx, midNode.cy);
res = res + " " + this._createPath(midNode.cx, midNode.cy, endNode.cx, endNode.cy);
return res;
}
// https://stackoverflow.com/a/45245042
_createPath(startX, startY, endX, endY) {
// L
let BX = Math.abs(endX - startX) * 0.05 + startX;
let BY = startY;
// C
let CX = startX + Math.abs(endX - startX) * 0.33;
let CY = startY;
let DX = endX - Math.abs(endX - startX) * 0.33;
let DY = endY;
let EX = -Math.abs(endX - startX) * 0.05 + endX;
let EY = endY;
const svgPath = []
svgPath.push("M", startX, startY);
svgPath.push("L", BX, ",", BY);
svgPath.push("C", CX, ",", CY);
svgPath.push(DX, ",", DY);
svgPath.push(EX, ",", EY);
svgPath.push("L", endX, ",", endY);
const res = svgPath.join(" ");
return res;
}
}
registry.category("actions").add("svg_bezier", SvgBezier);

Some files were not shown because too many files have changed in this diff Show More