diff --git a/cheat_web/README.md b/cheat_web/README.md new file mode 100644 index 0000000..b680f1e --- /dev/null +++ b/cheat_web/README.md @@ -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) \ No newline at end of file diff --git a/cheat_web/__init__.py b/cheat_web/__init__.py new file mode 100644 index 0000000..06aac66 --- /dev/null +++ b/cheat_web/__init__.py @@ -0,0 +1,3 @@ +from . import controllers +from . import models +from . import tools \ No newline at end of file diff --git a/cheat_web/__manifest__.py b/cheat_web/__manifest__.py new file mode 100644 index 0000000..f620541 --- /dev/null +++ b/cheat_web/__manifest__.py @@ -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", +} diff --git a/cheat_web/controllers/__init__.py b/cheat_web/controllers/__init__.py new file mode 100644 index 0000000..deec4a8 --- /dev/null +++ b/cheat_web/controllers/__init__.py @@ -0,0 +1 @@ +from . import main \ No newline at end of file diff --git a/cheat_web/controllers/main.py b/cheat_web/controllers/main.py new file mode 100644 index 0000000..3043788 --- /dev/null +++ b/cheat_web/controllers/main.py @@ -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//', type='json', auth='user', website=True) + def do_something_with_route_param(self, param1, param2): + return { + "result": "Ok", + "param1": param1, + "param2": param2 + } diff --git a/cheat_web/models/__init__.py b/cheat_web/models/__init__.py new file mode 100644 index 0000000..0d5323b --- /dev/null +++ b/cheat_web/models/__init__.py @@ -0,0 +1,4 @@ +from . import ir_action +from . import ir_ui_view +from . import cheat_web +from . import res_users_settings \ No newline at end of file diff --git a/cheat_web/models/cheat_web.py b/cheat_web/models/cheat_web.py new file mode 100644 index 0000000..33410b8 --- /dev/null +++ b/cheat_web/models/cheat_web.py @@ -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 + } diff --git a/cheat_web/models/ir_action.py b/cheat_web/models/ir_action.py new file mode 100644 index 0000000..5efb743 --- /dev/null +++ b/cheat_web/models/ir_action.py @@ -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'}) diff --git a/cheat_web/models/ir_ui_view.py b/cheat_web/models/ir_ui_view.py new file mode 100644 index 0000000..e5cd0b2 --- /dev/null +++ b/cheat_web/models/ir_ui_view.py @@ -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() diff --git a/cheat_web/models/res_users_settings.py b/cheat_web/models/res_users_settings.py new file mode 100644 index 0000000..2021c75 --- /dev/null +++ b/cheat_web/models/res_users_settings.py @@ -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) diff --git a/cheat_web/rng/cheat_view.rng b/cheat_web/rng/cheat_view.rng new file mode 100644 index 0000000..54d7c8d --- /dev/null +++ b/cheat_web/rng/cheat_view.rng @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + diff --git a/cheat_web/rng/hello_view.rng b/cheat_web/rng/hello_view.rng new file mode 100644 index 0000000..2fb5fa6 --- /dev/null +++ b/cheat_web/rng/hello_view.rng @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/cheat_web/rng/statistic_view.rng b/cheat_web/rng/statistic_view.rng new file mode 100644 index 0000000..e0855af --- /dev/null +++ b/cheat_web/rng/statistic_view.rng @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/cheat_web/security/ir.model.access.csv b/cheat_web/security/ir.model.access.csv new file mode 100644 index 0000000..e7974fb --- /dev/null +++ b/cheat_web/security/ir.model.access.csv @@ -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 diff --git a/cheat_web/static/description/icon.png b/cheat_web/static/description/icon.png new file mode 100644 index 0000000..00b2c54 Binary files /dev/null and b/cheat_web/static/description/icon.png differ diff --git a/cheat_web/static/img/exploring-odoo.png b/cheat_web/static/img/exploring-odoo.png new file mode 100644 index 0000000..9065801 Binary files /dev/null and b/cheat_web/static/img/exploring-odoo.png differ diff --git a/cheat_web/static/src/cheat_owl.js b/cheat_web/static/src/cheat_owl.js new file mode 100644 index 0000000..8c9afa9 --- /dev/null +++ b/cheat_web/static/src/cheat_owl.js @@ -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); diff --git a/cheat_web/static/src/cheat_owl.xml b/cheat_web/static/src/cheat_owl.xml new file mode 100644 index 0000000..86a0488 --- /dev/null +++ b/cheat_web/static/src/cheat_owl.xml @@ -0,0 +1,199 @@ + + + + +
+
+
+
+
+ +
+
+
+

+ This text color can be changed by clicking the button below. +

+
+ +
+
+
+
+
+
+ +
+
+
+

+ This is a random value:
+ + + + +

+
+ +
+
+
+
+
+
+ +
+
+
+

+ This is a random value:
+ + + + +

+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
Input
+
+

+ +


+
Input value:
+ +

+
+
+
+
+
+
Two-way Binding
+
+
+
Input
+
+

+ +


Input value:
+ +

+
+
+
+
+
+
Textarea
+
+

+ + + + + +

+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
diff --git a/cheat_web/static/src/views/cheat/cheat_view.js b/cheat_web/static/src/views/cheat/cheat_view.js new file mode 100644 index 0000000..b275479 --- /dev/null +++ b/cheat_web/static/src/views/cheat/cheat_view.js @@ -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); diff --git a/cheat_web/static/src/views/hello/hello_controller.js b/cheat_web/static/src/views/hello/hello_controller.js new file mode 100644 index 0000000..a6273c2 --- /dev/null +++ b/cheat_web/static/src/views/hello/hello_controller.js @@ -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); + } +} diff --git a/cheat_web/static/src/views/hello/hello_controller.xml b/cheat_web/static/src/views/hello/hello_controller.xml new file mode 100644 index 0000000..446c0ef --- /dev/null +++ b/cheat_web/static/src/views/hello/hello_controller.xml @@ -0,0 +1,13 @@ + + + +
+ +
+

+

Hello there!

+
+
+
+
+
diff --git a/cheat_web/static/src/views/hello/hello_view.js b/cheat_web/static/src/views/hello/hello_view.js new file mode 100644 index 0000000..c0974cb --- /dev/null +++ b/cheat_web/static/src/views/hello/hello_view.js @@ -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); diff --git a/cheat_web/static/src/views/statistic/statistic_controller.js b/cheat_web/static/src/views/statistic/statistic_controller.js new file mode 100644 index 0000000..1e84065 --- /dev/null +++ b/cheat_web/static/src/views/statistic/statistic_controller.js @@ -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); + }); + } +} diff --git a/cheat_web/static/src/views/statistic/statistic_controller.xml b/cheat_web/static/src/views/statistic/statistic_controller.xml new file mode 100644 index 0000000..50587cb --- /dev/null +++ b/cheat_web/static/src/views/statistic/statistic_controller.xml @@ -0,0 +1,10 @@ + + + +
+ + + +
+
+
diff --git a/cheat_web/static/src/views/statistic/statistic_model.js b/cheat_web/static/src/views/statistic/statistic_model.js new file mode 100644 index 0000000..acb964a --- /dev/null +++ b/cheat_web/static/src/views/statistic/statistic_model.js @@ -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; + } +} diff --git a/cheat_web/static/src/views/statistic/statistic_renderer.js b/cheat_web/static/src/views/statistic/statistic_renderer.js new file mode 100644 index 0000000..71da927 --- /dev/null +++ b/cheat_web/static/src/views/statistic/statistic_renderer.js @@ -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); + } +} diff --git a/cheat_web/static/src/views/statistic/statistic_renderer.xml b/cheat_web/static/src/views/statistic/statistic_renderer.xml new file mode 100644 index 0000000..8e67f6b --- /dev/null +++ b/cheat_web/static/src/views/statistic/statistic_renderer.xml @@ -0,0 +1,42 @@ + + + +
+
+
+
+

+
records
+
+
+
+

Model:

