From f78dc34aeea47ae27635fb043499b63ef4365b8a Mon Sep 17 00:00:00 2001 From: KaySar12 Date: Tue, 19 Aug 2025 15:05:55 +0700 Subject: [PATCH] update --- cheat_web/README.md | 25 + cheat_web/__init__.py | 3 + cheat_web/__manifest__.py | 28 + cheat_web/controllers/__init__.py | 1 + cheat_web/controllers/main.py | 24 + cheat_web/models/__init__.py | 4 + cheat_web/models/cheat_web.py | 29 + cheat_web/models/ir_action.py | 11 + cheat_web/models/ir_ui_view.py | 25 + cheat_web/models/res_users_settings.py | 8 + cheat_web/rng/cheat_view.rng | 21 + cheat_web/rng/hello_view.rng | 15 + cheat_web/rng/statistic_view.rng | 15 + cheat_web/security/ir.model.access.csv | 2 + cheat_web/static/description/icon.png | Bin 0 -> 20487 bytes cheat_web/static/img/exploring-odoo.png | Bin 0 -> 2489 bytes cheat_web/static/src/cheat_owl.js | 136 + cheat_web/static/src/cheat_owl.xml | 199 ++ cheat_web/static/src/cheat_owl_qweb.js | 79 + cheat_web/static/src/cheat_owl_qweb.scss | 0 cheat_web/static/src/cheat_owl_qweb.xml | 404 +++ .../src/cheat_owl_qweb_inline_template.js | 11 + cheat_web/static/src/cheat_web.css | 63 + cheat_web/static/src/cheat_web.js | 298 ++ cheat_web/static/src/cheat_web.xml | 152 + .../src/core/collapsible/collapsible.js | 17 + .../src/core/collapsible/collapsible.scss | 0 .../src/core/collapsible/collapsible.xml | 23 + .../static/src/core/haccordion/haccordion.js | 25 + .../src/core/haccordion/haccordion.scss | 71 + .../static/src/core/haccordion/haccordion.xml | 24 + .../src/views/cheat/cheat_arch_parser.js | 41 + .../src/views/cheat/cheat_controller.js | 98 + .../src/views/cheat/cheat_controller.xml | 18 + .../static/src/views/cheat/cheat_model.js | 46 + .../static/src/views/cheat/cheat_renderer.js | 43 + .../static/src/views/cheat/cheat_renderer.xml | 47 + .../static/src/views/cheat/cheat_view.js | 33 + .../src/views/hello/hello_controller.js | 18 + .../src/views/hello/hello_controller.xml | 13 + .../static/src/views/hello/hello_view.js | 20 + .../views/statistic/statistic_controller.js | 32 + .../views/statistic/statistic_controller.xml | 10 + .../src/views/statistic/statistic_model.js | 21 + .../src/views/statistic/statistic_renderer.js | 26 + .../views/statistic/statistic_renderer.xml | 42 + .../src/views/statistic/statistic_view.js | 25 + cheat_web/tools/__init__.py | 1 + cheat_web/tools/view_validation.py | 34 + cheat_web/views/cheat_web_views.xml | 107 + cheat_web/views/contacts_views.xml | 36 + node_ui/README.md | 21 + node_ui/__init__.py | 0 node_ui/__manifest__.py | 25 + node_ui/static/images/align-center.svg | 4 + node_ui/static/images/align-end.svg | 5 + node_ui/static/images/align-start.svg | 5 + node_ui/static/images/credits.txt | 1 + node_ui/static/images/flow-chart-line.svg | 1 + node_ui/static/images/flowchart-outline.svg | 1 + node_ui/static/images/node-minus.svg | 4 + node_ui/static/images/node-plus.svg | 4 + node_ui/static/images/node-tree.svg | 1 + node_ui/static/images/node.svg | 2 + node_ui/static/images/reset.svg | 12 + node_ui/static/images/share-no-fill.svg | 4 + node_ui/static/images/share.svg | 4 + node_ui/static/images/textbox.svg | 2 + node_ui/static/src/node_ui/common.scss | 20 + node_ui/static/src/node_ui/connection.js | 123 + node_ui/static/src/node_ui/connection.xml | 26 + node_ui/static/src/node_ui/document.js | 174 ++ node_ui/static/src/node_ui/document.xml | 17 + node_ui/static/src/node_ui/models.js | 575 ++++ node_ui/static/src/node_ui/node.js | 239 ++ node_ui/static/src/node_ui/node.xml | 65 + node_ui/static/src/node_ui/node_ui.js | 338 +++ node_ui/static/src/node_ui/node_ui.scss | 169 ++ node_ui/static/src/node_ui/node_ui.xml | 89 + node_ui/static/src/node_ui/utils.js | 209 ++ node_ui/views/node_ui_views.xml | 13 + node_ui_basics/README.md | 21 + node_ui_basics/__init__.py | 0 node_ui_basics/__manifest__.py | 24 + node_ui_basics/static/src/canvas_basics.js | 242 ++ node_ui_basics/static/src/canvas_basics.xml | 117 + .../static/src/canvas_connection.js | 134 + .../static/src/canvas_connection.xml | 71 + node_ui_basics/static/src/canvas_konva.js | 171 ++ node_ui_basics/static/src/canvas_konva.xml | 18 + node_ui_basics/static/src/canvas_nodes.js | 148 + node_ui_basics/static/src/canvas_nodes.xml | 47 + node_ui_basics/static/src/movable_div.js | 49 + node_ui_basics/static/src/movable_div.xml | 23 + node_ui_basics/static/src/node_ui_basics.scss | 68 + node_ui_basics/static/src/node_ui_svg.js | 348 +++ node_ui_basics/static/src/node_ui_svg.xml | 120 + node_ui_basics/static/src/svg_basics.js | 55 + node_ui_basics/static/src/svg_basics.xml | 178 ++ node_ui_basics/static/src/svg_bezier.js | 125 + node_ui_basics/static/src/svg_bezier.xml | 41 + node_ui_basics/static/src/svg_connection.js | 179 ++ node_ui_basics/static/src/svg_connection.xml | 73 + node_ui_basics/static/src/svg_nodes.js | 113 + node_ui_basics/static/src/svg_nodes.xml | 56 + node_ui_basics/static/src/utils/utils.js | 30 + node_ui_basics/views/node_ui_basics_views.xml | 97 + quickboard/README.md | 22 + quickboard/__init__.py | 3 + quickboard/__manifest__.py | 36 + quickboard/controllers/__init__.py | 2 + quickboard/controllers/main.py | 297 ++ quickboard/models/__init__.py | 3 + quickboard/models/quickboard_item.py | 162 + quickboard/models/res_users_settings.py | 30 + quickboard/security/ir.model.access.csv | 5 + quickboard/security/quickboard_security.xml | 24 + quickboard/static/description/icon.png | Bin 0 -> 20831 bytes quickboard/static/description/icon.svg | 63 + quickboard/static/src/core/colors.js | 32 + .../src/core/qb_color_list/qb_color_list.js | 55 + .../src/core/qb_color_list/qb_color_list.xml | 21 + quickboard/static/src/css/quickboard.css | 28 + .../static/src/quickboard/quickboard.js | 241 ++ .../static/src/quickboard/quickboard.xml | 88 + .../static/src/quickboard/quickboard_item.js | 36 + .../static/src/quickboard/quickboard_item.xml | 7 + .../src/quickboard/quickboard_item_base.js | 73 + .../src/quickboard/quickboard_item_basic.js | 102 + .../src/quickboard/quickboard_item_basic.xml | 24 + .../src/quickboard/quickboard_item_chart.js | 263 ++ .../src/quickboard/quickboard_item_chart.xml | 19 + .../src/quickboard/quickboard_item_list.js | 112 + .../src/quickboard/quickboard_item_list.xml | 41 + .../src/quickboard/quickboard_service.js | 58 + .../standard_quickboard_item_props.js | 10 + quickboard/static/src/quickboard_loader.js | 20 + .../qb_color_picker/qb_color_picker_field.js | 63 + .../qb_color_picker/qb_color_picker_field.xml | 12 + .../views/fields/qb_icon_picker/fa_icons.js | 2702 +++++++++++++++++ .../qb_icon_picker/qb_icon_picker_field.js | 35 + .../qb_icon_picker/qb_icon_picker_field.xml | 19 + quickboard/views/quickboard_item_views.xml | 93 + quickboard/views/quickboard_views.xml | 14 + quickboard/wizard/__init__.py | 2 + quickboard/wizard/ai/__init__.py | 7 + quickboard/wizard/ai/consts.py | 239 ++ quickboard/wizard/ai/json_validator_agent.py | 115 + .../wizard/ai/quickboard_ai_generator.py | 628 ++++ quickboard/wizard/quickboard_generator.py | 133 + .../wizard/quickboard_generator_views.xml | 34 + scraper_test/README.md | 20 + scraper_test/__init__.py | 1 + scraper_test/__manifest__.py | 18 + scraper_test/security/ir.model.access.csv | 2 + scraper_test/test.py | 386 +++ scraper_test/wizard/__init__.py | 1 + scraper_test/wizard/async_utils.py | 11 + scraper_test/wizard/crawl_utils.py | 53 + scraper_test/wizard/scrap.py | 79 + scraper_test/wizard/scrap.xml | 34 + 161 files changed, 13703 insertions(+) create mode 100644 cheat_web/README.md create mode 100644 cheat_web/__init__.py create mode 100644 cheat_web/__manifest__.py create mode 100644 cheat_web/controllers/__init__.py create mode 100644 cheat_web/controllers/main.py create mode 100644 cheat_web/models/__init__.py create mode 100644 cheat_web/models/cheat_web.py create mode 100644 cheat_web/models/ir_action.py create mode 100644 cheat_web/models/ir_ui_view.py create mode 100644 cheat_web/models/res_users_settings.py create mode 100644 cheat_web/rng/cheat_view.rng create mode 100644 cheat_web/rng/hello_view.rng create mode 100644 cheat_web/rng/statistic_view.rng create mode 100644 cheat_web/security/ir.model.access.csv create mode 100644 cheat_web/static/description/icon.png create mode 100644 cheat_web/static/img/exploring-odoo.png create mode 100644 cheat_web/static/src/cheat_owl.js create mode 100644 cheat_web/static/src/cheat_owl.xml create mode 100644 cheat_web/static/src/cheat_owl_qweb.js create mode 100644 cheat_web/static/src/cheat_owl_qweb.scss create mode 100644 cheat_web/static/src/cheat_owl_qweb.xml create mode 100644 cheat_web/static/src/cheat_owl_qweb_inline_template.js create mode 100644 cheat_web/static/src/cheat_web.css create mode 100644 cheat_web/static/src/cheat_web.js create mode 100644 cheat_web/static/src/cheat_web.xml create mode 100644 cheat_web/static/src/core/collapsible/collapsible.js create mode 100644 cheat_web/static/src/core/collapsible/collapsible.scss create mode 100644 cheat_web/static/src/core/collapsible/collapsible.xml create mode 100644 cheat_web/static/src/core/haccordion/haccordion.js create mode 100644 cheat_web/static/src/core/haccordion/haccordion.scss create mode 100644 cheat_web/static/src/core/haccordion/haccordion.xml create mode 100644 cheat_web/static/src/views/cheat/cheat_arch_parser.js create mode 100644 cheat_web/static/src/views/cheat/cheat_controller.js create mode 100644 cheat_web/static/src/views/cheat/cheat_controller.xml create mode 100644 cheat_web/static/src/views/cheat/cheat_model.js create mode 100644 cheat_web/static/src/views/cheat/cheat_renderer.js create mode 100644 cheat_web/static/src/views/cheat/cheat_renderer.xml create mode 100644 cheat_web/static/src/views/cheat/cheat_view.js create mode 100644 cheat_web/static/src/views/hello/hello_controller.js create mode 100644 cheat_web/static/src/views/hello/hello_controller.xml create mode 100644 cheat_web/static/src/views/hello/hello_view.js create mode 100644 cheat_web/static/src/views/statistic/statistic_controller.js create mode 100644 cheat_web/static/src/views/statistic/statistic_controller.xml create mode 100644 cheat_web/static/src/views/statistic/statistic_model.js create mode 100644 cheat_web/static/src/views/statistic/statistic_renderer.js create mode 100644 cheat_web/static/src/views/statistic/statistic_renderer.xml create mode 100644 cheat_web/static/src/views/statistic/statistic_view.js create mode 100644 cheat_web/tools/__init__.py create mode 100644 cheat_web/tools/view_validation.py create mode 100644 cheat_web/views/cheat_web_views.xml create mode 100644 cheat_web/views/contacts_views.xml create mode 100644 node_ui/README.md create mode 100644 node_ui/__init__.py create mode 100644 node_ui/__manifest__.py create mode 100644 node_ui/static/images/align-center.svg create mode 100644 node_ui/static/images/align-end.svg create mode 100644 node_ui/static/images/align-start.svg create mode 100644 node_ui/static/images/credits.txt create mode 100644 node_ui/static/images/flow-chart-line.svg create mode 100644 node_ui/static/images/flowchart-outline.svg create mode 100644 node_ui/static/images/node-minus.svg create mode 100644 node_ui/static/images/node-plus.svg create mode 100644 node_ui/static/images/node-tree.svg create mode 100644 node_ui/static/images/node.svg create mode 100644 node_ui/static/images/reset.svg create mode 100644 node_ui/static/images/share-no-fill.svg create mode 100644 node_ui/static/images/share.svg create mode 100644 node_ui/static/images/textbox.svg create mode 100644 node_ui/static/src/node_ui/common.scss create mode 100644 node_ui/static/src/node_ui/connection.js create mode 100644 node_ui/static/src/node_ui/connection.xml create mode 100644 node_ui/static/src/node_ui/document.js create mode 100644 node_ui/static/src/node_ui/document.xml create mode 100644 node_ui/static/src/node_ui/models.js create mode 100644 node_ui/static/src/node_ui/node.js create mode 100644 node_ui/static/src/node_ui/node.xml create mode 100644 node_ui/static/src/node_ui/node_ui.js create mode 100644 node_ui/static/src/node_ui/node_ui.scss create mode 100644 node_ui/static/src/node_ui/node_ui.xml create mode 100644 node_ui/static/src/node_ui/utils.js create mode 100644 node_ui/views/node_ui_views.xml create mode 100644 node_ui_basics/README.md create mode 100644 node_ui_basics/__init__.py create mode 100644 node_ui_basics/__manifest__.py create mode 100644 node_ui_basics/static/src/canvas_basics.js create mode 100644 node_ui_basics/static/src/canvas_basics.xml create mode 100644 node_ui_basics/static/src/canvas_connection.js create mode 100644 node_ui_basics/static/src/canvas_connection.xml create mode 100644 node_ui_basics/static/src/canvas_konva.js create mode 100644 node_ui_basics/static/src/canvas_konva.xml create mode 100644 node_ui_basics/static/src/canvas_nodes.js create mode 100644 node_ui_basics/static/src/canvas_nodes.xml create mode 100644 node_ui_basics/static/src/movable_div.js create mode 100644 node_ui_basics/static/src/movable_div.xml create mode 100644 node_ui_basics/static/src/node_ui_basics.scss create mode 100644 node_ui_basics/static/src/node_ui_svg.js create mode 100644 node_ui_basics/static/src/node_ui_svg.xml create mode 100644 node_ui_basics/static/src/svg_basics.js create mode 100644 node_ui_basics/static/src/svg_basics.xml create mode 100644 node_ui_basics/static/src/svg_bezier.js create mode 100644 node_ui_basics/static/src/svg_bezier.xml create mode 100644 node_ui_basics/static/src/svg_connection.js create mode 100644 node_ui_basics/static/src/svg_connection.xml create mode 100644 node_ui_basics/static/src/svg_nodes.js create mode 100644 node_ui_basics/static/src/svg_nodes.xml create mode 100644 node_ui_basics/static/src/utils/utils.js create mode 100644 node_ui_basics/views/node_ui_basics_views.xml create mode 100644 quickboard/README.md create mode 100644 quickboard/__init__.py create mode 100644 quickboard/__manifest__.py create mode 100644 quickboard/controllers/__init__.py create mode 100644 quickboard/controllers/main.py create mode 100644 quickboard/models/__init__.py create mode 100644 quickboard/models/quickboard_item.py create mode 100644 quickboard/models/res_users_settings.py create mode 100644 quickboard/security/ir.model.access.csv create mode 100644 quickboard/security/quickboard_security.xml create mode 100644 quickboard/static/description/icon.png create mode 100644 quickboard/static/description/icon.svg create mode 100644 quickboard/static/src/core/colors.js create mode 100644 quickboard/static/src/core/qb_color_list/qb_color_list.js create mode 100644 quickboard/static/src/core/qb_color_list/qb_color_list.xml create mode 100644 quickboard/static/src/css/quickboard.css create mode 100644 quickboard/static/src/quickboard/quickboard.js create mode 100644 quickboard/static/src/quickboard/quickboard.xml create mode 100644 quickboard/static/src/quickboard/quickboard_item.js create mode 100644 quickboard/static/src/quickboard/quickboard_item.xml create mode 100644 quickboard/static/src/quickboard/quickboard_item_base.js create mode 100644 quickboard/static/src/quickboard/quickboard_item_basic.js create mode 100644 quickboard/static/src/quickboard/quickboard_item_basic.xml create mode 100644 quickboard/static/src/quickboard/quickboard_item_chart.js create mode 100644 quickboard/static/src/quickboard/quickboard_item_chart.xml create mode 100644 quickboard/static/src/quickboard/quickboard_item_list.js create mode 100644 quickboard/static/src/quickboard/quickboard_item_list.xml create mode 100644 quickboard/static/src/quickboard/quickboard_service.js create mode 100644 quickboard/static/src/quickboard/standard_quickboard_item_props.js create mode 100644 quickboard/static/src/quickboard_loader.js create mode 100644 quickboard/static/src/views/fields/qb_color_picker/qb_color_picker_field.js create mode 100644 quickboard/static/src/views/fields/qb_color_picker/qb_color_picker_field.xml create mode 100644 quickboard/static/src/views/fields/qb_icon_picker/fa_icons.js create mode 100644 quickboard/static/src/views/fields/qb_icon_picker/qb_icon_picker_field.js create mode 100644 quickboard/static/src/views/fields/qb_icon_picker/qb_icon_picker_field.xml create mode 100644 quickboard/views/quickboard_item_views.xml create mode 100644 quickboard/views/quickboard_views.xml create mode 100644 quickboard/wizard/__init__.py create mode 100644 quickboard/wizard/ai/__init__.py create mode 100644 quickboard/wizard/ai/consts.py create mode 100644 quickboard/wizard/ai/json_validator_agent.py create mode 100644 quickboard/wizard/ai/quickboard_ai_generator.py create mode 100644 quickboard/wizard/quickboard_generator.py create mode 100644 quickboard/wizard/quickboard_generator_views.xml create mode 100644 scraper_test/README.md create mode 100644 scraper_test/__init__.py create mode 100644 scraper_test/__manifest__.py create mode 100644 scraper_test/security/ir.model.access.csv create mode 100644 scraper_test/test.py create mode 100644 scraper_test/wizard/__init__.py create mode 100644 scraper_test/wizard/async_utils.py create mode 100644 scraper_test/wizard/crawl_utils.py create mode 100644 scraper_test/wizard/scrap.py create mode 100644 scraper_test/wizard/scrap.xml 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 0000000000000000000000000000000000000000..00b2c54aa44f3af8d2b346a49e8114d560779024 GIT binary patch literal 20487 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 o4_Hlj!7VgObEZLLd;7oh`v- zi!=X$1ahmILBqdp(YDLBZjlg(oWnnWvP~#{TcTo=H96{ZSU?obC&C{>qtWy)g$70X z`CRtb4~w{vHEV|18a!b~IDY0z_6+}z9#2r#QJZ#d?xTREb2sVSW;P9Sz=l$c~PLagU#i$V!0&#Cc{U9yBmNQ z=fCU~LnGV`MZ&!TJVo!a3GjTPUsaolsVrmb#0@eYKnF2Dg`uDa+mL+=xuSHDxvSC{ z3}MGGnox_skAP;N0%{Sr6RAb6hFVxfGTajo%bYFc400b?9P$8KVl?Cp)rIzT^;Nt* zC7u!#W&^2XDE_@f%VF!wa>Zhi-1*Cljkzr4%>bT}0NWpT#j;O4w@)p#h}`6h-c zZ#}<<4O5;7nggr8k%J3k0turY zagDIn>ZV(cDexBf3(eYfJGRW)+r;}Fgx0}CnGYCnaY$7g2+x1$bZv*y^^5z@P(->4 zem~LSMB^c+00pYm+7r2uJc;aS4@dGO zrMLaSug>M2i_+Mr2ofPH3pj`yZ|eBXyVh|}bVXr%`ik3WArDR z-{x;Zc)6fO!bSzABcRqIztDlvTW?Dx9$MTpWs)|N3GUDaghc~)XAj=9DP_;9XDeJB zgq`Cb)AK4qbGN0PCFdCrs3`%!yGOwyPA{=f-9T-9} zdt3i-!ZX6)Bd_+LJPW0{!JwSnj9D1Kiu=tl8a7$_oSFwemxnMbPqZ|(Q>@%t%{@`? zb~gtg@%I$dQ(U2I2DN>~>q&yTlmwi!t%eq}A@+Q8vsZ;?BVyU6eWQDN|61dt4}^U= zh0>&HLW@I{>z|_!fw|rJtq!N}V@2tQ$u%9DuUoitsKXQylD}SJ@xIsomFG+``1L*QCERVK-WGnAg*s@JUn0gd`h2@9JA5T1oHXGZzX|fN|FvD)8 z(r1$LG$~jVz`<0N&`FamY;l#N#JW zte|wW0=^b+;pD2x)@$yR>TXsWxem+Ic%=PiA9)8ae$OGaSW7~`_M74DjX;<0bU}yk zWHgNf!%_OZZ+(K&KQ=tJEhCO;sN6OmkvFA1j8bDMi&&4RjHU7FT|M$TfUt__tI-{e zM=nEHNKy})qCqW}#XJZi=(oYHCdgH0ME;47CmK55 z8R+&zGA{V|U2r{{u<m%jcVvwyVKwfpSzvTDyC&`K_Dh_h`SSr8YFz)mXoK{5&H9zr=L%$7QE-NN-IZ z1x^){8sxHyF3THfX01Okyd)cs5O@YZjpvn=tTv#e`WB8gYq6bqnrrmnxM(C*sKAnQGli^P&CN2L1Nb{!20oQpPGS~U?F4SgQiT}W|wEKY}e7}X}mg>Ysi z=qYs7#{Ry&$e{PZ;9cHM{(;=@AE9(5kE}qfh14|q?r}7?aF^gbh~P05Z2W$h9 { + 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 0000000000000000000000000000000000000000..9789f4b92b026a424362ea9c09933fa905524434 GIT binary patch literal 20831 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 BdJO;o literal 0 HcmV?d00001 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 + + + + + +