+
+
Fields count:
+
+ + + + + + + + + + + + + + + + +
NameTypeDescription
+
+
+
+
+
+
+
+
+
diff --git a/cheat_web/static/src/views/statistic/statistic_view.js b/cheat_web/static/src/views/statistic/statistic_view.js new file mode 100644 index 0000000..f43e6a4 --- /dev/null +++ b/cheat_web/static/src/views/statistic/statistic_view.js @@ -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); diff --git a/cheat_web/tools/__init__.py b/cheat_web/tools/__init__.py new file mode 100644 index 0000000..fe3ff6f --- /dev/null +++ b/cheat_web/tools/__init__.py @@ -0,0 +1 @@ +from . import view_validation \ No newline at end of file diff --git a/cheat_web/tools/view_validation.py b/cheat_web/tools/view_validation.py new file mode 100644 index 0000000..38ff0cc --- /dev/null +++ b/cheat_web/tools/view_validation.py @@ -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 diff --git a/cheat_web/views/cheat_web_views.xml b/cheat_web/views/cheat_web_views.xml new file mode 100644 index 0000000..7a2eeec --- /dev/null +++ b/cheat_web/views/cheat_web_views.xml @@ -0,0 +1,107 @@ + + + + + cheat.web.view.list + cheat.web + + + + + + + + + + + cheat.web.view.form + cheat.web + +
+ + + + + + +
+
+
+ + + + cheat.web.view.search + cheat.web + + + + + + + + + + + cheat.web.view.hello + cheat.web + + + + + + + + cheat.web.view.statistic + cheat.web + + + + + + + + cheat.web.view.cheat + cheat.web + + + + + + + + + + + Custom View Type + cheat.web + list,form,hello,statistic,cheat + + + + Web Framework + cheat_web + + + + Web Library + cheat_owl + + + + QWeb + cheat_owl_qweb + + + + + + + +
+
diff --git a/cheat_web/views/contacts_views.xml b/cheat_web/views/contacts_views.xml new file mode 100644 index 0000000..51eeafe --- /dev/null +++ b/cheat_web/views/contacts_views.xml @@ -0,0 +1,36 @@ + + + + res.partner.hello + res.partner + + + + + + + res.partner.statistic + res.partner + + + + + + + res.partner.cheat + res.partner + + + + + + + + + + + + kanban,list,form,activity,hello,statistic,cheat + + + \ No newline at end of file diff --git a/node_ui/README.md b/node_ui/README.md new file mode 100644 index 0000000..ee70ac3 --- /dev/null +++ b/node_ui/README.md @@ -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) \ No newline at end of file diff --git a/node_ui/__init__.py b/node_ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/node_ui/__manifest__.py b/node_ui/__manifest__.py new file mode 100644 index 0000000..997cab8 --- /dev/null +++ b/node_ui/__manifest__.py @@ -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 +} diff --git a/node_ui/static/images/align-center.svg b/node_ui/static/images/align-center.svg new file mode 100644 index 0000000..6cd007b --- /dev/null +++ b/node_ui/static/images/align-center.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/node_ui/static/images/align-end.svg b/node_ui/static/images/align-end.svg new file mode 100644 index 0000000..796bb28 --- /dev/null +++ b/node_ui/static/images/align-end.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/node_ui/static/images/align-start.svg b/node_ui/static/images/align-start.svg new file mode 100644 index 0000000..a5a7e09 --- /dev/null +++ b/node_ui/static/images/align-start.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/node_ui/static/images/credits.txt b/node_ui/static/images/credits.txt new file mode 100644 index 0000000..fd664ce --- /dev/null +++ b/node_ui/static/images/credits.txt @@ -0,0 +1 @@ +Svg files from svgrepo.com \ No newline at end of file diff --git a/node_ui/static/images/flow-chart-line.svg b/node_ui/static/images/flow-chart-line.svg new file mode 100644 index 0000000..fcc6a5d --- /dev/null +++ b/node_ui/static/images/flow-chart-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/node_ui/static/images/flowchart-outline.svg b/node_ui/static/images/flowchart-outline.svg new file mode 100644 index 0000000..a20b1b9 --- /dev/null +++ b/node_ui/static/images/flowchart-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/node_ui/static/images/node-minus.svg b/node_ui/static/images/node-minus.svg new file mode 100644 index 0000000..fa9b12c --- /dev/null +++ b/node_ui/static/images/node-minus.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/node_ui/static/images/node-plus.svg b/node_ui/static/images/node-plus.svg new file mode 100644 index 0000000..a2354c8 --- /dev/null +++ b/node_ui/static/images/node-plus.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/node_ui/static/images/node-tree.svg b/node_ui/static/images/node-tree.svg new file mode 100644 index 0000000..e10ef83 --- /dev/null +++ b/node_ui/static/images/node-tree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/node_ui/static/images/node.svg b/node_ui/static/images/node.svg new file mode 100644 index 0000000..d3769e5 --- /dev/null +++ b/node_ui/static/images/node.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/node_ui/static/images/reset.svg b/node_ui/static/images/reset.svg new file mode 100644 index 0000000..f1c6410 --- /dev/null +++ b/node_ui/static/images/reset.svg @@ -0,0 +1,12 @@ + + + + reset + + + + + + + + \ No newline at end of file diff --git a/node_ui/static/images/share-no-fill.svg b/node_ui/static/images/share-no-fill.svg new file mode 100644 index 0000000..6288b9c --- /dev/null +++ b/node_ui/static/images/share-no-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/node_ui/static/images/share.svg b/node_ui/static/images/share.svg new file mode 100644 index 0000000..b0438ad --- /dev/null +++ b/node_ui/static/images/share.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/node_ui/static/images/textbox.svg b/node_ui/static/images/textbox.svg new file mode 100644 index 0000000..add9092 --- /dev/null +++ b/node_ui/static/images/textbox.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/node_ui/static/src/node_ui/common.scss b/node_ui/static/src/node_ui/common.scss new file mode 100644 index 0000000..1bc965c --- /dev/null +++ b/node_ui/static/src/node_ui/common.scss @@ -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'; +} \ No newline at end of file diff --git a/node_ui/static/src/node_ui/connection.js b/node_ui/static/src/node_ui/connection.js new file mode 100644 index 0000000..1b89b9d --- /dev/null +++ b/node_ui/static/src/node_ui/connection.js @@ -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"); + } +} diff --git a/node_ui/static/src/node_ui/connection.xml b/node_ui/static/src/node_ui/connection.xml new file mode 100644 index 0000000..c3c74de --- /dev/null +++ b/node_ui/static/src/node_ui/connection.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/node_ui/static/src/node_ui/document.js b/node_ui/static/src/node_ui/document.js new file mode 100644 index 0000000..3abb35a --- /dev/null +++ b/node_ui/static/src/node_ui/document.js @@ -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"); + } +} diff --git a/node_ui/static/src/node_ui/document.xml b/node_ui/static/src/node_ui/document.xml new file mode 100644 index 0000000..bbbf8b2 --- /dev/null +++ b/node_ui/static/src/node_ui/document.xml @@ -0,0 +1,17 @@ + + + +
+ + + + + + + + + +
+
+
diff --git a/node_ui/static/src/node_ui/models.js b/node_ui/static/src/node_ui/models.js new file mode 100644 index 0000000..0a60dd3 --- /dev/null +++ b/node_ui/static/src/node_ui/models.js @@ -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); + }); + } +} diff --git a/node_ui/static/src/node_ui/node.js b/node_ui/static/src/node_ui/node.js new file mode 100644 index 0000000..38bccc7 --- /dev/null +++ b/node_ui/static/src/node_ui/node.js @@ -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); + } + } +} diff --git a/node_ui/static/src/node_ui/node.xml b/node_ui/static/src/node_ui/node.xml new file mode 100644 index 0000000..1f814ac --- /dev/null +++ b/node_ui/static/src/node_ui/node.xml @@ -0,0 +1,65 @@ + + + +
+
+
+ + + +
+
+
+ +
+
+
+
+
Left:
+
Top:
+
+
+
+ + + +
+
+
+
+ + +
+
+
+ + + +
+
Id:
+
+
+
+ + + + + + + + + + + + + + + +
Id:
+
+
+
+ +
\ No newline at end of file diff --git a/node_ui/static/src/node_ui/node_ui.js b/node_ui/static/src/node_ui/node_ui.js new file mode 100644 index 0000000..6c7069a --- /dev/null +++ b/node_ui/static/src/node_ui/node_ui.js @@ -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); diff --git a/node_ui/static/src/node_ui/node_ui.scss b/node_ui/static/src/node_ui/node_ui.scss new file mode 100644 index 0000000..c48caba --- /dev/null +++ b/node_ui/static/src/node_ui/node_ui.scss @@ -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; + } + } + } + } +} \ No newline at end of file diff --git a/node_ui/static/src/node_ui/node_ui.xml b/node_ui/static/src/node_ui/node_ui.xml new file mode 100644 index 0000000..00ad8fe --- /dev/null +++ b/node_ui/static/src/node_ui/node_ui.xml @@ -0,0 +1,89 @@ + + + +
+
+ +
+
+
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+
+
+
+
+
+ + +
+
+ + + + +
+
+
+
diff --git a/node_ui/static/src/node_ui/utils.js b/node_ui/static/src/node_ui/utils.js new file mode 100644 index 0000000..9995bb9 --- /dev/null +++ b/node_ui/static/src/node_ui/utils.js @@ -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; +} + diff --git a/node_ui/views/node_ui_views.xml b/node_ui/views/node_ui_views.xml new file mode 100644 index 0000000..edef05d --- /dev/null +++ b/node_ui/views/node_ui_views.xml @@ -0,0 +1,13 @@ + + + + Node UI + NodeUi + + + + + + + diff --git a/node_ui_basics/README.md b/node_ui_basics/README.md new file mode 100644 index 0000000..cbfeb9e --- /dev/null +++ b/node_ui_basics/README.md @@ -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) \ No newline at end of file diff --git a/node_ui_basics/__init__.py b/node_ui_basics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/node_ui_basics/__manifest__.py b/node_ui_basics/__manifest__.py new file mode 100644 index 0000000..c6754a6 --- /dev/null +++ b/node_ui_basics/__manifest__.py @@ -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 +} diff --git a/node_ui_basics/static/src/canvas_basics.js b/node_ui_basics/static/src/canvas_basics.js new file mode 100644 index 0000000..88327c3 --- /dev/null +++ b/node_ui_basics/static/src/canvas_basics.js @@ -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); diff --git a/node_ui_basics/static/src/canvas_basics.xml b/node_ui_basics/static/src/canvas_basics.xml new file mode 100644 index 0000000..5a37aa4 --- /dev/null +++ b/node_ui_basics/static/src/canvas_basics.xml @@ -0,0 +1,117 @@ + + + +
+
+
+
+
+ Rectangle +
+
+ + +
+
+
+
+
+
+ Circle +
+
+ + +
+
+
+
+
+
+ Ellipse +
+
+ + +
+
+
+
+
+
+ Line +
+
+ + +
+
+
+
+
+
+
+
+ Round Rect +
+
+ + +
+
+
+
+
+
+ Cubic Bézier Curve +
+
+ + +
+
+
+
+
+
+ Quadratic Bézier Curved +
+
+ + +
+
+
+
+
+
+ Misc. Shape +
+
+ + +
+
+
+
+
+
+
+
+ Interactive Curve +
+
+ + +
+
+
+
+
+
+
diff --git a/node_ui_basics/static/src/canvas_connection.js b/node_ui_basics/static/src/canvas_connection.js new file mode 100644 index 0000000..0f57ed9 --- /dev/null +++ b/node_ui_basics/static/src/canvas_connection.js @@ -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); diff --git a/node_ui_basics/static/src/canvas_connection.xml b/node_ui_basics/static/src/canvas_connection.xml new file mode 100644 index 0000000..07fc391 --- /dev/null +++ b/node_ui_basics/static/src/canvas_connection.xml @@ -0,0 +1,71 @@ + + + +
+
+
+
+
+ Canvas Connection +
+
+ +
+
+
+
+
+
+
+
+
+ + + + + + + + + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/node_ui_basics/static/src/canvas_konva.js b/node_ui_basics/static/src/canvas_konva.js new file mode 100644 index 0000000..4bf3ee4 --- /dev/null +++ b/node_ui_basics/static/src/canvas_konva.js @@ -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); diff --git a/node_ui_basics/static/src/canvas_konva.xml b/node_ui_basics/static/src/canvas_konva.xml new file mode 100644 index 0000000..bfe9afa --- /dev/null +++ b/node_ui_basics/static/src/canvas_konva.xml @@ -0,0 +1,18 @@ + + + +
+
+
+
+
+ Konva +
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/node_ui_basics/static/src/canvas_nodes.js b/node_ui_basics/static/src/canvas_nodes.js new file mode 100644 index 0000000..1d8ba26 --- /dev/null +++ b/node_ui_basics/static/src/canvas_nodes.js @@ -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); diff --git a/node_ui_basics/static/src/canvas_nodes.xml b/node_ui_basics/static/src/canvas_nodes.xml new file mode 100644 index 0000000..8fbad85 --- /dev/null +++ b/node_ui_basics/static/src/canvas_nodes.xml @@ -0,0 +1,47 @@ + + + +
+
+
+
+
+ Canvas With DIV Nodes +
+
+ +
+ +
+
+
+
+
+ + + +
+
+
+
+
+
+
+
diff --git a/node_ui_basics/static/src/movable_div.js b/node_ui_basics/static/src/movable_div.js new file mode 100644 index 0000000..8d9b713 --- /dev/null +++ b/node_ui_basics/static/src/movable_div.js @@ -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); + diff --git a/node_ui_basics/static/src/movable_div.xml b/node_ui_basics/static/src/movable_div.xml new file mode 100644 index 0000000..31f68f8 --- /dev/null +++ b/node_ui_basics/static/src/movable_div.xml @@ -0,0 +1,23 @@ + + + +
+
+
+
+
+ Movable DIV +
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/node_ui_basics/static/src/node_ui_basics.scss b/node_ui_basics/static/src/node_ui_basics.scss new file mode 100644 index 0000000..aa1c2a9 --- /dev/null +++ b/node_ui_basics/static/src/node_ui_basics.scss @@ -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; +} \ No newline at end of file diff --git a/node_ui_basics/static/src/node_ui_svg.js b/node_ui_basics/static/src/node_ui_svg.js new file mode 100644 index 0000000..aded271 --- /dev/null +++ b/node_ui_basics/static/src/node_ui_svg.js @@ -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); diff --git a/node_ui_basics/static/src/node_ui_svg.xml b/node_ui_basics/static/src/node_ui_svg.xml new file mode 100644 index 0000000..ac38903 --- /dev/null +++ b/node_ui_basics/static/src/node_ui_svg.xml @@ -0,0 +1,120 @@ + + + +
+
+
+
+
+ Node UI Basic with SVG +
+
+
+ + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+ + + + + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/node_ui_basics/static/src/svg_basics.js b/node_ui_basics/static/src/svg_basics.js new file mode 100644 index 0000000..7fdb795 --- /dev/null +++ b/node_ui_basics/static/src/svg_basics.js @@ -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); diff --git a/node_ui_basics/static/src/svg_basics.xml b/node_ui_basics/static/src/svg_basics.xml new file mode 100644 index 0000000..ac325eb --- /dev/null +++ b/node_ui_basics/static/src/svg_basics.xml @@ -0,0 +1,178 @@ + + + +
+
+
+
+
+ Rectangle +
+
+ + + + + +
+
+
+
+
+
+ Circle +
+
+ + + + + +
+
+
+
+
+
+ Ellipse +
+
+ + + + + +
+
+
+
+
+
+ Line +
+
+ + + + + + + +
+
+
+
+
+
+
+
+ Polygon +
+
+ + + +
+
+
+
+
+
+ Polyline +
+
+ + + +
+
+
+
+
+
+ Simple Path +
+
+ + + +
+
+
+
+
+
+ Curved Path +
+
+ + + +
+
+
+
+
+
+
+
+ Interactive Path +
+
+ + + + + + + + + + + + +
+
+
+
+
+
+ + + + + + + + + + +
diff --git a/node_ui_basics/static/src/svg_bezier.js b/node_ui_basics/static/src/svg_bezier.js new file mode 100644 index 0000000..b4dba5d --- /dev/null +++ b/node_ui_basics/static/src/svg_bezier.js @@ -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); diff --git a/node_ui_basics/static/src/svg_bezier.xml b/node_ui_basics/static/src/svg_bezier.xml new file mode 100644 index 0000000..fcae872 --- /dev/null +++ b/node_ui_basics/static/src/svg_bezier.xml @@ -0,0 +1,41 @@ + + + +
+
+
+
+
+ SVG Connection +
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/node_ui_basics/static/src/svg_connection.js b/node_ui_basics/static/src/svg_connection.js new file mode 100644 index 0000000..1a8d280 --- /dev/null +++ b/node_ui_basics/static/src/svg_connection.js @@ -0,0 +1,179 @@ +import { Component, useRef, useState, onWillStart, onMounted } from "@odoo/owl"; +import { registry } from "@web/core/registry"; + +import { standardActionServiceProps } from "@web/webclient/actions/action_service"; + +class SvgConnection extends Component { + static template = "svg-connection"; + 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 = 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, + }, + 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(this.state.orientation); + } + + _createPaths(orient = "auto"){ + let res = ""; + + res = res + this._createPath(this.state.startNode, this.state.midNode, orient); + res = res + " " + this._createPath(this.state.midNode, this.state.endNode, orient); + + return res; + } + + _createPath(firstNode, secondNode, orient) { + let res = ""; + + 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; + + const dirX = Math.sign(secondX - firstX); + const dirY = Math.sign(secondY - firstY); + + let dirA = dirX > 0 ? 0 : 1; + let dirAFlip = dirA == 0 ? 1 : 0; + + if (dirY < 0){ + const temp = dirA; + dirA = dirAFlip; + dirAFlip = temp; + } + + const minDistance = 5; + const baseMargin = 10; + let margin = baseMargin; + if (hDistance <= margin * 2 || vDistance <= margin * 2){ + margin = hDistance > vDistance ? vDistance : hDistance; + } + + const marginX = margin * dirX; + const marginY = margin * dirY; + + if (orientation === "v") { + res += "M" + (firstX) + "," + (firstY); + res += " L" + (firstX) + "," + (midY - marginY); + + if (hDistance > baseMargin * 2){ + res += " A" + (margin) + " " + (margin) + " 90 0 " + " " + + (dirA) + " " + (firstX + marginX) + "," + (midY); + res += " L" + (secondX - marginX) + "," + (midY); + res += " A" + (margin) + " " + (margin) + " 90 0 " + " " + + (dirAFlip) + " " + (secondX) + "," + (midY + marginY); + } else if (hDistance > minDistance){ + res += " A" + (margin) + " " + (margin) + " 90 0 " + " " + + (dirA) + " " + (firstX + marginX / 2) + "," + (midY); + res += " A" + (margin) + " " + (margin) + " 90 0 " + " " + + (dirAFlip) + " " + (secondX) + "," + (midY + marginY); + } else { + res += " L" + (secondX) + "," + (midY + marginY); + } + + res += " L" + (secondX) + "," + (secondY); + } else { + res += "M" + (firstX) + "," + (firstY); + res += " L" + (midX - marginX) + "," + (firstY); + if (vDistance > baseMargin * 2){ + res += " A" + (margin) + " " + (margin) + " 90 0 " + " " + + (dirAFlip) + " " + (midX) + "," + (firstY + marginY); + res += " L" + (midX) + "," + (secondY - marginY); + res += " A" + (margin) + " " + (margin) + " 90 0 " + " " + + (dirA) + " " + (midX + marginX) + "," + (secondY); + } else if (vDistance > minDistance){ + res += " A" + (margin) + " " + (margin) + " 90 0 " + " " + + (dirAFlip) + " " + (midX) + "," + (firstY + marginY / 2); + res += " A" + (margin) + " " + (margin) + " 90 0 " + " " + + (dirA) + " " + (midX + marginX) + "," + (secondY); + } else { + res += " L" + (midX + marginX) + "," + (secondY); + } + res += " L" + (secondX) + "," + (secondY); + } + return res; + } +} + +registry.category("actions").add("svg_connection", SvgConnection); diff --git a/node_ui_basics/static/src/svg_connection.xml b/node_ui_basics/static/src/svg_connection.xml new file mode 100644 index 0000000..40463cc --- /dev/null +++ b/node_ui_basics/static/src/svg_connection.xml @@ -0,0 +1,73 @@ + + + +
+
+
+
+
+ SVG Connection +
+
+
+ + + +
+
+
+
+
+
+
+
+ + + + + + + + + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/node_ui_basics/static/src/svg_nodes.js b/node_ui_basics/static/src/svg_nodes.js new file mode 100644 index 0000000..18d6652 --- /dev/null +++ b/node_ui_basics/static/src/svg_nodes.js @@ -0,0 +1,113 @@ +import { Component, useRef, useState } 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 SvgNodes extends Component { + static template = "svg-nodes"; + static components = {}; + static props = { + ...standardActionServiceProps, + }; + + setup() { + this.containerRef = useRef("container"); + + this.state = useState({ + nodes: [], + selected: undefined, + }); + } + + onAddButtonClick() { + this.state.nodes.push({ + id: uuidv4(), + cx: 50, + cy: 50, + r: 25, + }); + } + + 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, + } + ); + } + + 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.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.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; + } + } + } + } +} + +registry.category("actions").add("svg_nodes", SvgNodes); diff --git a/node_ui_basics/static/src/svg_nodes.xml b/node_ui_basics/static/src/svg_nodes.xml new file mode 100644 index 0000000..f2792e2 --- /dev/null +++ b/node_ui_basics/static/src/svg_nodes.xml @@ -0,0 +1,56 @@ + + + +
+
+
+
+
+ SVG With DIV Nodes +
+
+
+ + + + + + + + +
+
+
+
+
+ + + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/node_ui_basics/static/src/utils/utils.js b/node_ui_basics/static/src/utils/utils.js new file mode 100644 index 0000000..02d7bb3 --- /dev/null +++ b/node_ui_basics/static/src/utils/utils.js @@ -0,0 +1,30 @@ +/* + * 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 createDiv(l, t, w, h, c) { + const el = document.createElement("div"); + + el.className = "debug-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); +} \ No newline at end of file diff --git a/node_ui_basics/views/node_ui_basics_views.xml b/node_ui_basics/views/node_ui_basics_views.xml new file mode 100644 index 0000000..bbd62a3 --- /dev/null +++ b/node_ui_basics/views/node_ui_basics_views.xml @@ -0,0 +1,97 @@ + + + + SVG + svg_basics + + + + SVG with Nodes + svg_nodes + + + + SVG Connection + svg_connection + + + + SVG Bezier + svg_bezier + + + + Node UI with SVG + node_ui_svg + + + + Canvas + canvas_basics + + + + Canvas with Nodes + canvas_nodes + + + + Canvas Connection + canvas_connection + + + + Canvas Connection + canvas_connection + + + + Konva + canvas_konva + + + + movable DIV + movable_div + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/quickboard/README.md b/quickboard/README.md new file mode 100644 index 0000000..eea6c3f --- /dev/null +++ b/quickboard/README.md @@ -0,0 +1,22 @@ +# Quickboard +> [!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] +> AI might generate commands that negatively impact your data. +> +> Do not use this module unless you have reviewed the source codes thoroughly, understand what it does and in an experimental environment. + +This module demonstrate how to create simple yet flexible dashboard and how to use AI to generate dashboard items and to arrange the dashboard layout. + +Please watch this video for more details: + +[![EXPLORING_ODOO](https://img.youtube.com/vi/LfxlUN9pikI/0.jpg)](https://youtu.be/LfxlUN9pikI) + +[![EXPLORING_ODOO](https://img.youtube.com/vi/y_prYVEp9mk/0.jpg)](https://youtu.be/y_prYVEp9mk) \ No newline at end of file diff --git a/quickboard/__init__.py b/quickboard/__init__.py new file mode 100644 index 0000000..e4f4917 --- /dev/null +++ b/quickboard/__init__.py @@ -0,0 +1,3 @@ +from . import controllers +from . import models +from . import wizard diff --git a/quickboard/__manifest__.py b/quickboard/__manifest__.py new file mode 100644 index 0000000..25dde1a --- /dev/null +++ b/quickboard/__manifest__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Quickboard", + 'summary': """Quickboard is a simple and easy to use dashboard powered with AI.""", + 'description': """ + Quickboard is a simple and easy to use dashboard powered with AI. + """, + 'author': "Yoni Tjio", + 'category': 'Productivity', + 'version': '18.0.1.0.0', + 'depends': ['web'], + 'data': [ + 'security/quickboard_security.xml', + 'security/ir.model.access.csv', + 'views/quickboard_views.xml', + 'views/quickboard_item_views.xml', + 'wizard/quickboard_generator_views.xml' + ], + 'assets': { + "web.assets_backend": [ + "quickboard/static/src/**/*", + ("remove", "quickboard/static/src/quickboard/**/*") + ], + "quickboard.assets": [ + ('include', "web.chartjs_lib"), + "quickboard/static/lib/gridstack/*", + "quickboard/static/lib/spinjs/*", + "quickboard/static/src/quickboard/**/*", + "quickboard/static/src/css/**/*", + ], + }, + "license":"Other proprietary", + "application": True, + "installable": True, + "auto_install": False +} diff --git a/quickboard/controllers/__init__.py b/quickboard/controllers/__init__.py new file mode 100644 index 0000000..cd4d6a8 --- /dev/null +++ b/quickboard/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import main \ No newline at end of file diff --git a/quickboard/controllers/main.py b/quickboard/controllers/main.py new file mode 100644 index 0000000..5ae0431 --- /dev/null +++ b/quickboard/controllers/main.py @@ -0,0 +1,297 @@ +# -*- coding: utf-8 -*- +from ast import literal_eval +import pandas as pd + +from odoo import http, fields, models +from odoo.http import request +from odoo.osv import expression +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT + +class QuickboardController(http.Controller): + def get_quickboard_item_values(self, quickboard_item, start_date=None, end_date=None, with_data=False): + vals = { + 'id': quickboard_item.id, + 'name': quickboard_item.name, + 'model_name': quickboard_item.model_name, + 'icon': quickboard_item.icon, + 'type': quickboard_item.type, + 'chart_type': quickboard_item.chart_type, + 'height': quickboard_item.height, + 'width': quickboard_item.width, + 'x_pos': quickboard_item.x_pos, + 'y_pos': quickboard_item.y_pos, + 'value_field_name': ",".join([f"{o.display_name}" for o in quickboard_item.value_field_id]), + 'value_field_type': ",".join([f"{o.ttype}" for o in quickboard_item.value_field_id]), + 'dimension_field_name': quickboard_item.dimension_field_id.display_name, + 'dimension_field_type': quickboard_item.dimension_field_id.ttype, + 'datetime_granularity': quickboard_item.datetime_granularity, + 'group_field_name': quickboard_item.group_field_id.display_name, + 'group_field_type': quickboard_item.group_field_id.ttype, + 'list_row_limit': quickboard_item.list_row_limit, + 'aggregate_function': quickboard_item.aggregate_function, + 'text_color': quickboard_item.text_color, + 'background_color': quickboard_item.background_color + } + + if with_data: + domain = [] + + date_filter_field = "create_date" + if date_filter_field not in request.env[quickboard_item.model_name]: + field_iterable = request.env[quickboard_item.model_name]._fields.items() + new_date_filter_field = next((v for k, v in field_iterable if v.type in ["date", "datetime"]), None) + date_filter_field = new_date_filter_field.name + + if start_date and date_filter_field is not None: + sd = fields.Datetime.from_string(start_date) + domain.append((date_filter_field, ">", sd)) + + if end_date and date_filter_field is not None: + ed = fields.Datetime.from_string(end_date) + domain.append((date_filter_field, "<", ed)) + + if quickboard_item.domain_filter and quickboard_item.domain_filter != "": + the_filter = expression.AND([literal_eval(quickboard_item.domain_filter)]) + domain = expression.AND([domain, the_filter]) + + if quickboard_item.type == "basic": + aggregate_value = 0 + aggr_func = f"{quickboard_item.value_field_id.name}:{quickboard_item.aggregate_function}" + + agg = request.env[quickboard_item.model_name].sudo()._read_group( + domain=domain, + groupby=[], + aggregates=[aggr_func] + ) + aggregate_value = agg[0][0] if agg[0][0] else 0 + vals.update({ 'aggregate_value': aggregate_value }) + elif quickboard_item.type == 'list': + data = [] + grouping = [] + + aggr_func = f"{quickboard_item.value_field_id.name}:{quickboard_item.aggregate_function}" + group_by = quickboard_item.dimension_field_id.name + if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]: + group_by = f"{group_by}:{quickboard_item.datetime_granularity}" + + grouping.append(group_by) + + limit = quickboard_item.list_row_limit + + order = f"{aggr_func} desc" + + aggs = request.env[quickboard_item.model_name].sudo()._read_group( + domain=domain, + groupby=grouping, + aggregates=[aggr_func], + limit=limit, + order=order + ) + + # seq is to ease t-foreach on the javascript part because it needs t-key + for seq, agg in enumerate(aggs, start=1): + if isinstance(agg[0], models.Model): + if agg[0]: + x_data = agg[0].name + else: + x_data = "N/A" + else: + x_data = agg[0] + + data.append({ + "seq": seq, + "x": x_data, + "y": agg[1] + }) + + vals.update({'data': data}) + else: + if quickboard_item.group_field_id: + data = [] + grouping = [] + + aggr_func = f"{quickboard_item.value_field_id.name}:{quickboard_item.aggregate_function}" + group_by = quickboard_item.dimension_field_id.name + if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]: + group_by = f"{group_by}:{quickboard_item.datetime_granularity}" + + grouping.append(group_by) + order = f"{grouping[0]} desc, {aggr_func} asc" + + sub_group = quickboard_item.group_field_id.name + if quickboard_item.group_field_id.ttype in "many2one": + sub_group = quickboard_item.group_field_id.name + + if quickboard_item.group_field_id.ttype in ["date", "datetime"]: + sub_group = f"{sub_group}:{quickboard_item.datetime_granularity}" + + grouping.append(sub_group) + + order = f"{grouping[1]} desc, {grouping[0]} desc, {aggr_func} asc" + aggs = request.env[quickboard_item.model_name].sudo()._read_group( + domain=domain, + groupby=grouping, + aggregates=[aggr_func], + order=order + ) + + if len(aggs) > 0: + dimension_field_display_name = quickboard_item.dimension_field_id.display_name + group_field_display_name = quickboard_item.group_field_id.display_name + value_field_display_name = quickboard_item.value_field_id.display_name + df = pd.DataFrame(aggs, + columns=[ + dimension_field_display_name, + group_field_display_name, + value_field_display_name + ] + ) + + if (quickboard_item.dimension_field_id.ttype == "many2one"): + df[dimension_field_display_name] = df[dimension_field_display_name].map(lambda o: o.name) + + filler = None + if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]: + if quickboard_item.datetime_granularity == "year": + filler = pd.DataFrame(pd.date_range( + df[dimension_field_display_name].min(), + df[dimension_field_display_name].max(), + freq="YS" + ), + columns=[dimension_field_display_name] + ) + elif quickboard_item.datetime_granularity == "month": + filler = pd.DataFrame(pd.date_range( + df[dimension_field_display_name].min(), + df[dimension_field_display_name].max(), + freq="MS" + ), + columns=[dimension_field_display_name] + ) + else: + filler = pd.DataFrame(pd.date_range( + df[dimension_field_display_name].min(), + df[dimension_field_display_name].max(), + freq="D" + ), + columns=[dimension_field_display_name] + ) + else: + filler = pd.DataFrame( + df[dimension_field_display_name].unique(), + columns=[dimension_field_display_name] + ) + + keys = df[group_field_display_name].unique().tolist() + + for key in keys: + dfd = df.loc[ + df[group_field_display_name] == key + ][ + [ + dimension_field_display_name, + value_field_display_name + ] + ] + + dfd = filler.merge(right=dfd, how="left", on=dimension_field_display_name) + + if (quickboard_item.value_field_id.ttype in ["integer", "float", "monetary"]): + dfd = dfd.fillna(0) + else: + dfd = dfd.fillna("") + + if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]: + dfd[dimension_field_display_name] = dfd[dimension_field_display_name].dt.strftime(DEFAULT_SERVER_DATE_FORMAT) + + dataset = dfd.values.tolist() + + label = "N/A" + if isinstance(key, models.Model): + label = key.name + else: + label = key + + data.append({ + "label": label, + "dataset": dataset + }) + + vals.update({'data': data}) + else: + data = [] + + group_by = quickboard_item.dimension_field_id.name + if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]: + group_by = f"{group_by}:{quickboard_item.datetime_granularity}" + + for value_field in quickboard_item.value_field_id: + aggr_func = f"{value_field.name}:{quickboard_item.aggregate_function}" + + order = f"{group_by} desc, {aggr_func} asc" + + aggs = request.env[quickboard_item.model_name].sudo()._read_group( + domain=domain, + groupby=[group_by], + aggregates=[aggr_func], + order=order + ) + + dataset = [] + for agg in aggs: + if isinstance(agg[0], models.Model): + if agg[0]: + x_data = agg[0].name + else: + x_data = "N/A" + else: + x_data = agg[0] + + dataset.append([x_data, agg[1]]) + + data.append({ + "label": value_field.display_name, + "dataset": dataset + }) + + vals.update({'data': data}) + return vals + + @http.route('/quickboard/item', type='json', auth='user', website=True) + def get_quickboard_item(self, item_id, start_date=None, end_date=None): + quickboard_item = request.env['quickboard.item'].with_context({"hide_model": True}).search([("id", "=", item_id)]) + vals = self.get_quickboard_item_values(quickboard_item, start_date, end_date, True) + return vals + + @http.route('/quickboard/item_defs', type='json', auth='user', website=True) + def get_quickboard_items(self): + items = [] + for quickboard_item in request.env['quickboard.item'].with_context({"hide_model": True}).search([], order="id"): + vals = self.get_quickboard_item_values(quickboard_item, None, None, False) + items.append(vals) + return items + + @http.route('/quickboard/save_layout', type='json', auth='user', website=True) + def save_layout(self, layout): + for item in layout: + quickboard_item = request.env["quickboard.item"].with_context({"hide_model": True}).search([("id", "=", item["id"])], limit=1) + quickboard_item.update({ + "x_pos": item["x"], + "y_pos": item["y"], + "height": item["h"] if "h" in item else 0, + "width": item["w"] if "w" in item else 0 + }) + return True + + @http.route('/quickboard/save_theme', type='json', auth='user', website=True) + def save_theme(self, theme): + request.env.user.res_users_settings_id.quickboard_theme = theme + return True + + @http.route('/quickboard/save_filter', type='json', auth='user', website=True) + def save_filter(self, start_date, end_date): + request.env.user.res_users_settings_id.update({ + "quickboard_start_date": start_date, + "quickboard_end_date": end_date + }) + return True diff --git a/quickboard/models/__init__.py b/quickboard/models/__init__.py new file mode 100644 index 0000000..db361b1 --- /dev/null +++ b/quickboard/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import quickboard_item +from . import res_users_settings \ No newline at end of file diff --git a/quickboard/models/quickboard_item.py b/quickboard/models/quickboard_item.py new file mode 100644 index 0000000..822ca78 --- /dev/null +++ b/quickboard/models/quickboard_item.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +from typing import Dict, List + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +class QuickboardItem(models.Model): + _name = "quickboard.item" + _description = "Quickboard Item" + + name = fields.Char(string="Name") + model_id = fields.Many2one('ir.model', string='Model') + model_name = fields.Char(related='model_id.model', string="Model Name") + icon = fields.Char(string="Icon") + type = fields.Selection( + selection=[("basic", "Basic"), ("chart", "Chart"), ("list", "List")], + string="Item Type", + default="basic") + chart_type = fields.Selection( + selection=[("bar", "Bar"), ('horizontal-bar', 'Horizontal Bar'), ('doughnut', "Doughnut"), ("line", "Line"), ("pie", "Pie"), ("polar", "Polar Area")], + string="Chart Type") + + value_field_id = fields.Many2many("ir.model.fields", string="Value Field") + aggregate_function = fields.Selection( + selection=[("avg","Average"), ("count", "Count"), ('max', "Max"), ('min', "Min"), ("sum","Sum")], + string="Aggregate Function", + default="count", + depends=['value_field_id']) + + dimension_field_id = fields.Many2one("ir.model.fields", string="Dimension Field") + group_field_id = fields.Many2one("ir.model.fields", string="Group Field") + datetime_granularity = fields.Selection( + selection=[("year", "Year"), ("month", "Month"), ("day", "Day")], + string="Date/Time Granularity", + default="day", + depends=['dimension_field_id']) + + list_row_limit = fields.Integer(string="Row limit", default=10) + + domain_filter = fields.Char(string="Filter") + + # basic item color + text_color = fields.Integer("Text Color") + background_color = fields.Integer("Background Color") + + # layout + x_pos = fields.Integer(string="X Pos") + y_pos = fields.Integer(string="Y Pos") + height = fields.Integer(string="Height") + width = fields.Integer(string="Width") + + @api.model_create_multi + def create(self, vals_list): + for val in vals_list: + if not self.env.context.get("ai_generation", False): + if 'type' in val: + if val["type"] == "basic": + val["width"] = 2 + val["height"] = 1 + else: + val["width"] = 4 + val["height"] = 2 + + sql = f"""WITH item_dim AS ( + SELECT y_pos, CASE WHEN height = 0 THEN 1 ELSE height END AS height + FROM quickboard_item sdi WHERE create_uid = {self.env.uid} + ) + SELECT max(y_pos + height) as max_y_pos FROM item_dim WHERE y_pos = (SELECT max(y_pos) FROM item_dim); + """ + self.env.cr.execute(sql) + res = self.env.cr.dictfetchall() + max_y_pos = res[0].get("max_y_pos") + val["y_pos"] = max_y_pos + val["x_pos"] = 0 + res = super().create(vals_list) + return res + + @api.onchange("type") + def clear_values(self): + for rec in self: + if rec.type: + if rec.type == 'basic': + rec.group_field_id = False + rec.dimension_field_id = False + elif rec.type == 'list': + rec.group_field_id = False + + @api.constrains("aggregate_function", "value_field_id") + def _validate_aggregate_function(self): + for rec in self: + if rec.type != 'chart' and len(rec.value_field_id) > 1: + raise ValidationError(f"Basic and list items can only have one value field.") + if rec.type == 'chart': + for vf in rec.value_field_id: + if vf.ttype not in ['float', 'integer', 'monetary'] and rec.aggregate_function != "count": + raise ValidationError(f"Other fields than float, integer and monetary can only use count as aggregation.") + else: + if rec.value_field_id.ttype not in ['float', 'integer', 'monetary'] and rec.aggregate_function != "count": + raise ValidationError(f"Other fields than float, integer and monetary can only use count as aggregation.") + + @api.constrains("value_field_id", "dimension_field_id") + def _validate_value_field_01(self): + for rec in self: + if rec.value_field_id and rec.dimension_field_id and rec.value_field_id == rec.dimension_field_id: + raise ValidationError("Value field must not be the same with dimension field.") + + + @api.constrains("dimension_field_id", "type") + def _validate_dimension_field_01(self): + for rec in self: + if rec.type in ["chart", "list"] and not rec.dimension_field_id: + raise ValidationError("Dimension field is required for charts.") + + @api.constrains("list_row_limit", "type") + def _validate_dimension_field_02(self): + for rec in self: + if rec.type == "list" and not rec.list_row_limit: + raise ValidationError("Row limit is required for lists.") + + @api.constrains("dimension_field_id", "datetime_granularity") + def _validate_dimension_field_03(self): + for rec in self: + if rec.dimension_field_id.ttype in ["date", "datetime"] and not rec.datetime_granularity: + raise ValidationError("Granularity for date or datetime field is required for charts.") + + @api.constrains("dimension_field_id", "group_field_id") + def _validate_dimension_field_04(self): + for rec in self: + if not rec.dimension_field_id and rec.group_field_id: + raise ValidationError("Dimension field is required for grouping data.") + if rec.dimension_field_id and rec.group_field_id and rec.dimension_field_id == rec.group_field_id: + raise ValidationError("Dimension field must not be the same with grouping field.") + + @api.constrains("group_field_id", "type") + def _validate_group_field_01(self): + for rec in self: + if rec.type != "chart" and rec.group_field_id: + raise ValidationError("Grouping only supported for charts.") + + @api.constrains("group_field_id") + def _validate_group_field_02(self): + for rec in self: + if rec.group_field_id and rec.group_field_id.ttype in ["many2many", "one2many"]: + raise ValidationError("Grouping is not supported for x2many fields.") + + @api.constrains("value_field_id", "group_field_id") + def _validate_group_field_03(self): + for rec in self: + if rec.group_field_id and len(rec.value_field_id) > 1: + raise ValidationError("Grouping is not supported when using multiple value fields.") + + def web_save(self, vals, specification: Dict[str, Dict], next_id=None) -> List[Dict]: + res = super(QuickboardItem, self).web_save(vals, specification=specification, next_id=next_id) + if self.env.context.get("quick_edit", False): + self.env["bus.bus"]._sendone( + "quickboard", + "quickboard_item_updated", + { + "id": self.id, + } + ) + return res diff --git a/quickboard/models/res_users_settings.py b/quickboard/models/res_users_settings.py new file mode 100644 index 0000000..d23feab --- /dev/null +++ b/quickboard/models/res_users_settings.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +class Users(models.Model): + _inherit = 'res.users.settings' + + quickboard_theme = fields.Char("Quickboard Theme", default="def") + quickboard_start_date = fields.Char("Start Date") + quickboard_end_date = fields.Char("End Date") + + @api.constrains("quickboard_start_date") + def _validate_start_date(self): + for rec in self: + if rec.quickboard_start_date and rec.quickboard_start_date.strip() != "": + dt = fields.Datetime.from_string(rec.quickboard_start_date) + if not dt: + raise ValidationError(f"Invalid date.") + else: + rec.quickboard_start_date = None + + @api.constrains("quickboard_end_date") + def _validate_end_date(self): + for rec in self: + if rec.quickboard_end_date and rec.quickboard_end_date.strip() != "": + dt = fields.Datetime.from_string(rec.quickboard_end_date) + if not dt: + raise ValidationError(f"Invalid date.") + else: + rec.quickboard_end_date = None \ No newline at end of file diff --git a/quickboard/security/ir.model.access.csv b/quickboard/security/ir.model.access.csv new file mode 100644 index 0000000..1bd4ffc --- /dev/null +++ b/quickboard/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ir_model_quick_board,access_ir_model_quick_board,base.model_ir_model,group_quickboard_user,1,0,0,0 +access_ir_field_quick_board,access_ir_field_quick_board,base.model_ir_model_fields,group_quickboard_user,1,0,0,0 +access_quickboard_item,access_quickboard_item,model_quickboard_item,group_quickboard_user,1,1,1,1 +access_quickboard_generator,access_quickboard_generator,model_quickboard_generator,group_quickboard_user,1,1,1,1 diff --git a/quickboard/security/quickboard_security.xml b/quickboard/security/quickboard_security.xml new file mode 100644 index 0000000..d22f0c9 --- /dev/null +++ b/quickboard/security/quickboard_security.xml @@ -0,0 +1,24 @@ + + + + Quickboard + Quickboard + + + + Quickboard user + + + + + + Quickboard: Items + + [('create_uid', '=', user.id)] + + + + + + + diff --git a/quickboard/static/description/icon.png b/quickboard/static/description/icon.png new file mode 100644 index 0000000..9789f4b Binary files /dev/null and b/quickboard/static/description/icon.png differ diff --git a/quickboard/static/description/icon.svg b/quickboard/static/description/icon.svg new file mode 100644 index 0000000..de893b8 --- /dev/null +++ b/quickboard/static/description/icon.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + diff --git a/quickboard/static/src/core/colors.js b/quickboard/static/src/core/colors.js new file mode 100644 index 0000000..2897309 --- /dev/null +++ b/quickboard/static/src/core/colors.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ +export const QUICKBOARD_BG_COLORS = { + "def": ["#845ec2","#d65db1","#ff6f91","#ff9671","#ffc75f","#2c73d2","#0081cf","#0089ba","#008e9b","#008f7a"], + "alt": ["#005f73","#ee9b00","#94d2bd","#ca6702","#e9d8a6","#bb3e03","#0a9396","#9b2226","#ae2012","#c0d896"], + "cld": ["#a9d6e5","#89c2d9","#61a5c2","#468faf","#2c7da0","#2a6f97","#014f86","#01497c","#013a63","#012a4a"], + "hot": ["#ffb950","#ffad33","#ff931f","#ff7e33","#fa5e1f","#ec3f13","#b81702","#a50104","#8e0103","#7a0103"], + "ert": ["#bfc882","#7b4618","#a4b75c","#532a09","#647332","#915c27","#3e4c22","#ad8042","#2e401c","#bfab67"], + "clr": ["#1f2ba0","#0063ff","#0087ff","#19b6ec","#038659","#006f26","#563c0c","#803c00","#ed9180","#ff1002"], + "ptl": ["#66c5cc","#f6cf71","#f89c74","#dcb0f2","#87c55f","#9eb9f3","#fe88b1","#c9db74","#8be0a4","#b497e7"], + "pur": ["#f992ad","#fbbcee","#fab4c8","#f78ecf","#cfb9f7","#e0cefd","#a480f2","#d4b0f9","#c580ed","#d199f1"] + } + +export const QUICKBOARD_FG_COLORS = { + "def": ["#000000","#ffffff"], + "alt": ["#000000","#ffffff"], + "cld": ["#000000","#ffffff"], + "hot": ["#000000","#ffffff"], + "ert": ["#000000","#ffffff"], + "clr": ["#000000","#ffffff"], + "ptl": ["#000000","#ffffff"], + "pur": ["#000000","#ffffff"] +} + +export function getBackgroundColor(index, theme="def") { + let idx = index % QUICKBOARD_BG_COLORS[theme].length; + return QUICKBOARD_BG_COLORS[theme][idx]; +} + +export function getForegroundColor(index, theme="def") { + let idx = index % QUICKBOARD_FG_COLORS[theme].length; + return QUICKBOARD_FG_COLORS[theme][idx]; +} diff --git a/quickboard/static/src/core/qb_color_list/qb_color_list.js b/quickboard/static/src/core/qb_color_list/qb_color_list.js new file mode 100644 index 0000000..660c511 --- /dev/null +++ b/quickboard/static/src/core/qb_color_list/qb_color_list.js @@ -0,0 +1,55 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; + +import { Component, useRef, useState, useExternalListener } from "@odoo/owl"; + +export class QbColorList extends Component { + static template = "quickboard.QbColorList"; + static defaultProps = { + forceExpanded: false, + isExpanded: false, + }; + static props = { + canToggle: { type: Boolean, optional: true }, + colors: Array, + forceExpanded: { type: Boolean, optional: true }, + isExpanded: { type: Boolean, optional: true }, + onColorSelected: Function, + selectedColor: { type: Number, optional: true }, + }; + + setup() { + this.colorlistRef = useRef("colorlist"); + this.state = useState({ isExpanded: this.props.isExpanded }); + useExternalListener(window, "click", this.onOutsideClick); + } + + get colors() { + return this.props.colors; + } + + onColorSelected(id) { + const idx = this.props.colors.indexOf(id); + this.props.onColorSelected(idx); + if (!this.props.forceExpanded) { + this.state.isExpanded = false; + } + } + + onOutsideClick(ev) { + if (this.colorlistRef.el.contains(ev.target) || this.props.forceExpanded) { + return; + } + this.state.isExpanded = false; + } + + onToggle(ev) { + if (this.props.canToggle) { + ev.preventDefault(); + ev.stopPropagation(); + this.state.isExpanded = !this.state.isExpanded; + this.colorlistRef.el.firstElementChild.focus(); + } + } +} diff --git a/quickboard/static/src/core/qb_color_list/qb_color_list.xml b/quickboard/static/src/core/qb_color_list/qb_color_list.xml new file mode 100644 index 0000000..7b9f6ca --- /dev/null +++ b/quickboard/static/src/core/qb_color_list/qb_color_list.xml @@ -0,0 +1,21 @@ + + + +
+ +
+
+
diff --git a/quickboard/static/src/css/quickboard.css b/quickboard/static/src/css/quickboard.css new file mode 100644 index 0000000..5fb4dd5 --- /dev/null +++ b/quickboard/static/src/css/quickboard.css @@ -0,0 +1,28 @@ +.quickboard { + background-color: #555; +} + +.grid-stack-item-content { + background-color: whitesmoke; + border: 1px solid black; +} + +.quickboard-item-basic-title { + font-weight: 500; +} + +.quickboard-item-basic-value { + font-size: 3em; +} + +.quickboard-item-basic-icon { + font-size: 3em; +} + +.quickboard-item-chart-title, .quickboard-item-list-title { + font-weight: 500; +} + +.quickboard-item-chart-icon, .quickboard-item-list-icon { + font-size: 1em !important; +} diff --git a/quickboard/static/src/quickboard/quickboard.js b/quickboard/static/src/quickboard/quickboard.js new file mode 100644 index 0000000..f1de837 --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard.js @@ -0,0 +1,241 @@ +/** @odoo-module **/ + +import { Component, useRef, useEffect, useState, onPatched } from "@odoo/owl"; +import { standardActionServiceProps } from "@web/webclient/actions/action_service"; +import { registry } from "@web/core/registry"; +import { user } from "@web/core/user"; +import { useService } from "@web/core/utils/hooks"; +import { DateTimeInput } from "@web/core/datetime/datetime_input"; +import { SelectMenu } from "@web/core/select_menu/select_menu"; +import { deserializeDateTime, serializeDateTime } from "@web/core/l10n/dates"; +import { QuickboardItem } from "./quickboard_item"; +import { QUICKBOARD_BG_COLORS } from "../core/colors" + +class Quickboard extends Component { + static template = "quickboard"; + static components = { SelectMenu, DateTimeInput, QuickboardItem }; + static props = { + ...standardActionServiceProps, + }; + + setup() { + this.action = useService("action"); + this.dialog = useService("dialog"); + + let theme = "def"; + if (user.settings.quickboard_theme) { + theme = user.settings.quickboard_theme; + } + + let startDate = luxon.DateTime.local().startOf("month"); + if (user.settings.quickboard_start_date) { + startDate = deserializeDateTime( + user.settings.quickboard_start_date + ); + } + let endDate = luxon.DateTime.now(); + if (user.settings.quickboard_end_date) { + endDate = deserializeDateTime(user.settings.quickboard_end_date); + } + + this.gridRef = useRef("grid-stack"); + this.state = useState({ + "theme": theme, + "startDate": startDate, + "endDate": endDate, + "items": [], + }); + + this.quickboard = useState(useService("quickboard")); + this.quickboard.getQuickboardItemDefs( + this.state.startDate, + this.state.endDate + ); + + useEffect( + (isReady) => { + self = this; + let items = Object.entries(this.quickboard.items) + .filter(([k, v]) => !isNaN(k)) + .map(([k, v]) => Object.assign({}, v)); + this.state.items = items; + }, + () => [this.quickboard.isReady] + ); + + onPatched(() => { + this.gridRef.current = + this.gridRef.current || + GridStack.init({ + float: true, + columnOpts: { + breakpoints: [{ w: 768, c: 1 }], + }, + cellHeight: "10rem", + }); + if (this.gridRef.current) { + const grid = this.gridRef.current; + grid.batchUpdate(); + grid.removeAll(false); + + for (let i = 0; i < this.state.items.length; i++) { + const element = document.querySelector( + `#grid-stack-item-${this.state.items[i]["id"]}` + ); + if (element) { + grid.makeWidget(element); + } + } + + grid.batchUpdate(false); + } + }); + + this.busService = this.env.services.bus_service; + this.busService.addChannel("quickboard"); + this.busService.subscribe("quickboard_updated", ({}) => { + this.onQuickboardUpdated(); + }); + + this.setupNoData(); + } + + onQuickboardUpdated() { + this.applyFilter(); + let grid = this.gridRef.current; + grid.compact(); + } + + onStartDateChanged(date) { + this.state.startDate = date; + } + + onEndDateChanged(date) { + this.state.endDate = date; + } + + saveQuickboard(ev) { + let serializedData = this.gridRef.current.save(false); + this.quickboard.saveLayout(serializedData); + } + + compact(ev) { + let grid = this.gridRef.current; + grid.compact(); + } + + async generateQuickboard(ev) { + const cell_width = this.gridRef.current.cellWidth(); + // DO NOT REMOVE: Without this the getCellHeight will return weird number + const h_0 = this.gridRef.current.cellHeight().el.clientHeight; + const cell_height = this.gridRef.current.getCellHeight(); + const screen_width = screen.width; + const screen_height = screen.height; + + this.action.doAction( + { + type: "ir.actions.act_window", + name: "Generate Quickboard", + res_model: "quickboard.generator", + views: [[false, "form"]], + view_mode: "form", + target: "new", + context: { + dialog_size: "medium", + cell_width: cell_width, + cell_height: cell_height, + screen_width: screen_width, + screen_height: screen_height, + }, + } + ); + } + + async addItem(ev) { + this.action.doAction( + { + type: "ir.actions.act_window", + name: "New", + res_model: "quickboard.item", + views: [[false, "form"]], + view_mode: "form", + target: "new", + context: { + dialog_size: "medium", + quick_add: true + }, + }, + { + onClose: () => { + this.applyFilter(); + }, + } + ); + } + + setupNoData() { + Chart.register({ + id: "NoData", + afterDraw: function (chart) { + if ( + chart.data.datasets + .map((d) => d.data.length) + .reduce((p, a) => p + a, 0) === 0 + ) { + const ctx = chart.ctx; + const width = chart.width; + const height = chart.height; + chart.clear(); + + ctx.save(); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + ctx.fillText("No data to display.", width / 2, height / 2); + ctx.restore(); + } + }, + }); + } + + async applyFilter(ev) { + await user.setUserSettings( + "quickboard_start_date", + serializeDateTime(this.state.startDate) + ); + await user.setUserSettings( + "quickboard_end_date", + serializeDateTime(this.state.endDate) + ); + await this.quickboard.getQuickboardItemDefs(); + } + + async onSelectTheme(val) { + this.state.theme = val; + await user.setUserSettings("quickboard_theme", val); + this.quickboard.getQuickboardItemDefs(); + } + + getThemeSelectionItem(label, theme){ + return { + value: theme, + label: label, + colors: QUICKBOARD_BG_COLORS[theme] + } + } + + get themes() { + return [ + this.getThemeSelectionItem("Default", "def"), + this.getThemeSelectionItem("Alternative", "alt"), + this.getThemeSelectionItem("Cold", "cld"), + this.getThemeSelectionItem("Hot", "hot"), + this.getThemeSelectionItem("Earth", "ert"), + this.getThemeSelectionItem("Colorful", "clr"), + this.getThemeSelectionItem("Pastel", "ptl"), + this.getThemeSelectionItem("Pink Purple", "pur"), + ]; + } +} + +registry.category("lazy_components").add("Quickboard", Quickboard); diff --git a/quickboard/static/src/quickboard/quickboard.xml b/quickboard/static/src/quickboard/quickboard.xml new file mode 100644 index 0000000..1c9f877 --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard.xml @@ -0,0 +1,88 @@ + + + + +
+
+ +
+
+
Theme
+
+ + +
+ +
+
+
+
+
+
+
+
+
+
Start Date
+
+ +
+
+
+
End Date
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/quickboard/static/src/quickboard/quickboard_item.js b/quickboard/static/src/quickboard/quickboard_item.js new file mode 100644 index 0000000..5db453e --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_item.js @@ -0,0 +1,36 @@ +/** @odoo-module **/ +import { Component } from "@odoo/owl"; +import { QuickboardItemBasic } from "./quickboard_item_basic"; +import { QuickboardItemChart } from "./quickboard_item_chart"; +import { QuickboardItemList } from "./quickboard_item_list"; +import { standardQuickboardItemProps } from "./standard_quickboard_item_props" + +export class QuickboardItem extends Component { + static template = "quickboard.QuickboardItem" + static props = { + ...standardQuickboardItemProps, + itemType: { type: String }, + } + + get _itemComponent(){ + if (this.props.itemType === "basic"){ + return QuickboardItemBasic; + } else if (this.props.itemType === "chart"){ + return QuickboardItemChart + } else if (this.props.itemType === "list"){ + return QuickboardItemList + } + + return Component; + } + + get _itemProps(){ + return { + "action": this.props.action, + "itemId": this.props.itemId, + "theme": this.props.theme, + "startDate": this.props.startDate, + "endDate": this.props.endDate + } + } +} diff --git a/quickboard/static/src/quickboard/quickboard_item.xml b/quickboard/static/src/quickboard/quickboard_item.xml new file mode 100644 index 0000000..e3e7f80 --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_item.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/quickboard/static/src/quickboard/quickboard_item_base.js b/quickboard/static/src/quickboard/quickboard_item_base.js new file mode 100644 index 0000000..0c8e700 --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_item_base.js @@ -0,0 +1,73 @@ +/** @odoo-module **/ +import { Component, useState } from "@odoo/owl"; +import { user } from "@web/core/user"; +import { useService } from "@web/core/utils/hooks"; +import { standardQuickboardItemProps } from "./standard_quickboard_item_props" + +export class QuickboardItemBase extends Component { + static props = { + ...standardQuickboardItemProps + } + + setup() { + this.action = this.props.action; + this.itemId = this.props.itemId; + + this.quickboard = useState(useService("quickboard")); + + this.busService = this.env.services.bus_service; + this.busService.subscribe("quickboard_item_updated", ({ id }) => { + this.onMessage(id) + }); + } + + onMessage(id) { + console.log(id); + } + + showItemConfig(ev) { + this._showItemConfig(); + } + + _getSpinnerOpt(){ + var opts = { + "lines": 10, // The number of lines to draw + "length": 0, // The length of each line + "width": 2, // The line thickness + "radius": 4, // The radius of the inner circle + "scale": 4, // Scales overall size of the spinner + "corners": 1, // Corner roundness (0..1) + "speed": 0.7, // Rounds per second + "rotate": 0, // The rotation offset + "animation": 'spinner-line-fade-more', // The CSS animation name for the lines + "direction": 1, // 1: clockwise, -1: counterclockwise + "color": '#7a008a', // CSS color or array of colors + "fadeColor": 'transparent', // CSS color or array of colors + "top": '51%', // Top position relative to parent + "left": '50%', // Left position relative to parent + "shadow": '0 0 1px transparent', // Box-shadow for the lines + "zIndex": 2000000000, // The z-index (defaults to 2e9) + "className": 'spinner', // The CSS class to assign to the spinner + "position": 'absolute', // Element positioning + }; + + return opts; + } + + _showItemConfig(){ + var self = this; + this.action.doAction({ + 'type': 'ir.actions.act_window', + 'name': 'Quickboard Item', + 'res_model': 'quickboard.item', + 'res_id': self.itemId, + 'views': [[false, 'form']], + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'dialog_size': 'medium', + 'quick_edit': true + } + }); + } +} \ No newline at end of file diff --git a/quickboard/static/src/quickboard/quickboard_item_basic.js b/quickboard/static/src/quickboard/quickboard_item_basic.js new file mode 100644 index 0000000..b990181 --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_item_basic.js @@ -0,0 +1,102 @@ +/** @odoo-module **/ +import { useState, onMounted, useRef } from "@odoo/owl"; +import { parseFloat, parseInteger, parseMonetary } from "@web/views/fields/parsers"; +import { formatFloat, formatInteger, formatMonetary } from "@web/views/fields/formatters";; +import { QuickboardItemBase } from "./quickboard_item_base"; +import { getBackgroundColor, getForegroundColor } from "../core/colors"; + +export class QuickboardItemBasic extends QuickboardItemBase { + static template = "quickboard.QuickboardItemBasic"; + + setup(){ + super.setup(); + + this.gsItemRef = useRef("grid-stack-item"); + this.containerRef = useRef("container"); + this.spinner = new Spin.Spinner(this._getSpinnerOpt()); + + this.state = useState({ + "title": "", + "icon": "", + "valueFieldType": "", + "aggregateValue": "", + "value": "", + "aggregateFunction": "", + "textColor": "", + "backgroundColor": "", + + "theme": this.props.theme, + "startDate": this.props.startDate, + "endDate": this.props.endDate, + }); + + onMounted(async () => { + var target = this.gsItemRef.el; + this.spinner.spin(target); + await this.loadData( + this.props.itemId, + this.state.startDate, + this.state.endDate + ).then(() => {this.spinner.stop()}); + }) + } + + async onMessage(id) { + if (id == this.itemId){ + var target = this.gsItemRef.el; + if (this.containerRef.el){ + this.containerRef.el.classList.add("d-none"); + } + this.spinner.spin(target); + await this.loadData( + this.props.itemId, + this.state.startDate, + this.state.endDate + ).then(() => { + if (this.containerRef.el){ + this.containerRef.el.classList.remove("d-none"); + } + this.spinner.stop() + }); + } + } + + async loadData(itemId, startDate, endDate) { + const res = await this.quickboard.getQuickboardItem(itemId, startDate, endDate) + this.state.title = res.name; + this.state.icon = res.icon; + this.state.valueFieldType = res.value_field_type; + this.state.aggregateValue = res.aggregate_value; + this.state.value = this.getFormattedValue(); + this.state.aggregateFunction = this.aggregate_function; + + this.state.textColor = getForegroundColor(res.text_color, this.state.theme); + this.state.backgroundColor = getBackgroundColor(res.background_color, this.state.theme); + } + + getFormattedValue(){ + let val; + let val_formatted; + + switch (this.state.valueFieldType){ + case "integer": + val = parseInteger(String(this.state.aggregateValue)); + val_formatted = formatInteger(val); + break; + case "float": + val = parseFloat(String(this.state.aggregateValue)); + val_formatted = formatFloat(val); + break; + case "monetary": + val = parseMonetary(String(this.state.aggregateValue)); + val_formatted = formatMonetary(val); + break; + default: + if (Number.isSafeInteger(this.state.aggregateValue)) + val_formatted = formatInteger(this.state.aggregateValue); + else + val_formatted = formatFloat(this.state.aggregateValue); + } + return val_formatted + } +} \ No newline at end of file diff --git a/quickboard/static/src/quickboard/quickboard_item_basic.xml b/quickboard/static/src/quickboard/quickboard_item_basic.xml new file mode 100644 index 0000000..dc69f4c --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_item_basic.xml @@ -0,0 +1,24 @@ + + + +
+
+
+
+ +
+
+ + + +
+ +
+
+
+
+
+
+
+
diff --git a/quickboard/static/src/quickboard/quickboard_item_chart.js b/quickboard/static/src/quickboard/quickboard_item_chart.js new file mode 100644 index 0000000..e275f7c --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_item_chart.js @@ -0,0 +1,263 @@ +/** @odoo-module **/ +import { parseDate, parseDateTime } from "@web/core/l10n/dates"; +import { useState, useRef, onMounted } from "@odoo/owl"; +import { getBackgroundColor } from "../core/colors"; + +import { QuickboardItemBase } from "./quickboard_item_base"; + +export class QuickboardItemChart extends QuickboardItemBase { + static template = "quickboard.QuickboardItemChart"; + + setup() { + super.setup(); + + this.gsItemRef = useRef("grid-stack-item"); + this.spinner = new Spin.Spinner(this._getSpinnerOpt()); + + this.state = useState({ + "title": "", + "icon": "", + "chartType": "", + "data": "", + "valueFieldName": "", + "valueFieldType": "", + "dimensionFieldName": "", + "dimensionFieldType": "", + "datetimeGranularity": "", + "aggregateFunction": "", + "datetimeGranularity": "", + + "theme": this.props.theme, + "startDate": this.props.startDate, + "endDate": this.props.endDate, + }); + this.chartCanvasRef = useRef("chartCanvas"); + + onMounted(async () => { + var target = this.gsItemRef.el; + this.spinner.spin(target); + await this.loadData( + this.props.itemId, + this.state.startDate, + this.state.endDate + ).then(() => { + this.spinner.stop(); + }); + }); + } + + async onMessage(id) { + if (id == this.itemId) { + var target = this.gsItemRef.el; + this.spinner.spin(target); + if (this.chartCanvasRef.el) { + this.chartCanvasRef.el.style.display = "none"; + } + await this.loadData( + this.props.itemId, + this.state.startDate, + this.state.endDate + ).then(() => { + this.spinner.stop(); + }); + } + } + + async loadData(itemId, startDate, endDate) { + var target = this.gsItemRef.el; + this.spinner.spin(target); + + const res = await this.quickboard.getQuickboardItem( + itemId, + startDate, + endDate + ); + this.state.title = res.name; + this.state.icon = res.icon; + this.state.chartType = res.chart_type; + this.state.data = res.data; + this.state.valueFieldName = res.value_field_name; + this.state.valueFieldType = res.value_field_type; + this.state.dimensionFieldName = res.dimension_field_name; + this.state.dimensionFieldType = res.dimension_field_type; + this.state.groupFieldName = res.group_field_name; + this.state.groupFieldType = res.group_field_type; + this.state.aggregateFunction = res.aggregate_function; + this.state.datetimeGranularity = res.datetime_granularity; + + this.renderChart( + this.state.chartType, + this.state.data, + this.state.valueFieldName, + this.state.aggregateFunction, + this.state.dimensionFieldType, + this.state.datetimeGranularity + ); + } + + _getCircularChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity){ + if (chartType === "polar") { + chartType = "polarArea"; + } + + const chartData = []; + let labels = []; + let dataset_color = []; + data.forEach((element, index) => { + const lbl = Object.entries(element["dataset"]) + .filter(([k, v]) => !isNaN(k)) + .map(([k, v]) => v[0]); + + labels = Array.from(new Set(labels.concat(lbl))) + }); + + dataset_color = labels.map((_, index) => getBackgroundColor(index, this.state.theme)); + + data.forEach((element, index) => { + const dt = Object.entries(element["dataset"]) + .filter(([k, v]) => !isNaN(k)) + .map(([k, v]) => v[1]); + + chartData.push({ + label: element.label, + data: dt, + backgroundColor: dataset_color + }); + }); + + let chartConfig = { + type: chartType, + data: { + labels: labels, + datasets: chartData, + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: "bottom" + } + } + }, + }; + + return chartConfig; + } + + _getXYChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity){ + const chartData = []; + data.forEach((element, index) => { + const dt = Object.entries(element["dataset"]) + .filter(([k, v]) => !isNaN(k)) + .map(([k, v]) => { + let x_val; + let y_val; + let dateTimeDimension = ["date", "datetime"].includes(dimensionFieldType) + if (chartType === 'horizontal-bar'){ + x_val = v[1]; + y_val = dateTimeDimension ? parseDateTime(v[0]) : v[0] + } else { + x_val = dateTimeDimension ? parseDateTime(v[0]) : v[0]; + y_val = v[1] + } + + return Object.assign({}, { + x: x_val, + y: y_val + }) + } + ); + + const dataset_color = getBackgroundColor(index, this.state.theme); + + chartData.push({ + label: element.label, + data: dt, + backgroundColor: dataset_color, + borderColor: dataset_color, + borderWidth: 3, + cubicInterpolationMode: 'monotone', + }); + }); + + let x_axis_option = {}; + let y_axis_option = {} + if (["date", "datetime"].includes(dimensionFieldType)) { + if (chartType === 'horizontal-bar') { + chartType = 'bar'; + y_axis_option = { + indexAxis: 'y', + scales: { + y: { + type: "time", + time: { + unit: datetimeGranularity, + }, + }, + }, + } + } else { + x_axis_option = { + scales: { + x: { + type: "time", + time: { + unit: datetimeGranularity, + }, + }, + }, + }; + } + } else { + if (chartType === 'horizontal-bar') { + chartType = 'bar'; + y_axis_option = { + indexAxis: 'y', + } + } + } + + let chartConfig = { + type: chartType, + data: { + datasets: chartData + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: "bottom" + } + } + }, + }; + + Object.assign(chartConfig.options, x_axis_option); + Object.assign(chartConfig.options, y_axis_option); + + return chartConfig; + } + + renderChart(chartType, chartData, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity) { + let data = Object.entries(chartData) + .filter(([k, v]) => !isNaN(k)) + .map(([k, v]) => Object.assign({}, v)); + + let chartConfig = {} + if (["doughnut", "pie", "polar"].includes(chartType)) { + chartConfig = this._getCircularChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity) + } else { + chartConfig = this._getXYChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity) + } + + if (this.chartCanvasRef.el) { + const ctx = this.chartCanvasRef.el.getContext("2d"); + if (this.chart) { + this.chart.destroy(); + } + this.chart = new Chart(ctx, chartConfig); + } + } +} diff --git a/quickboard/static/src/quickboard/quickboard_item_chart.xml b/quickboard/static/src/quickboard/quickboard_item_chart.xml new file mode 100644 index 0000000..54c776a --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_item_chart.xml @@ -0,0 +1,19 @@ + + + +
+
+
+ + +
+ +
+
+
+ +
+
+
+
+
\ No newline at end of file diff --git a/quickboard/static/src/quickboard/quickboard_item_list.js b/quickboard/static/src/quickboard/quickboard_item_list.js new file mode 100644 index 0000000..c5094db --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_item_list.js @@ -0,0 +1,112 @@ +/** @odoo-module **/ +import { useState, useRef, onMounted } from "@odoo/owl"; + +import { parseFloat, parseInteger, parseMonetary } from "@web/views/fields/parsers"; +import { formatFloat, formatInteger, formatMonetary } from "@web/views/fields/formatters";; + +import { QuickboardItemBase } from "./quickboard_item_base"; + +export class QuickboardItemList extends QuickboardItemBase { + static template = "quickboard.QuickboardItemList"; + + setup() { + super.setup(); + + this.gsItemRef = useRef("grid-stack-item"); + this.containerRef = useRef("container"); + this.spinner = new Spin.Spinner(this._getSpinnerOpt()); + + this.state = useState({ + "title": "", + "icon": "", + "data": "", + "valueFieldName": "", + "valueFieldType": "", + "aggregateFunction": "", + + "dimensionFieldName": "", + "dimensionFieldType": "", + + "startDate": this.props.startDate, + "endDate": this.props.endDate, + }); + + onMounted(async () => { + var target = this.gsItemRef.el; + this.spinner.spin(target); + await this.loadData( + this.props.itemId, + this.state.startDate, + this.state.endDate + ).then(() => { + this.spinner.stop(); + }); + }); + } + + async onMessage(id) { + if (id == this.itemId) { + var target = this.gsItemRef.el; + if (this.containerRef.el){ + this.containerRef.el.classList.add("d-none"); + } + this.spinner.spin(target); + await this.loadData( + this.props.itemId, + this.state.startDate, + this.state.endDate + ).then(() => { + if (this.containerRef.el){ + this.containerRef.el.classList.remove("d-none"); + } + this.spinner.stop(); + }); + } + } + + async loadData(itemId, startDate, endDate) { + var target = this.gsItemRef.el; + this.spinner.spin(target); + + const res = await this.quickboard.getQuickboardItem( + itemId, + startDate, + endDate + ); + this.state.title = res.name; + this.state.icon = res.icon; + this.state.data = res.data; + this.state.valueFieldName = res.value_field_name; + this.state.valueFieldType = res.value_field_type; + this.state.dimensionFieldName = res.dimension_field_name; + this.state.dimensionFieldType = res.dimension_field_type; + this.state.aggregateFunction = res.aggregate_function; + + } + + formatValue(value){ + let val; + let val_formatted; + + switch (this.state.valueFieldType){ + case "integer": + val = parseInteger(String(value)); + val_formatted = formatInteger(val); + break; + case "float": + val = parseFloat(String(value)); + val_formatted = formatFloat(val); + break; + case "monetary": + val = parseMonetary(String(value)); + val_formatted = formatMonetary(val); + break; + default: + if (Number.isSafeInteger(value)) + val_formatted = formatInteger(value); + else + val_formatted = formatFloat(value); + } + return val_formatted + } +} diff --git a/quickboard/static/src/quickboard/quickboard_item_list.xml b/quickboard/static/src/quickboard/quickboard_item_list.xml new file mode 100644 index 0000000..fcba3ea --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_item_list.xml @@ -0,0 +1,41 @@ + + + +
+
+
+ + +
+ +
+
+
+
+ + + + + + + + + + + + + + +
+
+ +
+ No data to display. +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/quickboard/static/src/quickboard/quickboard_service.js b/quickboard/static/src/quickboard/quickboard_service.js new file mode 100644 index 0000000..50463c9 --- /dev/null +++ b/quickboard/static/src/quickboard/quickboard_service.js @@ -0,0 +1,58 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { reactive } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; + +const quickboardService = { + start(env, services) { + const quickboard = reactive({ + items: {}, + isReady: false + }); + + async function getQuickboardItemDefs() { + quickboard.isReady = false; + quickboard.items = {}; + const updates = await rpc("/quickboard/item_defs",{}); + Object.assign(quickboard.items, updates); + quickboard.isReady = true; + }; + + async function getQuickboardItem(itemId, startDate, endDate) { + return await rpc("/quickboard/item",{ + item_id: itemId, + start_date: startDate.toSQLDate(), + end_date: endDate.toSQLDate() + }); + }; + + async function saveLayout(layout){ + await rpc("/quickboard/save_layout",{ + "layout": layout + }); + }; + + async function saveFilter(startDate, endDate) { + return await rpc("/quickboard/save_filter",{ + start_date: startDate.toSQLDate(), + end_date: endDate.toSQLDate() + }); + }; + + async function saveTheme(theme) { + return await rpc("/quickboard/save_theme",{ + theme: theme + }); + }; + + quickboard.saveTheme = saveTheme; + quickboard.saveFilter = saveFilter; + quickboard.getQuickboardItemDefs = getQuickboardItemDefs; + quickboard.getQuickboardItem = getQuickboardItem; + quickboard.saveLayout = saveLayout; + return quickboard; + } +}; + +registry.category("services").add("quickboard", quickboardService); diff --git a/quickboard/static/src/quickboard/standard_quickboard_item_props.js b/quickboard/static/src/quickboard/standard_quickboard_item_props.js new file mode 100644 index 0000000..25f3d0a --- /dev/null +++ b/quickboard/static/src/quickboard/standard_quickboard_item_props.js @@ -0,0 +1,10 @@ +/** @odoo-module **/ + +export const standardQuickboardItemProps = { + action: { type: Object }, + itemId: { type: Number }, + theme: { type: String }, + startDate: { type: luxon.DateTime }, + endDate: { type: luxon.DateTime }, +}; + diff --git a/quickboard/static/src/quickboard_loader.js b/quickboard/static/src/quickboard_loader.js new file mode 100644 index 0000000..e46c043 --- /dev/null +++ b/quickboard/static/src/quickboard_loader.js @@ -0,0 +1,20 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { LazyComponent } from "@web/core/assets"; +import { Component, xml } from "@odoo/owl"; +import { standardActionServiceProps } from "@web/webclient/actions/action_service"; + +class QuickboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; + static props = { + ...standardActionServiceProps, + props: { type: Object, optional: true }, + Component: { type: Function, optional: true }, + }; +} + +registry.category("actions").add("quickboard", QuickboardLoader); diff --git a/quickboard/static/src/views/fields/qb_color_picker/qb_color_picker_field.js b/quickboard/static/src/views/fields/qb_color_picker/qb_color_picker_field.js new file mode 100644 index 0000000..d35d869 --- /dev/null +++ b/quickboard/static/src/views/fields/qb_color_picker/qb_color_picker_field.js @@ -0,0 +1,63 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { QbColorList } from "../../../core/qb_color_list/qb_color_list"; + +import { user } from "@web/core/user"; +import { Component } from "@odoo/owl"; + +import { QUICKBOARD_BG_COLORS, QUICKBOARD_FG_COLORS } from "../../../core/colors"; + +export class QbColorPickerField extends Component { + static template = "quickboard.QbColorPickerField"; + static components = { + QbColorList, + }; + static props = { + ...standardFieldProps, + canToggle: { type: Boolean }, + mode: { type: String }, + }; + + currentColorPalette() { + let theme = user.settings.quickboard_theme; + if (this.props.mode === "foreground"){ + return QUICKBOARD_FG_COLORS[theme]; + } else { + return QUICKBOARD_BG_COLORS[theme]; + } + } + + get isExpanded() { + return !this.props.canToggle && !this.props.readonly; + } + + switchColor(colorIndex) { + this.props.record.update({ [this.props.name]: colorIndex }); + } +} + +export const qbColorPickerField = { + component: QbColorPickerField, + supportedTypes: ["integer"], + supportedOptions: [ + { + label: _t("Mode"), + name: "mode", + type: "selection", + choices: [ + { label: "Foreground", value: "fg" }, + { label: "Background", value: "bg" }, + ], + default: "bg", + }, + ], + extractProps: ({ options, viewType }) => ({ + canToggle: viewType !== "list", + mode: options.mode, + }), +}; + +registry.category("fields").add("qb_color_picker", qbColorPickerField); diff --git a/quickboard/static/src/views/fields/qb_color_picker/qb_color_picker_field.xml b/quickboard/static/src/views/fields/qb_color_picker/qb_color_picker_field.xml new file mode 100644 index 0000000..53f280e --- /dev/null +++ b/quickboard/static/src/views/fields/qb_color_picker/qb_color_picker_field.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/quickboard/static/src/views/fields/qb_icon_picker/fa_icons.js b/quickboard/static/src/views/fields/qb_icon_picker/fa_icons.js new file mode 100644 index 0000000..519ca26 --- /dev/null +++ b/quickboard/static/src/views/fields/qb_icon_picker/fa_icons.js @@ -0,0 +1,2702 @@ +export const fa_icons = [ + { + label: "Glass", + value: "fa-glass" + }, + { + label: "Music", + value: "fa-music" + }, + { + label: "Search", + value: "fa-search" + }, + { + label: "Envelope Outlined", + value: "fa-envelope-o" + }, + { + label: "Heart", + value: "fa-heart" + }, + { + label: "Star", + value: "fa-star" + }, + { + label: "Star Outlined", + value: "fa-star-o" + }, + { + label: "User", + value: "fa-user" + }, + { + label: "Film", + value: "fa-film" + }, + { + label: "th-large", + value: "fa-th-large" + }, + { + label: "th", + value: "fa-th" + }, + { + label: "th-list", + value: "fa-th-list" + }, + { + label: "Check", + value: "fa-check" + }, + { + label: "Times", + value: "fa-times" + }, + { + label: "Search Plus", + value: "fa-search-plus" + }, + { + label: "Search Minus", + value: "fa-search-minus" + }, + { + label: "Power Off", + value: "fa-power-off" + }, + { + label: "signal", + value: "fa-signal" + }, + { + label: "cog", + value: "fa-cog" + }, + { + label: "Trash Outlined", + value: "fa-trash-o" + }, + { + label: "home", + value: "fa-home" + }, + { + label: "File Outlined", + value: "fa-file-o" + }, + { + label: "Clock Outlined", + value: "fa-clock-o" + }, + { + label: "road", + value: "fa-road" + }, + { + label: "Download", + value: "fa-download" + }, + { + label: "Arrow Circle Outlined Down", + value: "fa-arrow-circle-o-down" + }, + { + label: "Arrow Circle Outlined Up", + value: "fa-arrow-circle-o-up" + }, + { + label: "inbox", + value: "fa-inbox" + }, + { + label: "Play Circle Outlined", + value: "fa-play-circle-o" + }, + { + label: "Repeat", + value: "fa-repeat" + }, + { + label: "refresh", + value: "fa-refresh" + }, + { + label: "list-alt", + value: "fa-list-alt" + }, + { + label: "lock", + value: "fa-lock" + }, + { + label: "flag", + value: "fa-flag" + }, + { + label: "headphones", + value: "fa-headphones" + }, + { + label: "volume-off", + value: "fa-volume-off" + }, + { + label: "volume-down", + value: "fa-volume-down" + }, + { + label: "volume-up", + value: "fa-volume-up" + }, + { + label: "qrcode", + value: "fa-qrcode" + }, + { + label: "barcode", + value: "fa-barcode" + }, + { + label: "tag", + value: "fa-tag" + }, + { + label: "tags", + value: "fa-tags" + }, + { + label: "book", + value: "fa-book" + }, + { + label: "bookmark", + value: "fa-bookmark" + }, + { + label: "print", + value: "fa-print" + }, + { + label: "camera", + value: "fa-camera" + }, + { + label: "font", + value: "fa-font" + }, + { + label: "bold", + value: "fa-bold" + }, + { + label: "italic", + value: "fa-italic" + }, + { + label: "text-height", + value: "fa-text-height" + }, + { + label: "text-width", + value: "fa-text-width" + }, + { + label: "align-left", + value: "fa-align-left" + }, + { + label: "align-center", + value: "fa-align-center" + }, + { + label: "align-right", + value: "fa-align-right" + }, + { + label: "align-justify", + value: "fa-align-justify" + }, + { + label: "list", + value: "fa-list" + }, + { + label: "Outdent", + value: "fa-outdent" + }, + { + label: "Indent", + value: "fa-indent" + }, + { + label: "Video Camera", + value: "fa-video-camera" + }, + { + label: "Picture Outlined", + value: "fa-picture-o" + }, + { + label: "pencil", + value: "fa-pencil" + }, + { + label: "map-marker", + value: "fa-map-marker" + }, + { + label: "adjust", + value: "fa-adjust" + }, + { + label: "tint", + value: "fa-tint" + }, + { + label: "Pencil Square Outlined", + value: "fa-pencil-square-o" + }, + { + label: "Share Square Outlined", + value: "fa-share-square-o" + }, + { + label: "Check Square Outlined", + value: "fa-check-square-o" + }, + { + label: "Arrows", + value: "fa-arrows" + }, + { + label: "step-backward", + value: "fa-step-backward" + }, + { + label: "fast-backward", + value: "fa-fast-backward" + }, + { + label: "backward", + value: "fa-backward" + }, + { + label: "play", + value: "fa-play" + }, + { + label: "pause", + value: "fa-pause" + }, + { + label: "stop", + value: "fa-stop" + }, + { + label: "forward", + value: "fa-forward" + }, + { + label: "fast-forward", + value: "fa-fast-forward" + }, + { + label: "step-forward", + value: "fa-step-forward" + }, + { + label: "eject", + value: "fa-eject" + }, + { + label: "chevron-left", + value: "fa-chevron-left" + }, + { + label: "chevron-right", + value: "fa-chevron-right" + }, + { + label: "Plus Circle", + value: "fa-plus-circle" + }, + { + label: "Minus Circle", + value: "fa-minus-circle" + }, + { + label: "Times Circle", + value: "fa-times-circle" + }, + { + label: "Check Circle", + value: "fa-check-circle" + }, + { + label: "Question Circle", + value: "fa-question-circle" + }, + { + label: "Info Circle", + value: "fa-info-circle" + }, + { + label: "Crosshairs", + value: "fa-crosshairs" + }, + { + label: "Times Circle Outlined", + value: "fa-times-circle-o" + }, + { + label: "Check Circle Outlined", + value: "fa-check-circle-o" + }, + { + label: "ban", + value: "fa-ban" + }, + { + label: "arrow-left", + value: "fa-arrow-left" + }, + { + label: "arrow-right", + value: "fa-arrow-right" + }, + { + label: "arrow-up", + value: "fa-arrow-up" + }, + { + label: "arrow-down", + value: "fa-arrow-down" + }, + { + label: "Share", + value: "fa-share" + }, + { + label: "Expand", + value: "fa-expand" + }, + { + label: "Compress", + value: "fa-compress" + }, + { + label: "plus", + value: "fa-plus" + }, + { + label: "minus", + value: "fa-minus" + }, + { + label: "asterisk", + value: "fa-asterisk" + }, + { + label: "Exclamation Circle", + value: "fa-exclamation-circle" + }, + { + label: "gift", + value: "fa-gift" + }, + { + label: "leaf", + value: "fa-leaf" + }, + { + label: "fire", + value: "fa-fire" + }, + { + label: "Eye", + value: "fa-eye" + }, + { + label: "Eye Slash", + value: "fa-eye-slash" + }, + { + label: "Exclamation Triangle", + value: "fa-exclamation-triangle" + }, + { + label: "plane", + value: "fa-plane" + }, + { + label: "calendar", + value: "fa-calendar" + }, + { + label: "random", + value: "fa-random" + }, + { + label: "comment", + value: "fa-comment" + }, + { + label: "magnet", + value: "fa-magnet" + }, + { + label: "chevron-up", + value: "fa-chevron-up" + }, + { + label: "chevron-down", + value: "fa-chevron-down" + }, + { + label: "retweet", + value: "fa-retweet" + }, + { + label: "shopping-cart", + value: "fa-shopping-cart" + }, + { + label: "Folder", + value: "fa-folder" + }, + { + label: "Folder Open", + value: "fa-folder-open" + }, + { + label: "Arrows Vertical", + value: "fa-arrows-v" + }, + { + label: "Arrows Horizontal", + value: "fa-arrows-h" + }, + { + label: "Bar Chart", + value: "fa-bar-chart" + }, + { + label: "Twitter Square", + value: "fa-twitter-square" + }, + { + label: "Facebook Square", + value: "fa-facebook-square" + }, + { + label: "camera-retro", + value: "fa-camera-retro" + }, + { + label: "key", + value: "fa-key" + }, + { + label: "cogs", + value: "fa-cogs" + }, + { + label: "comments", + value: "fa-comments" + }, + { + label: "Thumbs Up Outlined", + value: "fa-thumbs-o-up" + }, + { + label: "Thumbs Down Outlined", + value: "fa-thumbs-o-down" + }, + { + label: "star-half", + value: "fa-star-half" + }, + { + label: "Heart Outlined", + value: "fa-heart-o" + }, + { + label: "Sign Out", + value: "fa-sign-out" + }, + { + label: "LinkedIn Square", + value: "fa-linkedin-square" + }, + { + label: "Thumb Tack", + value: "fa-thumb-tack" + }, + { + label: "External Link", + value: "fa-external-link" + }, + { + label: "Sign In", + value: "fa-sign-in" + }, + { + label: "trophy", + value: "fa-trophy" + }, + { + label: "GitHub Square", + value: "fa-github-square" + }, + { + label: "Upload", + value: "fa-upload" + }, + { + label: "Lemon Outlined", + value: "fa-lemon-o" + }, + { + label: "Phone", + value: "fa-phone" + }, + { + label: "Square Outlined", + value: "fa-square-o" + }, + { + label: "Bookmark Outlined", + value: "fa-bookmark-o" + }, + { + label: "Phone Square", + value: "fa-phone-square" + }, + { + label: "Twitter", + value: "fa-twitter" + }, + { + label: "Facebook", + value: "fa-facebook" + }, + { + label: "GitHub", + value: "fa-github" + }, + { + label: "unlock", + value: "fa-unlock" + }, + { + label: "credit-card", + value: "fa-credit-card" + }, + { + label: "rss", + value: "fa-rss" + }, + { + label: "HDD", + value: "fa-hdd-o" + }, + { + label: "bullhorn", + value: "fa-bullhorn" + }, + { + label: "bell", + value: "fa-bell" + }, + { + label: "certificate", + value: "fa-certificate" + }, + { + label: "Hand Outlined Right", + value: "fa-hand-o-right" + }, + { + label: "Hand Outlined Left", + value: "fa-hand-o-left" + }, + { + label: "Hand Outlined Up", + value: "fa-hand-o-up" + }, + { + label: "Hand Outlined Down", + value: "fa-hand-o-down" + }, + { + label: "Arrow Circle Left", + value: "fa-arrow-circle-left" + }, + { + label: "Arrow Circle Right", + value: "fa-arrow-circle-right" + }, + { + label: "Arrow Circle Up", + value: "fa-arrow-circle-up" + }, + { + label: "Arrow Circle Down", + value: "fa-arrow-circle-down" + }, + { + label: "Globe", + value: "fa-globe" + }, + { + label: "Wrench", + value: "fa-wrench" + }, + { + label: "Tasks", + value: "fa-tasks" + }, + { + label: "Filter", + value: "fa-filter" + }, + { + label: "Briefcase", + value: "fa-briefcase" + }, + { + label: "Arrows Alt", + value: "fa-arrows-alt" + }, + { + label: "Users", + value: "fa-users" + }, + { + label: "Link", + value: "fa-link" + }, + { + label: "Cloud", + value: "fa-cloud" + }, + { + label: "Flask", + value: "fa-flask" + }, + { + label: "Scissors", + value: "fa-scissors" + }, + { + label: "Files Outlined", + value: "fa-files-o" + }, + { + label: "Paperclip", + value: "fa-paperclip" + }, + { + label: "Floppy Outlined", + value: "fa-floppy-o" + }, + { + label: "Square", + value: "fa-square" + }, + { + label: "Bars", + value: "fa-bars" + }, + { + label: "list-ul", + value: "fa-list-ul" + }, + { + label: "list-ol", + value: "fa-list-ol" + }, + { + label: "Strikethrough", + value: "fa-strikethrough" + }, + { + label: "Underline", + value: "fa-underline" + }, + { + label: "table", + value: "fa-table" + }, + { + label: "magic", + value: "fa-magic" + }, + { + label: "truck", + value: "fa-truck" + }, + { + label: "Pinterest", + value: "fa-pinterest" + }, + { + label: "Pinterest Square", + value: "fa-pinterest-square" + }, + { + label: "Google Plus Square", + value: "fa-google-plus-square" + }, + { + label: "Google Plus", + value: "fa-google-plus" + }, + { + label: "Money", + value: "fa-money" + }, + { + label: "Caret Down", + value: "fa-caret-down" + }, + { + label: "Caret Up", + value: "fa-caret-up" + }, + { + label: "Caret Left", + value: "fa-caret-left" + }, + { + label: "Caret Right", + value: "fa-caret-right" + }, + { + label: "Columns", + value: "fa-columns" + }, + { + label: "Sort", + value: "fa-sort" + }, + { + label: "Sort Descending", + value: "fa-sort-desc" + }, + { + label: "Sort Ascending", + value: "fa-sort-asc" + }, + { + label: "Envelope", + value: "fa-envelope" + }, + { + label: "LinkedIn", + value: "fa-linkedin" + }, + { + label: "Undo", + value: "fa-undo" + }, + { + label: "Gavel", + value: "fa-gavel" + }, + { + label: "Tachometer", + value: "fa-tachometer" + }, + { + label: "comment-o", + value: "fa-comment-o" + }, + { + label: "comments-o", + value: "fa-comments-o" + }, + { + label: "Lightning Bolt", + value: "fa-bolt" + }, + { + label: "Sitemap", + value: "fa-sitemap" + }, + { + label: "Umbrella", + value: "fa-umbrella" + }, + { + label: "Clipboard", + value: "fa-clipboard" + }, + { + label: "Lightbulb Outlined", + value: "fa-lightbulb-o" + }, + { + label: "Exchange", + value: "fa-exchange" + }, + { + label: "Cloud Download", + value: "fa-cloud-download" + }, + { + label: "Cloud Upload", + value: "fa-cloud-upload" + }, + { + label: "user-md", + value: "fa-user-md" + }, + { + label: "Stethoscope", + value: "fa-stethoscope" + }, + { + label: "Suitcase", + value: "fa-suitcase" + }, + { + label: "Bell Outlined", + value: "fa-bell-o" + }, + { + label: "Coffee", + value: "fa-coffee" + }, + { + label: "Cutlery", + value: "fa-cutlery" + }, + { + label: "File Text Outlined", + value: "fa-file-text-o" + }, + { + label: "Building Outlined", + value: "fa-building-o" + }, + { + label: "hospital Outlined", + value: "fa-hospital-o" + }, + { + label: "ambulance", + value: "fa-ambulance" + }, + { + label: "medkit", + value: "fa-medkit" + }, + { + label: "fighter-jet", + value: "fa-fighter-jet" + }, + { + label: "beer", + value: "fa-beer" + }, + { + label: "H Square", + value: "fa-h-square" + }, + { + label: "Plus Square", + value: "fa-plus-square" + }, + { + label: "Angle Double Left", + value: "fa-angle-double-left" + }, + { + label: "Angle Double Right", + value: "fa-angle-double-right" + }, + { + label: "Angle Double Up", + value: "fa-angle-double-up" + }, + { + label: "Angle Double Down", + value: "fa-angle-double-down" + }, + { + label: "angle-left", + value: "fa-angle-left" + }, + { + label: "angle-right", + value: "fa-angle-right" + }, + { + label: "angle-up", + value: "fa-angle-up" + }, + { + label: "angle-down", + value: "fa-angle-down" + }, + { + label: "Desktop", + value: "fa-desktop" + }, + { + label: "Laptop", + value: "fa-laptop" + }, + { + label: "tablet", + value: "fa-tablet" + }, + { + label: "Mobile Phone", + value: "fa-mobile" + }, + { + label: "Circle Outlined", + value: "fa-circle-o" + }, + { + label: "quote-left", + value: "fa-quote-left" + }, + { + label: "quote-right", + value: "fa-quote-right" + }, + { + label: "Spinner", + value: "fa-spinner" + }, + { + label: "Circle", + value: "fa-circle" + }, + { + label: "Reply", + value: "fa-reply" + }, + { + label: "GitHub Alt", + value: "fa-github-alt" + }, + { + label: "Folder Outlined", + value: "fa-folder-o" + }, + { + label: "Folder Open Outlined", + value: "fa-folder-open-o" + }, + { + label: "Smile Outlined", + value: "fa-smile-o" + }, + { + label: "Frown Outlined", + value: "fa-frown-o" + }, + { + label: "Meh Outlined", + value: "fa-meh-o" + }, + { + label: "Gamepad", + value: "fa-gamepad" + }, + { + label: "Keyboard Outlined", + value: "fa-keyboard-o" + }, + { + label: "Flag Outlined", + value: "fa-flag-o" + }, + { + label: "flag-checkered", + value: "fa-flag-checkered" + }, + { + label: "Terminal", + value: "fa-terminal" + }, + { + label: "Code", + value: "fa-code" + }, + { + label: "reply-all", + value: "fa-reply-all" + }, + { + label: "Star Half Outlined", + value: "fa-star-half-o" + }, + { + label: "location-arrow", + value: "fa-location-arrow" + }, + { + label: "crop", + value: "fa-crop" + }, + { + label: "code-fork", + value: "fa-code-fork" + }, + { + label: "Chain Broken", + value: "fa-chain-broken" + }, + { + label: "Question", + value: "fa-question" + }, + { + label: "Info", + value: "fa-info" + }, + { + label: "exclamation", + value: "fa-exclamation" + }, + { + label: "superscript", + value: "fa-superscript" + }, + { + label: "subscript", + value: "fa-subscript" + }, + { + label: "eraser", + value: "fa-eraser" + }, + { + label: "Puzzle Piece", + value: "fa-puzzle-piece" + }, + { + label: "microphone", + value: "fa-microphone" + }, + { + label: "Microphone Slash", + value: "fa-microphone-slash" + }, + { + label: "shield", + value: "fa-shield" + }, + { + label: "calendar-o", + value: "fa-calendar-o" + }, + { + label: "fire-extinguisher", + value: "fa-fire-extinguisher" + }, + { + label: "rocket", + value: "fa-rocket" + }, + { + label: "MaxCDN", + value: "fa-maxcdn" + }, + { + label: "Chevron Circle Left", + value: "fa-chevron-circle-left" + }, + { + label: "Chevron Circle Right", + value: "fa-chevron-circle-right" + }, + { + label: "Chevron Circle Up", + value: "fa-chevron-circle-up" + }, + { + label: "Chevron Circle Down", + value: "fa-chevron-circle-down" + }, + { + label: "HTML 5 Logo", + value: "fa-html5" + }, + { + label: "CSS 3 Logo", + value: "fa-css3" + }, + { + label: "Anchor", + value: "fa-anchor" + }, + { + label: "Unlock Alt", + value: "fa-unlock-alt" + }, + { + label: "Bullseye", + value: "fa-bullseye" + }, + { + label: "Ellipsis Horizontal", + value: "fa-ellipsis-h" + }, + { + label: "Ellipsis Vertical", + value: "fa-ellipsis-v" + }, + { + label: "RSS Square", + value: "fa-rss-square" + }, + { + label: "Play Circle", + value: "fa-play-circle" + }, + { + label: "Ticket", + value: "fa-ticket" + }, + { + label: "Minus Square", + value: "fa-minus-square" + }, + { + label: "Minus Square Outlined", + value: "fa-minus-square-o" + }, + { + label: "Level Up", + value: "fa-level-up" + }, + { + label: "Level Down", + value: "fa-level-down" + }, + { + label: "Check Square", + value: "fa-check-square" + }, + { + label: "Pencil Square", + value: "fa-pencil-square" + }, + { + label: "External Link Square", + value: "fa-external-link-square" + }, + { + label: "Share Square", + value: "fa-share-square" + }, + { + label: "Compass", + value: "fa-compass" + }, + { + label: "Caret Square Outlined Down", + value: "fa-caret-square-o-down" + }, + { + label: "Caret Square Outlined Up", + value: "fa-caret-square-o-up" + }, + { + label: "Caret Square Outlined Right", + value: "fa-caret-square-o-right" + }, + { + label: "Euro (EUR)", + value: "fa-eur" + }, + { + label: "GBP", + value: "fa-gbp" + }, + { + label: "US Dollar", + value: "fa-usd" + }, + { + label: "Indian Rupee (INR)", + value: "fa-inr" + }, + { + label: "Japanese Yen (JPY)", + value: "fa-jpy" + }, + { + label: "Russian Ruble (RUB)", + value: "fa-rub" + }, + { + label: "Korean Won (KRW)", + value: "fa-krw" + }, + { + label: "Bitcoin (BTC)", + value: "fa-btc" + }, + { + label: "File", + value: "fa-file" + }, + { + label: "File Text", + value: "fa-file-text" + }, + { + label: "Sort Alpha Ascending", + value: "fa-sort-alpha-asc" + }, + { + label: "Sort Alpha Descending", + value: "fa-sort-alpha-desc" + }, + { + label: "Sort Amount Ascending", + value: "fa-sort-amount-asc" + }, + { + label: "Sort Amount Descending", + value: "fa-sort-amount-desc" + }, + { + label: "Sort Numeric Ascending", + value: "fa-sort-numeric-asc" + }, + { + label: "Sort Numeric Descending", + value: "fa-sort-numeric-desc" + }, + { + label: "thumbs-up", + value: "fa-thumbs-up" + }, + { + label: "thumbs-down", + value: "fa-thumbs-down" + }, + { + label: "YouTube Square", + value: "fa-youtube-square" + }, + { + label: "YouTube", + value: "fa-youtube" + }, + { + label: "Xing", + value: "fa-xing" + }, + { + label: "Xing Square", + value: "fa-xing-square" + }, + { + label: "YouTube Play", + value: "fa-youtube-play" + }, + { + label: "Dropbox", + value: "fa-dropbox" + }, + { + label: "Stack Overflow", + value: "fa-stack-overflow" + }, + { + label: "Instagram", + value: "fa-instagram" + }, + { + label: "Flickr", + value: "fa-flickr" + }, + { + label: "App.net", + value: "fa-adn" + }, + { + label: "Bitbucket", + value: "fa-bitbucket" + }, + { + label: "Bitbucket Square", + value: "fa-bitbucket-square" + }, + { + label: "Tumblr", + value: "fa-tumblr" + }, + { + label: "Tumblr Square", + value: "fa-tumblr-square" + }, + { + label: "Long Arrow Down", + value: "fa-long-arrow-down" + }, + { + label: "Long Arrow Up", + value: "fa-long-arrow-up" + }, + { + label: "Long Arrow Left", + value: "fa-long-arrow-left" + }, + { + label: "Long Arrow Right", + value: "fa-long-arrow-right" + }, + { + label: "Apple", + value: "fa-apple" + }, + { + label: "Windows", + value: "fa-windows" + }, + { + label: "Android", + value: "fa-android" + }, + { + label: "Linux", + value: "fa-linux" + }, + { + label: "Dribbble", + value: "fa-dribbble" + }, + { + label: "Skype", + value: "fa-skype" + }, + { + label: "Foursquare", + value: "fa-foursquare" + }, + { + label: "Trello", + value: "fa-trello" + }, + { + label: "Female", + value: "fa-female" + }, + { + label: "Male", + value: "fa-male" + }, + { + label: "Gratipay (Gittip)", + value: "fa-gratipay" + }, + { + label: "Sun Outlined", + value: "fa-sun-o" + }, + { + label: "Moon Outlined", + value: "fa-moon-o" + }, + { + label: "Archive", + value: "fa-archive" + }, + { + label: "Bug", + value: "fa-bug" + }, + { + label: "VK", + value: "fa-vk" + }, + { + label: "Weibo", + value: "fa-weibo" + }, + { + label: "Renren", + value: "fa-renren" + }, + { + label: "Pagelines", + value: "fa-pagelines" + }, + { + label: "Stack Exchange", + value: "fa-stack-exchange" + }, + { + label: "Arrow Circle Outlined Right", + value: "fa-arrow-circle-o-right" + }, + { + label: "Arrow Circle Outlined Left", + value: "fa-arrow-circle-o-left" + }, + { + label: "Caret Square Outlined Left", + value: "fa-caret-square-o-left" + }, + { + label: "Dot Circle Outlined", + value: "fa-dot-circle-o" + }, + { + label: "Wheelchair", + value: "fa-wheelchair" + }, + { + label: "Vimeo Square", + value: "fa-vimeo-square" + }, + { + label: "Turkish Lira (TRY)", + value: "fa-try" + }, + { + label: "Plus Square Outlined", + value: "fa-plus-square-o" + }, + { + label: "Space Shuttle", + value: "fa-space-shuttle" + }, + { + label: "Slack Logo", + value: "fa-slack" + }, + { + label: "Envelope Square", + value: "fa-envelope-square" + }, + { + label: "WordPress Logo", + value: "fa-wordpress" + }, + { + label: "OpenID", + value: "fa-openid" + }, + { + label: "University", + value: "fa-university" + }, + { + label: "Graduation Cap", + value: "fa-graduation-cap" + }, + { + label: "Yahoo Logo", + value: "fa-yahoo" + }, + { + label: "Google Logo", + value: "fa-google" + }, + { + label: "reddit Logo", + value: "fa-reddit" + }, + { + label: "reddit Square", + value: "fa-reddit-square" + }, + { + label: "StumbleUpon Circle", + value: "fa-stumbleupon-circle" + }, + { + label: "StumbleUpon Logo", + value: "fa-stumbleupon" + }, + { + label: "Delicious Logo", + value: "fa-delicious" + }, + { + label: "Digg Logo", + value: "fa-digg" + }, + { + label: "Pied Piper PP Logo (Old)", + value: "fa-pied-piper-pp" + }, + { + label: "Pied Piper Alternate Logo", + value: "fa-pied-piper-alt" + }, + { + label: "Drupal Logo", + value: "fa-drupal" + }, + { + label: "Joomla Logo", + value: "fa-joomla" + }, + { + label: "Language", + value: "fa-language" + }, + { + label: "Fax", + value: "fa-fax" + }, + { + label: "Building", + value: "fa-building" + }, + { + label: "Child", + value: "fa-child" + }, + { + label: "Paw", + value: "fa-paw" + }, + { + label: "spoon", + value: "fa-spoon" + }, + { + label: "Cube", + value: "fa-cube" + }, + { + label: "Cubes", + value: "fa-cubes" + }, + { + label: "Behance", + value: "fa-behance" + }, + { + label: "Behance Square", + value: "fa-behance-square" + }, + { + label: "Steam", + value: "fa-steam" + }, + { + label: "Steam Square", + value: "fa-steam-square" + }, + { + label: "Recycle", + value: "fa-recycle" + }, + { + label: "Car", + value: "fa-car" + }, + { + label: "Taxi", + value: "fa-taxi" + }, + { + label: "Tree", + value: "fa-tree" + }, + { + label: "Spotify", + value: "fa-spotify" + }, + { + label: "deviantART", + value: "fa-deviantart" + }, + { + label: "SoundCloud", + value: "fa-soundcloud" + }, + { + label: "Database", + value: "fa-database" + }, + { + label: "PDF File Outlined", + value: "fa-file-pdf-o" + }, + { + label: "Word File Outlined", + value: "fa-file-word-o" + }, + { + label: "Excel File Outlined", + value: "fa-file-excel-o" + }, + { + label: "Powerpoint File Outlined", + value: "fa-file-powerpoint-o" + }, + { + label: "Image File Outlined", + value: "fa-file-image-o" + }, + { + label: "Archive File Outlined", + value: "fa-file-archive-o" + }, + { + label: "Audio File Outlined", + value: "fa-file-audio-o" + }, + { + label: "Video File Outlined", + value: "fa-file-video-o" + }, + { + label: "Code File Outlined", + value: "fa-file-code-o" + }, + { + label: "Vine", + value: "fa-vine" + }, + { + label: "Codepen", + value: "fa-codepen" + }, + { + label: "jsFiddle", + value: "fa-jsfiddle" + }, + { + label: "Life Ring", + value: "fa-life-ring" + }, + { + label: "Circle Outlined Notched", + value: "fa-circle-o-notch" + }, + { + label: "Rebel Alliance", + value: "fa-rebel" + }, + { + label: "Galactic Empire", + value: "fa-empire" + }, + { + label: "Git Square", + value: "fa-git-square" + }, + { + label: "Git", + value: "fa-git" + }, + { + label: "Hacker News", + value: "fa-hacker-news" + }, + { + label: "Tencent Weibo", + value: "fa-tencent-weibo" + }, + { + label: "QQ", + value: "fa-qq" + }, + { + label: "Weixin (WeChat)", + value: "fa-weixin" + }, + { + label: "Paper Plane", + value: "fa-paper-plane" + }, + { + label: "Paper Plane Outlined", + value: "fa-paper-plane-o" + }, + { + label: "History", + value: "fa-history" + }, + { + label: "Circle Outlined Thin", + value: "fa-circle-thin" + }, + { + label: "header", + value: "fa-header" + }, + { + label: "paragraph", + value: "fa-paragraph" + }, + { + label: "Sliders", + value: "fa-sliders" + }, + { + label: "Share Alt", + value: "fa-share-alt" + }, + { + label: "Share Alt Square", + value: "fa-share-alt-square" + }, + { + label: "Bomb", + value: "fa-bomb" + }, + { + label: "Futbol Outlined", + value: "fa-futbol-o" + }, + { + label: "TTY", + value: "fa-tty" + }, + { + label: "Binoculars", + value: "fa-binoculars" + }, + { + label: "Plug", + value: "fa-plug" + }, + { + label: "Slideshare", + value: "fa-slideshare" + }, + { + label: "Twitch", + value: "fa-twitch" + }, + { + label: "Yelp", + value: "fa-yelp" + }, + { + label: "Newspaper Outlined", + value: "fa-newspaper-o" + }, + { + label: "WiFi", + value: "fa-wifi" + }, + { + label: "Calculator", + value: "fa-calculator" + }, + { + label: "Paypal", + value: "fa-paypal" + }, + { + label: "Google Wallet", + value: "fa-google-wallet" + }, + { + label: "Visa Credit Card", + value: "fa-cc-visa" + }, + { + label: "MasterCard Credit Card", + value: "fa-cc-mastercard" + }, + { + label: "Discover Credit Card", + value: "fa-cc-discover" + }, + { + label: "American Express Credit Card", + value: "fa-cc-amex" + }, + { + label: "Paypal Credit Card", + value: "fa-cc-paypal" + }, + { + label: "Stripe Credit Card", + value: "fa-cc-stripe" + }, + { + label: "Bell Slash", + value: "fa-bell-slash" + }, + { + label: "Bell Slash Outlined", + value: "fa-bell-slash-o" + }, + { + label: "Trash", + value: "fa-trash" + }, + { + label: "Copyright", + value: "fa-copyright" + }, + { + label: "At", + value: "fa-at" + }, + { + label: "Eyedropper", + value: "fa-eyedropper" + }, + { + label: "Paint Brush", + value: "fa-paint-brush" + }, + { + label: "Birthday Cake", + value: "fa-birthday-cake" + }, + { + label: "Area Chart", + value: "fa-area-chart" + }, + { + label: "Pie Chart", + value: "fa-pie-chart" + }, + { + label: "Line Chart", + value: "fa-line-chart" + }, + { + label: "last.fm", + value: "fa-lastfm" + }, + { + label: "last.fm Square", + value: "fa-lastfm-square" + }, + { + label: "Toggle Off", + value: "fa-toggle-off" + }, + { + label: "Toggle On", + value: "fa-toggle-on" + }, + { + label: "Bicycle", + value: "fa-bicycle" + }, + { + label: "Bus", + value: "fa-bus" + }, + { + label: "ioxhost", + value: "fa-ioxhost" + }, + { + label: "AngelList", + value: "fa-angellist" + }, + { + label: "Closed Captions", + value: "fa-cc" + }, + { + label: "Shekel (ILS)", + value: "fa-ils" + }, + { + label: "meanpath", + value: "fa-meanpath" + }, + { + label: "BuySellAds", + value: "fa-buysellads" + }, + { + label: "Connect Develop", + value: "fa-connectdevelop" + }, + { + label: "DashCube", + value: "fa-dashcube" + }, + { + label: "Forumbee", + value: "fa-forumbee" + }, + { + label: "Leanpub", + value: "fa-leanpub" + }, + { + label: "Sellsy", + value: "fa-sellsy" + }, + { + label: "Shirts in Bulk", + value: "fa-shirtsinbulk" + }, + { + label: "SimplyBuilt", + value: "fa-simplybuilt" + }, + { + label: "skyatlas", + value: "fa-skyatlas" + }, + { + label: "Add to Shopping Cart", + value: "fa-cart-plus" + }, + { + label: "Shopping Cart Arrow Down", + value: "fa-cart-arrow-down" + }, + { + label: "Diamond", + value: "fa-diamond" + }, + { + label: "Ship", + value: "fa-ship" + }, + { + label: "User Secret", + value: "fa-user-secret" + }, + { + label: "Motorcycle", + value: "fa-motorcycle" + }, + { + label: "Street View", + value: "fa-street-view" + }, + { + label: "Heartbeat", + value: "fa-heartbeat" + }, + { + label: "Venus", + value: "fa-venus" + }, + { + label: "Mars", + value: "fa-mars" + }, + { + label: "Mercury", + value: "fa-mercury" + }, + { + label: "Transgender", + value: "fa-transgender" + }, + { + label: "Transgender Alt", + value: "fa-transgender-alt" + }, + { + label: "Venus Double", + value: "fa-venus-double" + }, + { + label: "Mars Double", + value: "fa-mars-double" + }, + { + label: "Venus Mars", + value: "fa-venus-mars" + }, + { + label: "Mars Stroke", + value: "fa-mars-stroke" + }, + { + label: "Mars Stroke Vertical", + value: "fa-mars-stroke-v" + }, + { + label: "Mars Stroke Horizontal", + value: "fa-mars-stroke-h" + }, + { + label: "Neuter", + value: "fa-neuter" + }, + { + label: "Genderless", + value: "fa-genderless" + }, + { + label: "Facebook Official", + value: "fa-facebook-official" + }, + { + label: "Pinterest P", + value: "fa-pinterest-p" + }, + { + label: "What's App", + value: "fa-whatsapp" + }, + { + label: "Server", + value: "fa-server" + }, + { + label: "Add User", + value: "fa-user-plus" + }, + { + label: "Remove User", + value: "fa-user-times" + }, + { + label: "Bed", + value: "fa-bed" + }, + { + label: "Viacoin (VIA)", + value: "fa-viacoin" + }, + { + label: "Train", + value: "fa-train" + }, + { + label: "Subway", + value: "fa-subway" + }, + { + label: "Medium", + value: "fa-medium" + }, + { + label: "Y Combinator", + value: "fa-y-combinator" + }, + { + label: "Optin Monster", + value: "fa-optin-monster" + }, + { + label: "OpenCart", + value: "fa-opencart" + }, + { + label: "ExpeditedSSL", + value: "fa-expeditedssl" + }, + { + label: "Battery Full", + value: "fa-battery-full" + }, + { + label: "Battery 3/4 Full", + value: "fa-battery-three-quarters" + }, + { + label: "Battery 1/2 Full", + value: "fa-battery-half" + }, + { + label: "Battery 1/4 Full", + value: "fa-battery-quarter" + }, + { + label: "Battery Empty", + value: "fa-battery-empty" + }, + { + label: "Mouse Pointer", + value: "fa-mouse-pointer" + }, + { + label: "I Beam Cursor", + value: "fa-i-cursor" + }, + { + label: "Object Group", + value: "fa-object-group" + }, + { + label: "Object Ungroup", + value: "fa-object-ungroup" + }, + { + label: "Sticky Note", + value: "fa-sticky-note" + }, + { + label: "Sticky Note Outlined", + value: "fa-sticky-note-o" + }, + { + label: "JCB Credit Card", + value: "fa-cc-jcb" + }, + { + label: "Diner's Club Credit Card", + value: "fa-cc-diners-club" + }, + { + label: "Clone", + value: "fa-clone" + }, + { + label: "Balance Scale", + value: "fa-balance-scale" + }, + { + label: "Hourglass Outlined", + value: "fa-hourglass-o" + }, + { + label: "Hourglass Start", + value: "fa-hourglass-start" + }, + { + label: "Hourglass Half", + value: "fa-hourglass-half" + }, + { + label: "Hourglass End", + value: "fa-hourglass-end" + }, + { + label: "Hourglass", + value: "fa-hourglass" + }, + { + label: "Rock (Hand)", + value: "fa-hand-rock-o" + }, + { + label: "Paper (Hand)", + value: "fa-hand-paper-o" + }, + { + label: "Scissors (Hand)", + value: "fa-hand-scissors-o" + }, + { + label: "Lizard (Hand)", + value: "fa-hand-lizard-o" + }, + { + label: "Spock (Hand)", + value: "fa-hand-spock-o" + }, + { + label: "Hand Pointer", + value: "fa-hand-pointer-o" + }, + { + label: "Hand Peace", + value: "fa-hand-peace-o" + }, + { + label: "Trademark", + value: "fa-trademark" + }, + { + label: "Registered Trademark", + value: "fa-registered" + }, + { + label: "Creative Commons", + value: "fa-creative-commons" + }, + { + label: "GG Currency", + value: "fa-gg" + }, + { + label: "GG Currency Circle", + value: "fa-gg-circle" + }, + { + label: "TripAdvisor", + value: "fa-tripadvisor" + }, + { + label: "Odnoklassniki", + value: "fa-odnoklassniki" + }, + { + label: "Odnoklassniki Square", + value: "fa-odnoklassniki-square" + }, + { + label: "Get Pocket", + value: "fa-get-pocket" + }, + { + label: "Wikipedia W", + value: "fa-wikipedia-w" + }, + { + label: "Safari", + value: "fa-safari" + }, + { + label: "Chrome", + value: "fa-chrome" + }, + { + label: "Firefox", + value: "fa-firefox" + }, + { + label: "Opera", + value: "fa-opera" + }, + { + label: "Internet-explorer", + value: "fa-internet-explorer" + }, + { + label: "Television", + value: "fa-television" + }, + { + label: "Contao", + value: "fa-contao" + }, + { + label: "500px", + value: "fa-500px" + }, + { + label: "Amazon", + value: "fa-amazon" + }, + { + label: "Calendar Plus Outlined", + value: "fa-calendar-plus-o" + }, + { + label: "Calendar Minus Outlined", + value: "fa-calendar-minus-o" + }, + { + label: "Calendar Times Outlined", + value: "fa-calendar-times-o" + }, + { + label: "Calendar Check Outlined", + value: "fa-calendar-check-o" + }, + { + label: "Industry", + value: "fa-industry" + }, + { + label: "Map Pin", + value: "fa-map-pin" + }, + { + label: "Map Signs", + value: "fa-map-signs" + }, + { + label: "Map Outlined", + value: "fa-map-o" + }, + { + label: "Map", + value: "fa-map" + }, + { + label: "Commenting", + value: "fa-commenting" + }, + { + label: "Commenting Outlined", + value: "fa-commenting-o" + }, + { + label: "Houzz", + value: "fa-houzz" + }, + { + label: "Vimeo", + value: "fa-vimeo" + }, + { + label: "Font Awesome Black Tie", + value: "fa-black-tie" + }, + { + label: "Fonticons", + value: "fa-fonticons" + }, + { + label: "reddit Alien", + value: "fa-reddit-alien" + }, + { + label: "Edge Browser", + value: "fa-edge" + }, + { + label: "Credit Card", + value: "fa-credit-card-alt" + }, + { + label: "Codie Pie", + value: "fa-codiepie" + }, + { + label: "MODX", + value: "fa-modx" + }, + { + label: "Fort Awesome", + value: "fa-fort-awesome" + }, + { + label: "USB", + value: "fa-usb" + }, + { + label: "Product Hunt", + value: "fa-product-hunt" + }, + { + label: "Mixcloud", + value: "fa-mixcloud" + }, + { + label: "Scribd", + value: "fa-scribd" + }, + { + label: "Pause Circle", + value: "fa-pause-circle" + }, + { + label: "Pause Circle Outlined", + value: "fa-pause-circle-o" + }, + { + label: "Stop Circle", + value: "fa-stop-circle" + }, + { + label: "Stop Circle Outlined", + value: "fa-stop-circle-o" + }, + { + label: "Shopping Bag", + value: "fa-shopping-bag" + }, + { + label: "Shopping Basket", + value: "fa-shopping-basket" + }, + { + label: "Hashtag", + value: "fa-hashtag" + }, + { + label: "Bluetooth", + value: "fa-bluetooth" + }, + { + label: "Bluetooth", + value: "fa-bluetooth-b" + }, + { + label: "Percent", + value: "fa-percent" + }, + { + label: "GitLab", + value: "fa-gitlab" + }, + { + label: "WPBeginner", + value: "fa-wpbeginner" + }, + { + label: "WPForms", + value: "fa-wpforms" + }, + { + label: "Envira Gallery", + value: "fa-envira" + }, + { + label: "Universal Access", + value: "fa-universal-access" + }, + { + label: "Wheelchair Alt", + value: "fa-wheelchair-alt" + }, + { + label: "Question Circle Outlined", + value: "fa-question-circle-o" + }, + { + label: "Blind", + value: "fa-blind" + }, + { + label: "Audio Description", + value: "fa-audio-description" + }, + { + label: "Volume Control Phone", + value: "fa-volume-control-phone" + }, + { + label: "Braille", + value: "fa-braille" + }, + { + label: "Assistive Listening Systems", + value: "fa-assistive-listening-systems" + }, + { + label: "American Sign Language Interpreting", + value: "fa-american-sign-language-interpreting" + }, + { + label: "Deaf", + value: "fa-deaf" + }, + { + label: "Glide", + value: "fa-glide" + }, + { + label: "Glide G", + value: "fa-glide-g" + }, + { + label: "Sign Language", + value: "fa-sign-language" + }, + { + label: "Low Vision", + value: "fa-low-vision" + }, + { + label: "Viadeo", + value: "fa-viadeo" + }, + { + label: "Viadeo Square", + value: "fa-viadeo-square" + }, + { + label: "Snapchat", + value: "fa-snapchat" + }, + { + label: "Snapchat Ghost", + value: "fa-snapchat-ghost" + }, + { + label: "Snapchat Square", + value: "fa-snapchat-square" + }, + { + label: "Pied Piper Logo", + value: "fa-pied-piper" + }, + { + label: "First Order", + value: "fa-first-order" + }, + { + label: "Yoast", + value: "fa-yoast" + }, + { + label: "ThemeIsle", + value: "fa-themeisle" + }, + { + label: "Google Plus Official", + value: "fa-google-plus-official" + }, + { + label: "Font Awesome", + value: "fa-font-awesome" + }, + { + label: "Handshake Outlined", + value: "fa-handshake-o" + }, + { + label: "Envelope Open", + value: "fa-envelope-open" + }, + { + label: "Envelope Open Outlined", + value: "fa-envelope-open-o" + }, + { + label: "Linode", + value: "fa-linode" + }, + { + label: "Address Book", + value: "fa-address-book" + }, + { + label: "Address Book Outlined", + value: "fa-address-book-o" + }, + { + label: "Address Card", + value: "fa-address-card" + }, + { + label: "Address Card Outlined", + value: "fa-address-card-o" + }, + { + label: "User Circle", + value: "fa-user-circle" + }, + { + label: "User Circle Outlined", + value: "fa-user-circle-o" + }, + { + label: "User Outlined", + value: "fa-user-o" + }, + { + label: "Identification Badge", + value: "fa-id-badge" + }, + { + label: "Identification Card", + value: "fa-id-card" + }, + { + label: "Identification Card Outlined", + value: "fa-id-card-o" + }, + { + label: "Quora", + value: "fa-quora" + }, + { + label: "Free Code Camp", + value: "fa-free-code-camp" + }, + { + label: "Telegram", + value: "fa-telegram" + }, + { + label: "Thermometer Full", + value: "fa-thermometer-full" + }, + { + label: "Thermometer 3/4 Full", + value: "fa-thermometer-three-quarters" + }, + { + label: "Thermometer 1/2 Full", + value: "fa-thermometer-half" + }, + { + label: "Thermometer 1/4 Full", + value: "fa-thermometer-quarter" + }, + { + label: "Thermometer Empty", + value: "fa-thermometer-empty" + }, + { + label: "Shower", + value: "fa-shower" + }, + { + label: "Bath", + value: "fa-bath" + }, + { + label: "Podcast", + value: "fa-podcast" + }, + { + label: "Window Maximize", + value: "fa-window-maximize" + }, + { + label: "Window Minimize", + value: "fa-window-minimize" + }, + { + label: "Window Restore", + value: "fa-window-restore" + }, + { + label: "Window Close", + value: "fa-window-close" + }, + { + label: "Window Close Outline", + value: "fa-window-close-o" + }, + { + label: "Bandcamp", + value: "fa-bandcamp" + }, + { + label: "Grav", + value: "fa-grav" + }, + { + label: "Etsy", + value: "fa-etsy" + }, + { + label: "IMDB", + value: "fa-imdb" + }, + { + label: "Ravelry", + value: "fa-ravelry" + }, + { + label: "Eercast", + value: "fa-eercast" + }, + { + label: "Microchip", + value: "fa-microchip" + }, + { + label: "Snowflake Outlined", + value: "fa-snowflake-o" + }, + { + label: "Superpowers", + value: "fa-superpowers" + }, + { + label: "WPExplorer", + value: "fa-wpexplorer" + }, + { + label: "Meetup", + value: "fa-meetup" + } + ] \ No newline at end of file diff --git a/quickboard/static/src/views/fields/qb_icon_picker/qb_icon_picker_field.js b/quickboard/static/src/views/fields/qb_icon_picker/qb_icon_picker_field.js new file mode 100644 index 0000000..1f73c88 --- /dev/null +++ b/quickboard/static/src/views/fields/qb_icon_picker/qb_icon_picker_field.js @@ -0,0 +1,35 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { SelectMenu } from "@web/core/select_menu/select_menu"; +import { Component } from "@odoo/owl"; + +import { fa_icons } from "./fa_icons"; + +export class QbIconPickerField extends Component { + static template = "quickboard.QbIconPickerField"; + static components = { + SelectMenu, + }; + static props = { + ...standardFieldProps, + }; + + async onSelectIcon(val) { + this.props.record.update({ [this.props.name]: val }); + } + + get icons() { + return fa_icons; + } + +} + +export const qbIconPickerField = { + component: QbIconPickerField, + supportedTypes: ["char"], +}; + +registry.category("fields").add("qb_icon_picker", qbIconPickerField); diff --git a/quickboard/static/src/views/fields/qb_icon_picker/qb_icon_picker_field.xml b/quickboard/static/src/views/fields/qb_icon_picker/qb_icon_picker_field.xml new file mode 100644 index 0000000..74f5e5a --- /dev/null +++ b/quickboard/static/src/views/fields/qb_icon_picker/qb_icon_picker_field.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/quickboard/views/quickboard_item_views.xml b/quickboard/views/quickboard_item_views.xml new file mode 100644 index 0000000..b9a45dd --- /dev/null +++ b/quickboard/views/quickboard_item_views.xml @@ -0,0 +1,93 @@ + + + + + + quickboard.item.list + quickboard.item + + + + + + + + + + + + + quickboard.item.form + quickboard.item + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Visit here for icon values. +
+ +
+
+
+
+ + + Quickboard Item + quickboard.item + list,form + + + + +
+
diff --git a/quickboard/views/quickboard_views.xml b/quickboard/views/quickboard_views.xml new file mode 100644 index 0000000..328ffd0 --- /dev/null +++ b/quickboard/views/quickboard_views.xml @@ -0,0 +1,14 @@ + + + + Quickboard + quickboard + + + + + + + diff --git a/quickboard/wizard/__init__.py b/quickboard/wizard/__init__.py new file mode 100644 index 0000000..120699b --- /dev/null +++ b/quickboard/wizard/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import quickboard_generator diff --git a/quickboard/wizard/ai/__init__.py b/quickboard/wizard/ai/__init__.py new file mode 100644 index 0000000..a1dfa51 --- /dev/null +++ b/quickboard/wizard/ai/__init__.py @@ -0,0 +1,7 @@ +from .json_validator_agent import UserProxyAgentForJsonValidation +from .quickboard_ai_generator import QuickboardAiGenerator + +__all__ = [ + "UserProxyAgentForJsonValidation", + "QuickboardAiGenerator" +] diff --git a/quickboard/wizard/ai/consts.py b/quickboard/wizard/ai/consts.py new file mode 100644 index 0000000..7ec10f0 --- /dev/null +++ b/quickboard/wizard/ai/consts.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +from autogen import AssistantAgent, UserProxyAgent, filter_config +from textwrap import dedent +from prettytable import PrettyTable + +# API_KEY = "_ollama_" +# BASE_URL = "http://localhost:11434/v1" + +API_KEY = "_lmstudio_" +BASE_URL = "http://localhost:1234/v1" + +LLM_MODEL = "qwen2.5-coder-7b-instruct" + +DEFAULT_AUTOGEN_CONFIG_LIST = [ + { + "model": LLM_MODEL, + "base_url": BASE_URL, + "api_key": API_KEY, + }, +] + +DEFAULT_AUTOGEN_LLM_CONFIG = { + "config_list": DEFAULT_AUTOGEN_CONFIG_LIST, + "cache_seed": None, + "temperature": 0.3, + "seed": 10 +} + +QUICKBOARD_BG_COLORS = ["#845ec2","#d65db1","#ff6f91","#ff9671","#ffc75f","#2c73d2","#0081cf","#0089ba","#008e9b","#008f7a"] +QUICKBOARD_FG_COLORS = ["#000000", "$ffffff"] + +QUICKBOARD_DATA_UI_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Generated schema for Root", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "type": { + "enum": ["basic", "chart", "list"] + }, + "model": { + "type": "string" + }, + "value_field": { + "type": "string" + }, + "aggregate_function": { + "enum": ["avg", "count", "max", "min", "sum"] + }, + "text_color": { + "type": "string" + }, + "background_color": { + "type": "string" + }, + "x_pos": { + "type": "integer" + }, + "y_pos": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "dimension_field": { + "type": "string" + }, + "chart_type": { + "enum": ["bar", 'horizontal-bar' "doughnut", "line", "pie", "polar"] + }, + "list_row_limit": { + "type": "integer" + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "chart" } + } + }, + "then": { + "required": ["chart_type", "dimension_field"] + }, + "if": { + "properties": { + "type": { "const": "list" } + } + }, + "then": { + "required": ["list_row_limit"] + } + }, + ], + "required": [ + "name", + "icon", + "type", + "model", + "value_field", + "aggregate_function", + "x_pos", + "y_pos", + "width", + "height" + ] + } +} + +QUICKBOARD_DATA_ONLY_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Generated schema for Root", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "type": { + "enum": ["basic", "chart", "list"] + }, + "model": { + "type": "string" + }, + "value_field": { + "type": "string" + }, + "aggregate_function": { + "enum": ["avg", "count", "max", "min", "sum"] + }, + "text_color": { + "type": "string" + }, + "background_color": { + "type": "string" + }, + "dimension_field": { + "type": "string" + }, + "chart_type": { + "enum": ["bar", "horizontal-bar", "doughnut", "line", "pie", "polar"] + }, + "list_row_limit": { + "type": "integer" + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "chart" } + } + }, + "then": { + "required": ["chart_type", "dimension_field"] + } + }, + { + "if": { + "properties": { + "type": { "const": "basic" } + } + }, + "then": { + "required": ["text_color", "background_color"] + } + }, + { + "if": { + "properties": { + "type": { "const": "list" } + } + }, + "then": { + "required": ["list_row_limit"] + } + } + ], + "required": [ + "name", + "icon", + "type", + "model", + "value_field", + "aggregate_function" + ] + } +} + +QUICKBOARD_LAYOUT_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Generated schema for Root", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "enum": ["basic", "chart", "list"] + }, + "x_pos": { + "type": "integer" + }, + "y_pos": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + }, + "required": [ + "id", + "type", + "x_pos", + "y_pos", + "width", + "height" + ] + } +} diff --git a/quickboard/wizard/ai/json_validator_agent.py b/quickboard/wizard/ai/json_validator_agent.py new file mode 100644 index 0000000..3603199 --- /dev/null +++ b/quickboard/wizard/ai/json_validator_agent.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +import json +from jsonschema import validate + +from textwrap import dedent +from typing import Any, Callable, Dict, List, Optional, Literal, Optional, Union + +from autogen import Agent, ConversableAgent +from autogen.coding import CodeExecutor, CodeExtractor, MarkdownCodeExtractor, CodeBlock, CodeResult +from autogen.runtime_logging import log_new_agent, logging_enabled + +class JsonValidator(CodeExecutor): + def __init__(self, json_schema, **kwargs): + self.json_schema = json_schema + + @property + def code_extractor(self) -> CodeExtractor: + return MarkdownCodeExtractor() + + def execute_code_blocks(self, code_blocks: List[CodeBlock]) -> CodeResult: + logs_all = "" + exitcode = 0 + + json_code_block_count = 0 + for idx, code_block in enumerate(code_blocks, start=1): + lang, code = code_block.language, code_block.code + lang = lang.lower() + + if lang != "json": + logs_all += "\n" + f"Skipping execution: language not supported (code block #{idx})." + continue + + try: + quickboard_json = json.loads(code) + validate(quickboard_json, self.json_schema) + except Exception as e: + exitcode = -1 + logs_all += f"\nError: {str(e)}" + break + exitcode = 0 + logs_all += f"Json is valid." + json_code_block_count += 1 + + if (exitcode == 0 and json_code_block_count > 0) or exitcode == -1: + return CodeResult(exit_code=exitcode, output=logs_all) + + return CodeResult(exit_code=-1, output="Invalid or no json code block was detected, please make sure your code block is marked as json.") + + def restart(self) -> None: + self.engine.dispose() + +class UserProxyAgentForJsonValidation(ConversableAgent): + DEFAULT_USER_PROXY_AGENT_DESCRIPTIONS = { + "ALWAYS": dedent(\ + """An attentive HUMAN user who can answer questions about the task, and can perform tasks such as validating json + using software tools and reporting back the execution results."""), + "TERMINATE": dedent(\ + """A user that can validate json using software tools and report back the execution results."""), + "NEVER": dedent(\ + """An bot that performs no other action than validating json (provided to it's quoted in json blocks)."""), + } + + def __init__( + self, + name: str, + json_schema: object, + is_termination_msg: Optional[Callable[[Dict], bool]] = None, + max_consecutive_auto_reply: Optional[int] = None, + human_input_mode: Literal["ALWAYS", "NEVER", "TERMINATE"] = "NEVER", + default_auto_reply: Union[str, Dict] = "", + description: Optional[str] = None, + ): + json_validator = JsonValidator(json_schema=json_schema) + super().__init__( + name=name, + is_termination_msg=is_termination_msg, + max_consecutive_auto_reply=max_consecutive_auto_reply, + human_input_mode=human_input_mode, + code_execution_config={"executor": json_validator}, + default_auto_reply=default_auto_reply, + description=( + description if description is not None else self.DEFAULT_USER_PROXY_AGENT_DESCRIPTIONS[human_input_mode] + ), + ) + + if logging_enabled(): + log_new_agent(self, locals()) + + def run_code(self, code, **kwargs): + return -1, "Not supported", None + + def execute_code_blocks(self, code_blocks): + return -1, "Not supported" + + def generate_reply( + self, + messages: Optional[List[Dict[str, Any]]] = None, + sender: Optional["Agent"] = None, + **kwargs: Any, + ) -> Union[str, Dict, None]: + res = super().generate_reply(messages, sender) + + res_ok = True + if isinstance(res, Dict) and len(dict) == 0: + res_ok = False + elif isinstance(res, str) and str == "": + res_ok = False + elif res is None: + res_ok = False + + if not res_ok: + msg = "Invalid or no json code block was detected, please make sure your code block is marked as json." + return f"exitcode: -1 (execution failed)\nCode output: {msg}" + + return res diff --git a/quickboard/wizard/ai/quickboard_ai_generator.py b/quickboard/wizard/ai/quickboard_ai_generator.py new file mode 100644 index 0000000..5c55c37 --- /dev/null +++ b/quickboard/wizard/ai/quickboard_ai_generator.py @@ -0,0 +1,628 @@ +# -*- coding: utf-8 -*- +import json +import copy + +from textwrap import dedent +from prettytable import PrettyTable + +from autogen import Agent, AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager +from autogen.coding import MarkdownCodeExtractor + +from .consts import DEFAULT_AUTOGEN_LLM_CONFIG, QUICKBOARD_FG_COLORS, QUICKBOARD_BG_COLORS, QUICKBOARD_DATA_ONLY_JSON_SCHEMA, QUICKBOARD_LAYOUT_JSON_SCHEMA +from .json_validator_agent import UserProxyAgentForJsonValidation + +class QuickboardAiGenerator: + _QUICKBOARD_GENERATOR_SYSTEM_MESSAGE = f""" + You are an AI assistant specializing in data analysis. + Your task is to assist user to build an intuitive and informative dashboard. + You help by creating a list dashboard items base on the fields from the given tables. + + Ignore all your previous knowledge about models, here models are the same with relational database tables. + Only use the models as mentioned here with common sense, e.g. when the model name is 'sale.order' + then the model is about sales orders, etc. + + ## DASHBOARD ITEM TYPES + There are two kind of dashboard items, 'basic', 'list' and 'chart'. + A 'basic' item is used for single value KPI, 'list' items are usualy used to show top (n) data in a tabular list + while a 'chart' item is used to show data in a chart. + + Both shared the following common parameters: + 1. name (string): The title of the item. Required. + 2. icon (string): Font awesome version 4.7 icon in 'fa-*' format. Required. + 3. type (string): Dashboard item type. Required. + 4. model (string): model name, e.g. "sale.order", "product.product", etc. Required. + 5. value_field (string): Field of the chosen model for the value to be shown, only accept one field. Required. + 6. aggregate_function (string): Function to be applied on the value field. Required. + + Basic item has the following parameters in addition of the common parameters above: + 1. text_color (string): Text color for the item. Required. + 2. background_color (string): Background color for the item. Required. + + Chart item has the following parameters in addition of the common parameters above: + 1. dimension_field (string): Field of the chosen model for the grouping of the data, only accept one field. Required. + 2. chart_type (string): The chart type. Required. + + List item has the following parameters in addition of the common parameters above: + 1. dimension_field (string): Field of the chosen model for the grouping of the data, only accept one field. Required. + 2. list_row_limit (integer): Number of rows in the list. Required. + + ## COLORS + Use these color palette for text color parameters: {QUICKBOARD_FG_COLORS} + Use these color palette for background color parameters: {QUICKBOARD_BG_COLORS} + + #EXAMPLE + Given a model with name 'sale.order' which have the following fields: + +-------------+------------------------+-----------+-------------------------+-------------------+ + | Model | Name | Type | Description | Usage | + +-------------+------------------------+-----------+-------------------------+-------------------+ + | ... | ... | ... | ... | ... | + | sale.order | id | integer | ID | value, dimension | + | sale.order | date_order | datetime | Order Date | dimension | + | sale.order | medium_id | integer | Medium | dimension | + | sale.order | sale_order_option_ids | list | Optional Products Lines | | + | sale.order | amount_total | monetary | Total | value | + | sale.order | amount_to_invoice | monetary | Amount to invoice | value | + | ... | ... | ... | ... | ... | + +-------------+------------------------+-----------+-------------------------+-------------------+ + + And a model with name 'sale.order.line' which have the following fields: + +------------------+---------------------------+-----------+--------------------------------+-------------------+ + | Model | Name | Type | Description | Usage | + +------------------+---------------------------+-----------+--------------------------------+-------------------+ + | ... | ... | ... | ... | ... | + | sale.order.line | product_id | integer | Product | value, dimension | + | sale.order.line | product_uom_qty | float | Quantity | value | + | sale.order.line | qty_delivered_method | char | Method to update delivered qty | value, dimension | + | sale.order.line | qty_delivered | float | Delivery Quantity | value | + | ... | ... | ... | ... | ... | + +------------------+---------------------------+-----------+--------------------------------+-------------------+ + + You may choose to answer as follow: + ```json + [ + {{ + \"name\": \"Sales Order Count\", + \"icon\": \"fa-shopping-bag\", + \"type\": \"basic\", + \"model\": \"sale.order\", + \"value_field\": \"id\", + \"aggregate_function\": \"count\", + \"text_color\": \"#000000\", + \"background_color\": \"#FFFFFF\" + }}, + {{ + \"name\": \"Sales Order Total\", + \"icon\": \"fa-shopping-cart\", + \"type\": \"basic\", + \"model\": \"sale.order\", + \"value_field\": \"amount_total\", + \"aggregate_function\": \"sum\", + \"text_color\": \"#000000\", + \"background_color\": \"#3b3b3b\" + }}, + {{ + \"name\": \"Total Amount By Date\", + \"icon\": \"fa-usd\", + \"type\": \"chart\", + \"model\": \"sale.order\", + \"value_field\": \"amount_total\", + \"aggregate_function\": \"sum\", + \"dimension_field\": \"date_order\", + \"datetime_granularity\": \"day\", + \"chart_type\": \"line\" + }}, + {{ + \"name\": \"Top 10 Products\", + \"icon\": \"fa-shopping-bag\", + \"type\": \"list\", + \"model\": \"sale.order.line\", + \"value_field\": \"product_uom_qty\", + \"aggregate_function\": \"sum\", + \"dimension_field\": \"product_id\", + \"list_row_limit\": 10 + }} + ] + ``` + + ## RULES + 1. Answer only with the definitions of the items in a json list fenced in json code block. + 2. Do not comment. Do not explain your answer. + 3. Do not use models other than the user specifies. + 4. Pay attention to which field belong to which model. Do not to use fields from other models. + 5. Treat each model independently, do not mix fields from one model to another. + 6. All parameters are required, never set any parameter to null. + 7. Set parameter 'type' to 'basic' for basic items, set it to 'list' for list items and set it to 'chart' for chart items. + 8. Parameter 'chart_type' must be one of ['bar', 'horizontal-bar', 'doughnut', 'line', 'pie', 'polar']. + 9. Use one of ['avg', 'count', 'max', 'min', 'sum'] for 'aggregate_function' if the 'value_field' is one of ['integer', 'float', 'monetary']. + 10. For other types of 'value_field' such as 'char', 'many2one', etc., the parameter 'aggregate_function' must only be 'count'. + 11. Never use field with type 'date' or 'datetime' for 'value_field'. + 12. Parameter 'value_field' and 'dimension_field', requires exact field name, use the field as is without prefixes nor suffixes. + 13. If the type of 'dimension_field' is date or datetime, you may add 'datetime_granularity' parameter to specify the precision. + 'datetime_granularity' must be one of ["year", "month", "day"]. + 14. Do not assume a field has relation to other model, so again, 'value_field' and 'dimension_field' must use the exact name as mentioned here. + 15. Use only single color for color related parameters, not an array of colors, choose one of the colors mentioned above. + 16. Fence your anwser with markdown json code block (```json your_answer ```). + 17. Your answer will be validated by a bot, if your answer is not valid then you must fix it. + 18. When fixing answer re-evaluate everything and do not give comment on the json code block as it will create another error. + 19. When fixing answer always reply with the the fixed json code block with every items. + """ + + def __init__(self, env): + self.env = env + + self._admin: UserProxyAgent = None + self._quickboard_ai: AssistantAgent = None + self._json_validator: UserProxyAgentForJsonValidation = None + self._groupchat: GroupChat = None + self._manager: GroupChatManager = None + + def _create_agents(self, data_json_schema): + admin = UserProxyAgent( + "admin", + description="The user who give tasks and questions.", + human_input_mode="NEVER", + is_termination_msg=lambda message: True, # Always True + code_execution_config=False, + ) + + quickboard_generator = AssistantAgent( + name="quickboard_generator", + description=f"AI that generate dashboards.", + system_message=dedent(self._QUICKBOARD_GENERATOR_SYSTEM_MESSAGE), + human_input_mode="NEVER", + llm_config=DEFAULT_AUTOGEN_LLM_CONFIG, + ) + + data_json_validator = UserProxyAgentForJsonValidation( + "data_json_validator", + json_schema=data_json_schema, + description="An bot that performs no other action than validating json (provided to it's quoted in json blocks).", + human_input_mode="NEVER", + ) + + def _speaker_selection_func(last_speaker: Agent, groupchat: GroupChat): + last_messages = groupchat.messages[-1] + next_speaker = admin + + if last_speaker is admin: + next_speaker = quickboard_generator + elif last_speaker is quickboard_generator: + next_speaker = data_json_validator + elif last_speaker is data_json_validator: + # if last agent reply with invalid json then let it try again + if last_messages["content"].find("exitcode: -1") > -1: + next_speaker = quickboard_generator + elif last_messages["content"].strip() == "": + next_speaker = quickboard_generator + + return next_speaker + + groupchat = GroupChat( + agents=[admin, quickboard_generator, data_json_validator], + messages=[], + max_round=5, + speaker_selection_method= _speaker_selection_func, + # send_introductions=True, + ) + + manager = GroupChatManager( + groupchat=groupchat, + name="chat_manager", + llm_config=DEFAULT_AUTOGEN_LLM_CONFIG + ) + + return (admin, quickboard_generator, data_json_validator, manager, groupchat) + + def _build_agent_parameters(self, models): + model_names = [] + model_infos = [] + + data_json_schema = copy.deepcopy(QUICKBOARD_DATA_ONLY_JSON_SCHEMA) + + for model in models: + field_defs = PrettyTable() + field_defs.align = "l" + field_defs.field_names = ["Model", "Name", "Type", "Description", "Usage"] + + fields = self.env[model.model].fields_get() + + valid_value_fields = [] + valid_dimension_fields = [] + + for k, v in fields.items(): + if v["store"]: + + if v["type"] in ["many2many", "one2many"]: + field_type = "list" + elif v["type"] == "selection": + field_type = "char" + elif v["type"] == "many2one": + field_type = "integer" + else: + field_type = v["type"] + + usage = [] + if v["type"] not in ["many2many", "one2many", "float", "monetary"]: + valid_dimension_fields.append(f"{k}") + usage.append("dimension") + + if v["type"] in ["integer", "float", "monetary", "many2one", "selection"]: + valid_value_fields.append(f"{k}") + usage.append("value") + + field_defs.add_row([model.model, f"{k}", field_type, v['string'], ",".join(usage)]) + + valid_value_fields_str = ", ".join([f"'{o}'" for o in valid_value_fields]) + valid_dimension_fields_str = ", ".join([f"'{o}'" for o in valid_dimension_fields]) + + model_names.append(model.model) + model_infos.append({ + "name": model.model, + "field_defs": field_defs.get_string(), + "value_fields": valid_value_fields_str, + "dimension_fields": valid_dimension_fields_str + }) + + model_name_schema = { + "const": model.model + } + + value_field_schema = { + "if": { + "properties": { + "model": model_name_schema + } + }, + "then": { + "properties": { + "value_field": { + "enum": valid_value_fields + } + } + } + } + dimension_field_schema = { + "if": { + "properties": { + "model": model_name_schema + } + }, + "then": { + "properties": { + "dimension_field": { + "enum": valid_dimension_fields + } + } + } + } + + data_json_schema["items"]["model"] = model_name_schema + data_json_schema["items"]["allOf"].append(value_field_schema) + data_json_schema["items"]["allOf"].append(dimension_field_schema) + + return model_names, model_infos, data_json_schema + + def _create_message(self, model_names, model_infos): + message = dedent(f""" + Create dashboard items from these models {[o for o in model_names]}. + """) + + for mi in model_infos: + mi_string = dedent(f""" + ### MODEL '{mi["name"]}' + The model '{mi["name"]}' has the following fields: + {mi["field_defs"]} + """) + + message = message + "\n" + mi_string + + + message += dedent(""" + Pay attention to the field usage. Valid values for 'value_field' and 'dimension_field' are based on it. + + Also pay attention to which model a field belongs to, do not use other models field in a dashboard item. + Always use the exact field name as mentioned above on the models field. + + Create at least 3 'basic' items, 3 'list' items and 3 'chart' items. + Make the 'basic' items background colorful with matching but still readable text color. + Answer only in a json list fenced in a json code block. + """) + + return message + + def _arrange_items(self, quickboard_items): + items = copy.deepcopy(quickboard_items) + basic_items = [o for o in items if o["type"] == "basic"] + list_chart_items = [o for o in items if o["type"] != "basic"] + + fin = [] + row = 0 + while len(basic_items) > 0: + count = 1 + width = 6 + if len(basic_items) >= 6: + count = 6 + width = 2 + elif len(basic_items) >= 4: + count = 4 + width = 3 + elif len(basic_items) >= 3: + count = 3 + width = 4 + elif len(basic_items) >= 2: + count = 2 + width = 6 + + row_items = basic_items[:count] + for k, o in enumerate(row_items): + o["y_pos"] = row + o["x_pos"] = k * width + o["height"] = 1 + o["width"] = width + + fin.extend(row_items) + del basic_items[:count] + + row += 1 + + while len(list_chart_items) > 0: + width = 4 + count = 3 + + if len(list_chart_items) >= 4: + count = 4 + width = 3 + elif len(list_chart_items) >= 3: + count = 3 + width = 4 + elif len(list_chart_items) >= 2: + count = 2 + width = 6 + + row_items = list_chart_items[:count] + for k, o in enumerate(row_items): + o["y_pos"] = row + o["x_pos"] = k * width + o["height"] = 2 + o["width"] = width + + fin.extend(row_items) + del list_chart_items[:count] + + row += 2 + + return fin + + def generate_quickboard(self, models, layout_by_ai, screen_w, screen_h, cell_w, cell_h): + model_names, model_infos, json_schema = self._build_agent_parameters(models) + + self._admin,\ + self._quickboard_ai,\ + self._json_validator,\ + self._manager,\ + self._groupchat = self._create_agents(json_schema) + + message = self._create_message(model_names, model_infos) + answer = self._admin.initiate_chat(self._manager, message=message) #, silent=True) + + res = "" + + # The json is not on answer.summary / last message since the last agent is json validator when successful + if answer.summary.find("exitcode: 0") > -1: + extractor = MarkdownCodeExtractor() + code_blocks = extractor.extract_code_blocks(answer.chat_history[-2]["content"]) + if len(code_blocks) > 0: + quickboard = code_blocks[0].code + quickboard_items = json.loads(quickboard) + + arranged_items = [] + if layout_by_ai: + aiDesigner = QuickboardAIDesigner() + arranged_items = aiDesigner.arrange_items_with_ai(quickboard_items, screen_w, screen_h, cell_w, cell_h) + else: + arranged_items = self._arrange_items(quickboard_items) + + res = json.dumps(arranged_items) + + return res + +class QuickboardAIDesigner: + _QUICKBOARD_DESIGNER_SYSTEM_MESSAGE = f""" + Your task is to design the layout of a dashboard from the given dashboard items. + + ## DASHBOARD ITEM TYPES + There are three kind of dashboard items, basic, list and chart. + A basic item is used for single value KPI, a list is used to display data in a tabular list + while a chart item is used to show data in a graphical chart. + + All have the following attribute: + 1. id: The id of the dashboard item. + 2. type: Dashboard item type, 'basic' for basic items, 'list' for list items and 'chart' for chart items. + 3. x_pos: The horizontal position on the grid (0 to 11, representing the block's position). + 4. y_pos: The vertical position on the grid (measured in blocks, can be unlimited). + 5. width: The width of the item. + 6. height: The height of the item. + + ## LAYOUT + The dashboard layout is a grid system measured in square blocks. + + It has 12 blocks for column width and unlimited cell rows. + The origin (0, 0) position is on the top left. The maximum position of the top row is (12, 0). + + ## REQUIREMENTS + 1. Prioritize the arrangement of blocks to optimize space, i,e. no gaps between items. + 2. Ensure that no blocks overlap and that all blocks are positioned within the defined grid limits. + 3. Arrange the 'basic' items to fill the top rows. + 4. Arrange the rest of the items to fill the rows after the 'basic' items. + + ## RULES + 1. Do not change the 'id' and the 'type' of the dashboard items, + i.e, the 'id' and 'type' is a fixed pair. If you want to re-arrange the items, always use the same 'id' and 'type' pair. + 2. Only edit these attribute 'x_pos', 'y_pos', 'width', 'height'. + 3. Do not add nor remove dashboard items! + 4. Fence your anwser with markdown json block. + 5. Do not comment. Do not explain your answer. + 6. Your answer will be validated by a bot, if your answer is not valid then you must fix it. + 7. When fixing answer re-evaluate everything and do not give comment as it will create another error. + 8. When fixing answer always reply with the the fixed json code block with every items. + """ + + def __init__(self): + self._admin: UserProxyAgent = None + self._quickboard_ai: AssistantAgent = None + self._json_validator: UserProxyAgentForJsonValidation = None + self._groupchat: GroupChat = None + self._manager: GroupChatManager = None + + def _create_agents(self, ui_json_schema): + admin = UserProxyAgent( + "admin", + description="The user who give tasks and questions.", + human_input_mode="NEVER", + is_termination_msg=lambda message: True, # Always True + code_execution_config=False, + ) + + quickboard_designer = AssistantAgent( + name="quickboard_designer", + description=f"AI that design the layout of dashboards.", + system_message=dedent(self._QUICKBOARD_DESIGNER_SYSTEM_MESSAGE), + human_input_mode="NEVER", + llm_config=DEFAULT_AUTOGEN_LLM_CONFIG, + ) + + ui_json_validator = UserProxyAgentForJsonValidation( + "ui_json_validator", + json_schema=ui_json_schema, + description="An bot that performs no other action than validating json (provided to it's quoted in json blocks).", + human_input_mode="NEVER", + ) + + def _speaker_selection_func(last_speaker: Agent, groupchat: GroupChat): + last_messages = groupchat.messages[-1] + next_speaker = admin + + if last_speaker is admin: + next_speaker = quickboard_designer + elif last_speaker is quickboard_designer: + next_speaker = ui_json_validator + elif last_speaker is ui_json_validator: + # if last agent reply with invalid json then let it try again + if last_messages["content"].find("exitcode: -1") > -1: + next_speaker = quickboard_designer + elif last_messages["content"].strip() == "": + next_speaker = quickboard_designer + + return next_speaker + + groupchat = GroupChat( + agents=[admin, quickboard_designer, ui_json_validator], + messages=[], + max_round=5, + speaker_selection_method= _speaker_selection_func, + # send_introductions=True, + ) + + manager = GroupChatManager( + groupchat=groupchat, + name="chat_manager", + llm_config=DEFAULT_AUTOGEN_LLM_CONFIG + ) + + return (admin, quickboard_designer, ui_json_validator, manager, groupchat) + + def _build_agent_parameters(self, quickboard_items): + layout_json_schema = copy.deepcopy(QUICKBOARD_LAYOUT_JSON_SCHEMA) + + item_count = len(quickboard_items) + item_count_schema = { + "minItems": item_count, + "maxItems": item_count + } + + layout_json_schema.update(item_count_schema) + + item_id_type_schema = [] + for item in quickboard_items: + id_type_schema = { + "if": { + "properties": { + "id": { "const": item["id"] } + } + }, + "then": { + "properties": { + "type": { "const": item["type"] } + } + } + } + item_id_type_schema.append(id_type_schema) + + layout_json_schema["items"]["allOf"] = item_id_type_schema + + return layout_json_schema + + def _create_message(self, quickboard_items, screen_w, screen_h, cell_w, cell_h): + layout_items = [[{ + "id": o["id"], + "type": o["type"], + "x_pos": 0, + "y_pos": 0, + "width": 0, + "height": 0 + }] for o in quickboard_items] + + layout_items_count = len(layout_items) + layout_items_str = ", ".join([ f"{json.dumps(o)}\n" for o in layout_items]) + + message = f""" + Design dashboard layout for these {layout_items_count} items: {layout_items_str}. + + The cell size for my screen is {cell_w} x {cell_h} (width x height). + My screen size is {screen_w} x {screen_h} (width x height). + + Make it compact, for 'basic' items 1 block for its height is enough. + For 'list' and 'chart' items 3 blocks for its height are enough. + + Put basic items first then list and chart items. + Do not change the 'id' and the 'type' of the items. + Remember there are {layout_items_count}, use them all, do not add any item nor remove any of them. + """ + return message + + def arrange_items_with_ai(self, quickboard_items, screen_w, screen_h, cell_w, cell_h): + res = quickboard_items + for i, item in enumerate(quickboard_items, start=1): + item["id"] = i + + json_schema = self._build_agent_parameters(quickboard_items) + + self._admin,\ + self._quickboard_ai,\ + self._json_validator,\ + self._manager,\ + self._groupchat = self._create_agents(json_schema) + + message = self._create_message(quickboard_items, screen_w, screen_h, cell_w, cell_h) + answer = self._admin.initiate_chat(self._manager, message=message) #, silent=True) + + if answer.summary.find("exitcode: 0") > -1: + extractor = MarkdownCodeExtractor() + code_blocks = extractor.extract_code_blocks(answer.chat_history[-2]["content"]) + if len(code_blocks) > 0: + layout = code_blocks[0].code + layout_items = json.loads(layout) + + # merge item and layout + fin_items = [] + for item in quickboard_items: + for layout in layout_items: + if layout["id"] == item["id"] and layout["type"] == item["type"]: + item.update(layout) + break + + fin_items.append(item) + + return fin_items + + return res diff --git a/quickboard/wizard/quickboard_generator.py b/quickboard/wizard/quickboard_generator.py new file mode 100644 index 0000000..b4b2437 --- /dev/null +++ b/quickboard/wizard/quickboard_generator.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +import logging + +import json +from jsonschema import validate + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + +from .ai import QuickboardAiGenerator +from .ai.consts import QUICKBOARD_DATA_UI_JSON_SCHEMA, QUICKBOARD_BG_COLORS, QUICKBOARD_FG_COLORS + +_logger = logging.getLogger(__name__) + +class QuickboardGenerator(models.TransientModel): + _name = "quickboard.generator" + _description = "Generate quickbaord items with AI." + + model_ids = fields.Many2many('ir.model', string='Model') + layout_by_ai = fields.Boolean("Layout by AI", default=False) + + def action_generate_quickboard(self): + if len(self.model_ids.ids) < 1: + return { + 'name': _('Generate Quickboard'), + 'type': 'ir.actions.act_window', + 'res_model': 'quickboard.generator', + 'view_type': 'form', + 'view_mode': 'form', + 'res_id': self.id, + 'target': 'new', + } + + screen_width = self.env.context.get("screen_width") + screen_height = self.env.context.get("screen_height") + cell_width = self.env.context.get("cell_width") + cell_height = self.env.context.get("cell_height") + + try: + gen = QuickboardAiGenerator(self.env) + quickboard = gen.generate_quickboard( + self.model_ids, + self.layout_by_ai, + screen_width, + screen_height, + cell_width, + cell_height + ) + quickboard_json = json.loads(quickboard) + validate(quickboard_json, QUICKBOARD_DATA_UI_JSON_SCHEMA) + + quickboard_item = self.env['quickboard.item'] + items = quickboard_item.search([]) + for o in items: + o.unlink() + + for o in quickboard_json: + _logger.info(f"Creating quickboard item: {o}") + + model = self.env["ir.model"].search([("model", "=", o["model"])]) + value_field = self.env["ir.model.fields"].search([("model_id", "=", model.id), ("name", "=", o["value_field"])]) + + if not value_field.id: + raise Exception("AI generated invalid field: %s." % {o["value_field"]}) + + # Sometime AI choose the wrong aggregate function, we could return the result to AI with json schema validation. + # But, it would mean failing the result and making another attempt, the alternative is we just fix it here. + if value_field.ttype not in ['float', 'integer', 'monetary'] and o["aggregate_function"] != "count": + o["aggregate_function"] = "count" + + vals = { + "name": o["name"], + "model_id": model.id, + "icon": o["icon"], + "type": o["type"], + "value_field_id": [value_field.id], + "aggregate_function": o["aggregate_function"], + "x_pos": o["x_pos"], + "y_pos": o["y_pos"], + "height": o["height"], + "width": o["width"] + } + if o["type"] == "basic": + text_color = 0 + if o["text_color"] and o["text_color"] in QUICKBOARD_FG_COLORS: + text_color = QUICKBOARD_FG_COLORS.index(o["text_color"]) + + back_color = 0 + if o["text_color"] and o["text_color"] in QUICKBOARD_BG_COLORS: + back_color = QUICKBOARD_BG_COLORS.index(o["text_color"]) + + vals.update({ + "text_color": text_color, + "background_color": back_color, + }) + elif o["type"] == "chart": + dimension_field = self.env["ir.model.fields"].search([("model_id", "=", model.id), ("name", "=", o["dimension_field"])]) + if not dimension_field.id: + raise Exception("AI generated invalid field: %s." % {o["dimension_field"]}) + + vals.update({ + "dimension_field_id": dimension_field.id, + "chart_type": o["chart_type"], + }) + + if "datetime_granularity" in o: + vals.update({ + "datetime_granularity": o["datetime_granularity"] + }) + elif o["type"] == "list": + dimension_field = self.env["ir.model.fields"].search([("model_id", "=", model.id), ("name", "=", o["dimension_field"])]) + if not dimension_field.id: + raise Exception("AI generated invalid field: %s." % {o["dimension_field"]}) + + vals.update({ + "dimension_field_id": dimension_field.id, + "list_row_limit": o["list_row_limit"] + }) + + if "datetime_granularity" in o: + vals.update({ + "datetime_granularity": o["datetime_granularity"] + }) + + quickboard_item.with_context(ai_generation=True).create(vals) + except Exception as e: + _logger.error("Error generating quickboard", e) + raise ValidationError(_("Unfortunately the AI didn't generate valid quickboard. Please try again.")) + + for rec in self: + self.env["bus.bus"]._sendone("quickboard", "quickboard_updated", {}) + + return True \ No newline at end of file diff --git a/quickboard/wizard/quickboard_generator_views.xml b/quickboard/wizard/quickboard_generator_views.xml new file mode 100644 index 0000000..c5cfefb --- /dev/null +++ b/quickboard/wizard/quickboard_generator_views.xml @@ -0,0 +1,34 @@ + + + + + quickboard.generator.view + quickboard.generator + form + +
+ + + + + + + + +
+
+ +
+
+
+
+ diff --git a/scraper_test/README.md b/scraper_test/README.md new file mode 100644 index 0000000..2b0d4db --- /dev/null +++ b/scraper_test/README.md @@ -0,0 +1,20 @@ +# Scraper Test +> [!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] +> AI might generate commands that negatively impact your data. +> +> Do not use this module unless you have reviewed the source codes thoroughly, understand what it does and in an experimental environment. + +This module show the issues when running Crawl4AI, Playwright and Selenium inside Odoo. + +Please watch this video for more details: + +[![EXPLORING_ODOO](https://img.youtube.com/vi/dogjC_0P53A/0.jpg)](https://youtu.be/dogjC_0P53A) diff --git a/scraper_test/__init__.py b/scraper_test/__init__.py new file mode 100644 index 0000000..20a7f5c --- /dev/null +++ b/scraper_test/__init__.py @@ -0,0 +1 @@ +from . import wizard \ No newline at end of file diff --git a/scraper_test/__manifest__.py b/scraper_test/__manifest__.py new file mode 100644 index 0000000..a85d752 --- /dev/null +++ b/scraper_test/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "Scraper Test", + "version": "18.0.1.0.0", + "depends": ["web", "bus", "ai_chat_base"], + "author": "Yoni Tjio", + "category": "Customizations", + "description": """ + Scraper Test + """, + "data": [ + "security/ir.model.access.csv", + "wizard/scrap.xml", + ], + "application": True, + "installable": True, + "auto_install": False, + "license": "Other proprietary", +} diff --git a/scraper_test/security/ir.model.access.csv b/scraper_test/security/ir.model.access.csv new file mode 100644 index 0000000..31af5dd --- /dev/null +++ b/scraper_test/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_scrap_wizard,access_scrap_wizard,model_scrap_wizard,base.group_system,1,1,1,1 diff --git a/scraper_test/test.py b/scraper_test/test.py new file mode 100644 index 0000000..fb7b871 --- /dev/null +++ b/scraper_test/test.py @@ -0,0 +1,386 @@ +import os +import sys +import signal +import resource +import argparse +import logging +import asyncio +from multiprocessing import Process, get_context + +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig +from playwright.async_api import async_playwright +from selenium import webdriver + +_logger = logging.getLogger(__name__) + +def we_are_frozen(): + # All of the modules are built-in to the interpreter, e.g., by py2exe + return hasattr(sys, "frozen") + +def module_path(): + if we_are_frozen(): + return os.path.dirname(sys.executable) + return os.path.dirname(__file__) + +CWD = module_path() +TEST_WEBSITE = "http://example.com" + +# region utils +def exit(): + loop = asyncio.get_event_loop() + print("Stop") + loop.stop() + +def ask_exit(): + for task in asyncio.Task.all_tasks(): + task.cancel() + asyncio.ensure_future(exit()) + +def run_async_function(func_to_run, *args): + new_loop = asyncio.new_event_loop() + try: + for sig in (signal.SIGINT, signal.SIGTERM): + new_loop.add_signal_handler(sig, ask_exit) + asyncio.set_event_loop(new_loop) + return new_loop.run_until_complete(func_to_run(*args)) + finally: + new_loop.close() +# endregion + +# region base tests +async def test_crawl4ai_async(ref_url): + browser_config = BrowserConfig(verbose=True) + run_config = CrawlerRunConfig() + + crawler = AsyncWebCrawler(config=browser_config) + await crawler.start() + await crawler.arun(url=ref_url, config=run_config ) + await crawler.close() + +def test_crawl4ai(ref_url): + try: + run_async_function(test_crawl4ai_async, ref_url) + except Exception as e: + err = str(e) + print("Error! " + err[:75] + '..' * (len(err) > 75)) + +async def test_playwright_async(url): + async with async_playwright() as playwright: + await asyncio.sleep(0) + chromium = playwright.chromium + browser = await chromium.launch(headless=True) + page = await browser.new_page() + await page.goto(url) + title = await page.title() + print(title) + await browser.close() + +def test_playwright(ref_url): + try: + run_async_function(test_playwright_async, ref_url) + except Exception as e: + err = str(e) + print("Error! " + err[:75] + '..' * (len(err) > 75)) + +async def test_selenium_async(url): + options = webdriver.ChromeOptions() + options.add_argument("--headless") + driver = webdriver.Chrome(options) + driver.get(url) + print(driver.title) + driver.quit() + +def test_selenium(ref_url): + try: + run_async_function(test_selenium_async, ref_url) + except Exception as e: + err = str(e) + print("Error! " + err[:75] + '..' * (len(err) > 75)) +# endregion + +# region subprocess +async def test_playwright_subprocess(ref_url): + proc = await asyncio.create_subprocess_exec( + sys.executable, '-c', f"from test import test_playwright; test_playwright('{ref_url}')", + stdout=asyncio.subprocess.PIPE, cwd=CWD) + + data = await proc.stdout.readline() + line = data.decode('utf-8').rstrip() + + await proc.wait() + return line + +async def test_selenium_subprocess(ref_url): + proc = await asyncio.create_subprocess_exec( + sys.executable, '-c', f"from test import test_selenium; test_selenium('{ref_url}')", + stdout=asyncio.subprocess.PIPE, cwd=CWD) + + data = await proc.stdout.readline() + line = data.decode('utf-8').rstrip() + + await proc.wait() + try: + await asyncio.wait(0) + except: + pass + return line + +async def test_chromium_subprocess(ref_url): + try: + exec = "/home/yoni/.cache/ms-playwright/chromium_headless_shell-1155/chrome-linux/headless_shell" + proc = await asyncio.create_subprocess_exec( + exec, + "--single-process", + "--no-sandbox", + "--disable-gpu", + "--disable-gpu-compositing", + "--headless", + "--mute-audio", + "--dump-dom", + ref_url, + stdout=asyncio.subprocess.PIPE, cwd=CWD) + + data = await proc.stdout.readline() + line = data.decode('utf-8').rstrip() + + await proc.wait() + if line == '': + return "No output." + return line + except: + return "Error." +# endregion + +# region subprocess with unlimited resource +async def test_playwright_subprocess_with_unlimited_limit(ref_url): + rlimit = resource.RLIMIT_AS + soft, hard = resource.getrlimit(rlimit) + resource.prlimit(0, rlimit, (resource.RLIM_INFINITY, resource.RLIM_INFINITY)) + + proc = await asyncio.create_subprocess_exec( + sys.executable, '-c', f"from test import test_playwright; test_playwright('{ref_url}')", + stdout=asyncio.subprocess.PIPE, cwd=CWD) + + data = await proc.stdout.readline() + line = data.decode('utf-8').rstrip() + + await proc.wait() + return line + +def test_playwright_subprocess_with_unlimited_limit_sync(ref_url): + line = run_async_function(test_playwright_subprocess_with_unlimited_limit, ref_url) + print(line) + +async def test_playwright_sub_subprocess(ref_url): + proc = await asyncio.create_subprocess_exec( + sys.executable, '-c', f"from test import test_playwright_subprocess_with_unlimited_limit_sync; test_playwright_subprocess_with_unlimited_limit_sync('{ref_url}')", + stdout=asyncio.subprocess.PIPE, cwd=CWD) + + data = await proc.stdout.readline() + line = data.decode('utf-8').rstrip() + + await proc.wait() + return line + +async def test_selenium_subprocess_with_unlimited_limit(ref_url): + rlimit = resource.RLIMIT_AS + soft, hard = resource.getrlimit(rlimit) + resource.prlimit(0, rlimit, (resource.RLIM_INFINITY, resource.RLIM_INFINITY)) + + proc = await asyncio.create_subprocess_exec( + sys.executable, '-c', f"from test import test_selenium; test_selenium('{ref_url}')", + stdout=asyncio.subprocess.PIPE, cwd=CWD) + + data = await proc.stdout.readline() + line = data.decode('utf-8').rstrip() + + await proc.wait() + return line + +def test_selenium_subprocess_with_unlimited_limit_sync(ref_url): + line = run_async_function(test_selenium_subprocess_with_unlimited_limit, ref_url) + print(line) + +async def test_selenium_sub_subprocess(ref_url): + proc = await asyncio.create_subprocess_exec( + sys.executable, '-c', f"from test import test_selenium_subprocess_with_unlimited_limit_sync; test_selenium_subprocess_with_unlimited_limit_sync('{ref_url}')", + stdout=asyncio.subprocess.PIPE, cwd=CWD) + + data = await proc.stdout.readline() + line = data.decode('utf-8').rstrip() + + await proc.wait() + return line + +# endregion + +# region multiprocess +def test_playwright_with_unlimited_limit_multiprocess_child(q, ref_url): + rlimit = resource.RLIMIT_AS + soft, hard = resource.getrlimit(rlimit) + resource.prlimit(0, rlimit, (resource.RLIM_INFINITY, resource.RLIM_INFINITY)) + + line = run_async_function(test_playwright_subprocess, ref_url) + + q.put(line) + +def test_playwright_with_unlimited_limit_multiprocess_parent(ref_url): + ctx = get_context('spawn') + q = ctx.Queue() + + p = Process(target=test_playwright_with_unlimited_limit_multiprocess_child, args=(q, ref_url)) + p.start() + print(q.get()) + p.join() + +def test_selenium_with_unlimited_limit_multiprocess_child(q, ref_url): + rlimit = resource.RLIMIT_AS + soft, hard = resource.getrlimit(rlimit) + resource.prlimit(0, rlimit, (resource.RLIM_INFINITY, resource.RLIM_INFINITY)) + + line = run_async_function(test_selenium_subprocess, ref_url) + + q.put(line) + +def test_selenium_with_unlimited_limit_multiprocess_parent(ref_url): + ctx = get_context('spawn') + q = ctx.Queue() + + p = Process(target=test_selenium_with_unlimited_limit_multiprocess_child, args=(q, ref_url)) + p.start() + print(q.get()) + p.join() +# endregion + +def run_tests(): + print("----------crawl4ai") + test_crawl4ai(TEST_WEBSITE) + + print("----------playwright") + test_playwright(TEST_WEBSITE) + + print("----------selenium") + test_selenium(TEST_WEBSITE) + +def run_tests_subprocess(): + print("----------Playwright inside sub process") + line = run_async_function(test_playwright_subprocess, TEST_WEBSITE) + print(line) + + print("----------Selenium inside sub process") + line = run_async_function(test_selenium_subprocess, TEST_WEBSITE) + print(line) + + print("----------Chromium inside sub process") + line = run_async_function(test_chromium_subprocess, TEST_WEBSITE) + print(line) + +def run_tests_subprocess_with_limit(limit): + rlimit = resource.RLIMIT_AS + soft, hard = resource.getrlimit(rlimit) + resource.setrlimit(rlimit, (limit, hard)) + + soft, hard = resource.getrlimit(rlimit) + print("Limit before: ", soft, " : ", hard) + + run_tests_subprocess() + + soft, hard = resource.getrlimit(rlimit) + print("Limit after: ", soft, " : ", hard) + + resource.setrlimit(rlimit, (soft, hard)) + +def run_tests_with_unlimited_limit(limit): + rlimit = resource.RLIMIT_AS + soft, hard = resource.getrlimit(rlimit) + + print("----------Playwright inside sub process") + resource.setrlimit(rlimit, (limit, hard)) + soft, hard = resource.getrlimit(rlimit) + + print("Limit before: ", soft, " : ", hard) + line = run_async_function(test_playwright_subprocess_with_unlimited_limit, TEST_WEBSITE) + print(line) + + soft, hard = resource.getrlimit(rlimit) + print("Limit after: ", soft, " : ", hard) + + print("----------Playwright inside sub sub process") + resource.setrlimit(rlimit, (limit, hard)) + soft, hard = resource.getrlimit(rlimit) + + print("Limit before: ", soft, " : ", hard) + line = run_async_function(test_playwright_sub_subprocess, TEST_WEBSITE) + print(line) + + soft, hard = resource.getrlimit(rlimit) + print("Limit after: ", soft, " : ", hard) + + print("----------Playright inside multi process") + resource.setrlimit(rlimit, (limit, hard)) + soft, hard = resource.getrlimit(rlimit) + + print("Limit before: ", soft, " : ", hard) + test_playwright_with_unlimited_limit_multiprocess_parent(TEST_WEBSITE) + + soft, hard = resource.getrlimit(rlimit) + print("Limit after: ", soft, " : ", hard) + + print("----------Selenium inside sub process") + resource.setrlimit(rlimit, (limit, hard)) + soft, hard = resource.getrlimit(rlimit) + + print("Limit before: ", soft, " : ", hard) + line = run_async_function(test_selenium_subprocess_with_unlimited_limit, TEST_WEBSITE) + print(line) + + soft, hard = resource.getrlimit(rlimit) + print("Limit after: ", soft, " : ", hard) + + print("----------Selenium inside sub sub process") + resource.setrlimit(rlimit, (limit, hard)) + soft, hard = resource.getrlimit(rlimit) + + print("Limit before: ", soft, " : ", hard) + line = run_async_function(test_selenium_sub_subprocess, TEST_WEBSITE) + print(line) + + soft, hard = resource.getrlimit(rlimit) + print("Limit after: ", soft, " : ", hard) + + print("----------Selenium inside multi process") + resource.setrlimit(rlimit, (limit, hard)) + soft, hard = resource.getrlimit(rlimit) + + print("Limit before: ", soft, " : ", hard) + test_playwright_with_unlimited_limit_multiprocess_parent(TEST_WEBSITE) + soft, hard = resource.getrlimit(rlimit) + print("Limit after: ", soft, " : ", hard) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("operation", choices=['base', 'sub', 'slim', 'slila', 'multi']) + + args = parser.parse_args() + if args.operation == "base": + print("##### Base Test") + run_tests() + elif args.operation == "sub": + print("##### Subprocess Test") + run_tests_subprocess() + elif args.operation == "slim": + limit = 2684354560 + print("##### Test with limit") + run_tests_subprocess_with_limit(limit) + elif args.operation == "slila": + # limit = 8589934592 #64 + limit = 137438953472 #128 + print("##### Test with limit") + run_tests_subprocess_with_limit(limit) + elif args.operation == "multi": + limit = 2684354560 + print("##### Test with unlimited limit") + run_tests_with_unlimited_limit(limit) + +if __name__ == "__main__": + main() diff --git a/scraper_test/wizard/__init__.py b/scraper_test/wizard/__init__.py new file mode 100644 index 0000000..264c8f9 --- /dev/null +++ b/scraper_test/wizard/__init__.py @@ -0,0 +1 @@ +from . import scrap \ No newline at end of file diff --git a/scraper_test/wizard/async_utils.py b/scraper_test/wizard/async_utils.py new file mode 100644 index 0000000..0e63060 --- /dev/null +++ b/scraper_test/wizard/async_utils.py @@ -0,0 +1,11 @@ +import logging +_logger = logging.getLogger(__name__) +import asyncio + +def run_async_function(func_to_run, *args): + new_loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(new_loop) + return new_loop.run_until_complete(func_to_run(*args)) + finally: + new_loop.close() diff --git a/scraper_test/wizard/crawl_utils.py b/scraper_test/wizard/crawl_utils.py new file mode 100644 index 0000000..548e718 --- /dev/null +++ b/scraper_test/wizard/crawl_utils.py @@ -0,0 +1,53 @@ +import logging +_logger = logging.getLogger(__name__) + +import asyncio +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig + +from playwright.async_api import async_playwright +from selenium import webdriver + +from . import async_utils + +async def test_crawl4ai_async(ref_url): + browser_config = BrowserConfig(verbose=True) + run_config = CrawlerRunConfig() + + crawler = AsyncWebCrawler(config=browser_config) + await crawler.start() + await crawler.arun( + url=ref_url, + config=run_config + ) + await crawler.close() + + +def test_crawl4ai(ref_url): + async_utils.run_async_function(test_crawl4ai_async, ref_url) + +async def test_playwright_async(url): + async with async_playwright() as playwright: + await asyncio.sleep(0) + chromium = playwright.chromium + browser = await chromium.launch( + timeout=0, + headless=True, + traces_dir="traces" + ) + page = await browser.new_page() + await page.goto(url) + await page.title() + await browser.close() + +def test_playwright(ref_url): + async_utils.run_async_function(test_playwright_async, ref_url) + +async def test_selenium_async(url): + options = webdriver.ChromeOptions() + options.add_argument("--headless") + driver = webdriver.Chrome(options) + driver.get(url) + driver.quit() + +def test_selenium(ref_url): + async_utils.run_async_function(test_selenium_async, ref_url) \ No newline at end of file diff --git a/scraper_test/wizard/scrap.py b/scraper_test/wizard/scrap.py new file mode 100644 index 0000000..2392f6b --- /dev/null +++ b/scraper_test/wizard/scrap.py @@ -0,0 +1,79 @@ +import logging + +_logger = logging.getLogger(__name__) + +import re +from odoo import _, models, fields + +from .crawl_utils import test_playwright, test_selenium, test_crawl4ai + +class ScraperWizard(models.TransientModel): + _name = "scrap.wizard" + _description = "Scraper Test" + + url = fields.Char( + "Url", + required=True, + help="Url", + default="http://localhost:8069/ai_doc/static/html/index.html", + ) + + def _get_result(self, message = None): + if not message: + res = { + 'type': 'ir.actions.act_window', + 'name': "Scraper Test", + 'target': 'new', + 'view_mode': 'form', + 'res_model': 'scrap.wizard', + 'res_id': self.id, + } + if message: + res = { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'info', + 'title': 'Result', + 'message': message + }, + } + + return res + + def action_test_crawl4ai(self): + msg = "Ok!" + try: + text = self.url + test_crawl4ai(text) + except Exception as e: + _logger.error(e); + err = str(e) + msg = "Error! " + err[:75] + '..' * (len(err) > 75) + + return self._get_result(msg) + + + def action_test_playwright(self): + msg = "Ok!" + try: + text = self.url + test_playwright(text) + except Exception as e: + _logger.error(e); + err = str(e) + msg = "Error! " + err[:75] + '..' * (len(err) > 75) + + return self._get_result(msg) + + def action_test_selenium(self): + msg = "Ok!" + try: + text = self.url + test_selenium(text) + except Exception as e: + _logger.error(e); + err = str(e) + msg = "Error! " + err[:75] + '..' * (len(err) > 75) + + return self._get_result(msg) diff --git a/scraper_test/wizard/scrap.xml b/scraper_test/wizard/scrap.xml new file mode 100644 index 0000000..c2a834f --- /dev/null +++ b/scraper_test/wizard/scrap.xml @@ -0,0 +1,34 @@ + + + + scrap.wizard.view.form + scrap.wizard + +
+ + + + +
+
+
+
+
+ + + Scraper Test + ir.actions.act_window + scrap.wizard + form + new + + + + + +