From 7e5d21b9a0fbf15df499b142ba583bae5e922791 Mon Sep 17 00:00:00 2001 From: William Braeckman Date: Mon, 10 Mar 2025 14:32:42 +0100 Subject: [PATCH 1/7] [REF] runbot: reorganize static folder - Move libs from ./static/src/libs to ./static/libs - Move img from ./static/src/img to ./static/img - Remove the ./js subfolder of src - Remove empty log_display.js file - Remove the unused runbot.assets_frontend bundle --- runbot/__manifest__.py | 16 ++------- runbot/static/{src => }/img/icon_killed.png | Bin runbot/static/{src => }/img/icon_killed.svg | 0 runbot/static/{src => }/img/icon_ko.png | Bin runbot/static/{src => }/img/icon_ko.svg | 0 runbot/static/{src => }/img/icon_ok.png | Bin runbot/static/{src => }/img/icon_ok.svg | 0 runbot/static/{src => }/img/icon_skipped.png | Bin runbot/static/{src => }/img/icon_skipped.svg | 0 runbot/static/{src => }/img/icon_warn.png | Bin runbot/static/{src => }/img/icon_warn.svg | 0 .../static/{src => }/libs/bootstrap/LICENSE | 0 .../libs/bootstrap/css/bootstrap.css | 0 .../{src => }/libs/bootstrap/js/alert.js | 0 .../libs/bootstrap/js/bootstrap.bundle.js | 0 .../{src => }/libs/bootstrap/js/button.js | 0 .../{src => }/libs/bootstrap/js/carousel.js | 0 .../{src => }/libs/bootstrap/js/collapse.js | 0 .../{src => }/libs/bootstrap/js/dropdown.js | 0 .../{src => }/libs/bootstrap/js/index.js | 0 .../{src => }/libs/bootstrap/js/modal.js | 0 .../{src => }/libs/bootstrap/js/popover.js | 0 .../{src => }/libs/bootstrap/js/scrollspy.js | 0 .../static/{src => }/libs/bootstrap/js/tab.js | 0 .../{src => }/libs/bootstrap/js/toast.js | 0 .../{src => }/libs/bootstrap/js/tooltip.js | 0 .../{src => }/libs/bootstrap/js/util.js | 0 .../{src => }/libs/diff_match_patch/LICENSE | 0 .../libs/diff_match_patch/diff_match_patch.js | 0 .../libs/fontawesome/css/font-awesome.css | 0 .../libs/fontawesome/fonts/FontAwesome.otf | Bin .../fontawesome/fonts/fontawesome-webfont.eot | Bin .../fontawesome/fonts/fontawesome-webfont.svg | 0 .../fontawesome/fonts/fontawesome-webfont.ttf | Bin .../fonts/fontawesome-webfont.woff | Bin .../fonts/fontawesome-webfont.woff2 | Bin .../{src => }/libs/jquery/jquery.browser.js | 0 runbot/static/{src => }/libs/jquery/jquery.js | 0 runbot/static/{src => }/libs/popper/popper.js | 0 runbot/static/src/{js => }/fields/fields.css | 0 runbot/static/src/{js => }/fields/fields.js | 0 .../src/{js => }/fields/tracking_value.js | 0 .../src/{js => }/fields/tracking_value.scss | 0 .../src/{js => }/fields/tracking_value.xml | 0 runbot/static/src/{js => frontend}/runbot.js | 0 runbot/static/src/{js => frontend}/stats.js | 0 runbot/static/src/js/log_display.js | 0 runbot/templates/build_stats.xml | 2 +- runbot/templates/utils.xml | 32 +++++++++--------- 49 files changed, 19 insertions(+), 31 deletions(-) rename runbot/static/{src => }/img/icon_killed.png (100%) rename runbot/static/{src => }/img/icon_killed.svg (100%) rename runbot/static/{src => }/img/icon_ko.png (100%) rename runbot/static/{src => }/img/icon_ko.svg (100%) rename runbot/static/{src => }/img/icon_ok.png (100%) rename runbot/static/{src => }/img/icon_ok.svg (100%) rename runbot/static/{src => }/img/icon_skipped.png (100%) rename runbot/static/{src => }/img/icon_skipped.svg (100%) rename runbot/static/{src => }/img/icon_warn.png (100%) rename runbot/static/{src => }/img/icon_warn.svg (100%) rename runbot/static/{src => }/libs/bootstrap/LICENSE (100%) rename runbot/static/{src => }/libs/bootstrap/css/bootstrap.css (100%) rename runbot/static/{src => }/libs/bootstrap/js/alert.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/bootstrap.bundle.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/button.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/carousel.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/collapse.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/dropdown.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/index.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/modal.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/popover.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/scrollspy.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/tab.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/toast.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/tooltip.js (100%) rename runbot/static/{src => }/libs/bootstrap/js/util.js (100%) rename runbot/static/{src => }/libs/diff_match_patch/LICENSE (100%) rename runbot/static/{src => }/libs/diff_match_patch/diff_match_patch.js (100%) rename runbot/static/{src => }/libs/fontawesome/css/font-awesome.css (100%) rename runbot/static/{src => }/libs/fontawesome/fonts/FontAwesome.otf (100%) rename runbot/static/{src => }/libs/fontawesome/fonts/fontawesome-webfont.eot (100%) rename runbot/static/{src => }/libs/fontawesome/fonts/fontawesome-webfont.svg (100%) rename runbot/static/{src => }/libs/fontawesome/fonts/fontawesome-webfont.ttf (100%) rename runbot/static/{src => }/libs/fontawesome/fonts/fontawesome-webfont.woff (100%) rename runbot/static/{src => }/libs/fontawesome/fonts/fontawesome-webfont.woff2 (100%) rename runbot/static/{src => }/libs/jquery/jquery.browser.js (100%) rename runbot/static/{src => }/libs/jquery/jquery.js (100%) rename runbot/static/{src => }/libs/popper/popper.js (100%) rename runbot/static/src/{js => }/fields/fields.css (100%) rename runbot/static/src/{js => }/fields/fields.js (100%) rename runbot/static/src/{js => }/fields/tracking_value.js (100%) rename runbot/static/src/{js => }/fields/tracking_value.scss (100%) rename runbot/static/src/{js => }/fields/tracking_value.xml (100%) rename runbot/static/src/{js => frontend}/runbot.js (100%) rename runbot/static/src/{js => frontend}/stats.js (100%) delete mode 100644 runbot/static/src/js/log_display.js diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 25a07b90a..66f56d21d 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -63,20 +63,8 @@ 'assets': { 'web.assets_backend': [ - 'runbot/static/src/libs/diff_match_patch/diff_match_patch.js', - 'runbot/static/src/js/fields/*', - ], - 'runbot.assets_frontend': [ - '/web/static/lib/bootstrap/dist/css/bootstrap.css', - '/web/static/src/libs/fontawesome/css/font-awesome.css', - '/runbot/static/src/css/runbot.css', - - '/web/static/lib/jquery/jquery.js', - '/web/static/lib/popper/popper.js', - #'/web/static/lib/bootstrap/js/dist/util.js', - '/web/static/lib/bootstrap/js/dist/dropdown.js', - '/web/static/lib/bootstrap/js/dist/collapse.js', - '/runbot/static/src/js/runbot.js', + 'runbot/static/libs/diff_match_patch/diff_match_patch.js', + 'runbot/static/src/fields/*', ], }, 'post_load': 'runbot_post_load', diff --git a/runbot/static/src/img/icon_killed.png b/runbot/static/img/icon_killed.png similarity index 100% rename from runbot/static/src/img/icon_killed.png rename to runbot/static/img/icon_killed.png diff --git a/runbot/static/src/img/icon_killed.svg b/runbot/static/img/icon_killed.svg similarity index 100% rename from runbot/static/src/img/icon_killed.svg rename to runbot/static/img/icon_killed.svg diff --git a/runbot/static/src/img/icon_ko.png b/runbot/static/img/icon_ko.png similarity index 100% rename from runbot/static/src/img/icon_ko.png rename to runbot/static/img/icon_ko.png diff --git a/runbot/static/src/img/icon_ko.svg b/runbot/static/img/icon_ko.svg similarity index 100% rename from runbot/static/src/img/icon_ko.svg rename to runbot/static/img/icon_ko.svg diff --git a/runbot/static/src/img/icon_ok.png b/runbot/static/img/icon_ok.png similarity index 100% rename from runbot/static/src/img/icon_ok.png rename to runbot/static/img/icon_ok.png diff --git a/runbot/static/src/img/icon_ok.svg b/runbot/static/img/icon_ok.svg similarity index 100% rename from runbot/static/src/img/icon_ok.svg rename to runbot/static/img/icon_ok.svg diff --git a/runbot/static/src/img/icon_skipped.png b/runbot/static/img/icon_skipped.png similarity index 100% rename from runbot/static/src/img/icon_skipped.png rename to runbot/static/img/icon_skipped.png diff --git a/runbot/static/src/img/icon_skipped.svg b/runbot/static/img/icon_skipped.svg similarity index 100% rename from runbot/static/src/img/icon_skipped.svg rename to runbot/static/img/icon_skipped.svg diff --git a/runbot/static/src/img/icon_warn.png b/runbot/static/img/icon_warn.png similarity index 100% rename from runbot/static/src/img/icon_warn.png rename to runbot/static/img/icon_warn.png diff --git a/runbot/static/src/img/icon_warn.svg b/runbot/static/img/icon_warn.svg similarity index 100% rename from runbot/static/src/img/icon_warn.svg rename to runbot/static/img/icon_warn.svg diff --git a/runbot/static/src/libs/bootstrap/LICENSE b/runbot/static/libs/bootstrap/LICENSE similarity index 100% rename from runbot/static/src/libs/bootstrap/LICENSE rename to runbot/static/libs/bootstrap/LICENSE diff --git a/runbot/static/src/libs/bootstrap/css/bootstrap.css b/runbot/static/libs/bootstrap/css/bootstrap.css similarity index 100% rename from runbot/static/src/libs/bootstrap/css/bootstrap.css rename to runbot/static/libs/bootstrap/css/bootstrap.css diff --git a/runbot/static/src/libs/bootstrap/js/alert.js b/runbot/static/libs/bootstrap/js/alert.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/alert.js rename to runbot/static/libs/bootstrap/js/alert.js diff --git a/runbot/static/src/libs/bootstrap/js/bootstrap.bundle.js b/runbot/static/libs/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/bootstrap.bundle.js rename to runbot/static/libs/bootstrap/js/bootstrap.bundle.js diff --git a/runbot/static/src/libs/bootstrap/js/button.js b/runbot/static/libs/bootstrap/js/button.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/button.js rename to runbot/static/libs/bootstrap/js/button.js diff --git a/runbot/static/src/libs/bootstrap/js/carousel.js b/runbot/static/libs/bootstrap/js/carousel.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/carousel.js rename to runbot/static/libs/bootstrap/js/carousel.js diff --git a/runbot/static/src/libs/bootstrap/js/collapse.js b/runbot/static/libs/bootstrap/js/collapse.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/collapse.js rename to runbot/static/libs/bootstrap/js/collapse.js diff --git a/runbot/static/src/libs/bootstrap/js/dropdown.js b/runbot/static/libs/bootstrap/js/dropdown.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/dropdown.js rename to runbot/static/libs/bootstrap/js/dropdown.js diff --git a/runbot/static/src/libs/bootstrap/js/index.js b/runbot/static/libs/bootstrap/js/index.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/index.js rename to runbot/static/libs/bootstrap/js/index.js diff --git a/runbot/static/src/libs/bootstrap/js/modal.js b/runbot/static/libs/bootstrap/js/modal.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/modal.js rename to runbot/static/libs/bootstrap/js/modal.js diff --git a/runbot/static/src/libs/bootstrap/js/popover.js b/runbot/static/libs/bootstrap/js/popover.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/popover.js rename to runbot/static/libs/bootstrap/js/popover.js diff --git a/runbot/static/src/libs/bootstrap/js/scrollspy.js b/runbot/static/libs/bootstrap/js/scrollspy.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/scrollspy.js rename to runbot/static/libs/bootstrap/js/scrollspy.js diff --git a/runbot/static/src/libs/bootstrap/js/tab.js b/runbot/static/libs/bootstrap/js/tab.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/tab.js rename to runbot/static/libs/bootstrap/js/tab.js diff --git a/runbot/static/src/libs/bootstrap/js/toast.js b/runbot/static/libs/bootstrap/js/toast.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/toast.js rename to runbot/static/libs/bootstrap/js/toast.js diff --git a/runbot/static/src/libs/bootstrap/js/tooltip.js b/runbot/static/libs/bootstrap/js/tooltip.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/tooltip.js rename to runbot/static/libs/bootstrap/js/tooltip.js diff --git a/runbot/static/src/libs/bootstrap/js/util.js b/runbot/static/libs/bootstrap/js/util.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/util.js rename to runbot/static/libs/bootstrap/js/util.js diff --git a/runbot/static/src/libs/diff_match_patch/LICENSE b/runbot/static/libs/diff_match_patch/LICENSE similarity index 100% rename from runbot/static/src/libs/diff_match_patch/LICENSE rename to runbot/static/libs/diff_match_patch/LICENSE diff --git a/runbot/static/src/libs/diff_match_patch/diff_match_patch.js b/runbot/static/libs/diff_match_patch/diff_match_patch.js similarity index 100% rename from runbot/static/src/libs/diff_match_patch/diff_match_patch.js rename to runbot/static/libs/diff_match_patch/diff_match_patch.js diff --git a/runbot/static/src/libs/fontawesome/css/font-awesome.css b/runbot/static/libs/fontawesome/css/font-awesome.css similarity index 100% rename from runbot/static/src/libs/fontawesome/css/font-awesome.css rename to runbot/static/libs/fontawesome/css/font-awesome.css diff --git a/runbot/static/src/libs/fontawesome/fonts/FontAwesome.otf b/runbot/static/libs/fontawesome/fonts/FontAwesome.otf similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/FontAwesome.otf rename to runbot/static/libs/fontawesome/fonts/FontAwesome.otf diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.eot b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.eot similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.eot rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.eot diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.svg b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.svg similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.svg rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.svg diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.ttf b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.ttf similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.ttf rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.ttf diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.woff similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.woff diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff2 b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.woff2 similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff2 rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.woff2 diff --git a/runbot/static/src/libs/jquery/jquery.browser.js b/runbot/static/libs/jquery/jquery.browser.js similarity index 100% rename from runbot/static/src/libs/jquery/jquery.browser.js rename to runbot/static/libs/jquery/jquery.browser.js diff --git a/runbot/static/src/libs/jquery/jquery.js b/runbot/static/libs/jquery/jquery.js similarity index 100% rename from runbot/static/src/libs/jquery/jquery.js rename to runbot/static/libs/jquery/jquery.js diff --git a/runbot/static/src/libs/popper/popper.js b/runbot/static/libs/popper/popper.js similarity index 100% rename from runbot/static/src/libs/popper/popper.js rename to runbot/static/libs/popper/popper.js diff --git a/runbot/static/src/js/fields/fields.css b/runbot/static/src/fields/fields.css similarity index 100% rename from runbot/static/src/js/fields/fields.css rename to runbot/static/src/fields/fields.css diff --git a/runbot/static/src/js/fields/fields.js b/runbot/static/src/fields/fields.js similarity index 100% rename from runbot/static/src/js/fields/fields.js rename to runbot/static/src/fields/fields.js diff --git a/runbot/static/src/js/fields/tracking_value.js b/runbot/static/src/fields/tracking_value.js similarity index 100% rename from runbot/static/src/js/fields/tracking_value.js rename to runbot/static/src/fields/tracking_value.js diff --git a/runbot/static/src/js/fields/tracking_value.scss b/runbot/static/src/fields/tracking_value.scss similarity index 100% rename from runbot/static/src/js/fields/tracking_value.scss rename to runbot/static/src/fields/tracking_value.scss diff --git a/runbot/static/src/js/fields/tracking_value.xml b/runbot/static/src/fields/tracking_value.xml similarity index 100% rename from runbot/static/src/js/fields/tracking_value.xml rename to runbot/static/src/fields/tracking_value.xml diff --git a/runbot/static/src/js/runbot.js b/runbot/static/src/frontend/runbot.js similarity index 100% rename from runbot/static/src/js/runbot.js rename to runbot/static/src/frontend/runbot.js diff --git a/runbot/static/src/js/stats.js b/runbot/static/src/frontend/stats.js similarity index 100% rename from runbot/static/src/js/stats.js rename to runbot/static/src/frontend/stats.js diff --git a/runbot/static/src/js/log_display.js b/runbot/static/src/js/log_display.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/runbot/templates/build_stats.xml b/runbot/templates/build_stats.xml index 5c0d815d5..5c2883f07 100644 --- a/runbot/templates/build_stats.xml +++ b/runbot/templates/build_stats.xml @@ -131,7 +131,7 @@ - + diff --git a/runbot/templates/utils.xml b/runbot/templates/utils.xml index 9b3f74cd0..10089f7e2 100644 --- a/runbot/templates/utils.xml +++ b/runbot/templates/utils.xml @@ -6,38 +6,38 @@ - <link rel="stylesheet" type="text/css" href="/runbot/static/src/libs/bootstrap/css/bootstrap.css"/> - <link rel="stylesheet" type="text/css" href="/runbot/static/src/libs/fontawesome/css/font-awesome.css"/> + <link rel="stylesheet" type="text/css" href="/runbot/static/libs/bootstrap/css/bootstrap.css"/> + <link rel="stylesheet" type="text/css" href="/runbot/static/libs/fontawesome/css/font-awesome.css"/> <link rel="stylesheet" type="text/css" href="/runbot/static/src/css/runbot.css"/> - <script src="/runbot/static/src/libs/jquery/jquery.js" type="text/javascript"/> - <script type="text/javascript" src="/runbot/static/src/libs/popper/popper.js"/> - <script type="text/javascript" src="/runbot/static/src/libs/bootstrap/js/bootstrap.bundle.js"/> - <script type="text/javascript" src="/runbot/static/src/js/runbot.js"/> + <script src="/runbot/static/libs/jquery/jquery.js" type="text/javascript"/> + <script type="text/javascript" src="/runbot/static/libs/popper/popper.js"/> + <script type="text/javascript" src="/runbot/static/libs/bootstrap/js/bootstrap.bundle.js"/> + <script type="text/javascript" src="/runbot/static/src/frontend/runbot.js"/> <t t-if="refresh"> <meta http-equiv="refresh" t-att-content="refresh"/> </t> <t t-if="not page_info_state or page_info_state == 'ok' or page_info_state not in ('warn', 'ko', 'skipped', 'killed', 'manually_killed')"> - <link rel="icon" type="image/png" href="/runbot/static/src/img/icon_ok.png"/> - <link rel="icon" type="image/svg+xml" href="/runbot/static/src/img/icon_ok.svg"/> + <link rel="icon" type="image/png" href="/runbot/static/img/icon_ok.png"/> + <link rel="icon" type="image/svg+xml" href="/runbot/static/img/icon_ok.svg"/> </t> <t t-elif="page_info_state == 'ko'"> - <link rel="icon" type="image/png" href="/runbot/static/src/img/icon_ko.png"/> - <link rel="icon" type="image/svg+xml" href="/runbot/static/src/img/icon_ko.svg"/> + <link rel="icon" type="image/png" href="/runbot/static/img/icon_ko.png"/> + <link rel="icon" type="image/svg+xml" href="/runbot/static/img/icon_ko.svg"/> </t> <t t-elif="page_info_state == 'warn'"> - <link rel="icon" type="image/png" href="/runbot/static/src/img/icon_warn.png"/> - <link rel="icon" type="image/svg+xml" href="/runbot/static/src/img/icon_warn.svg"/> + <link rel="icon" type="image/png" href="/runbot/static/img/icon_warn.png"/> + <link rel="icon" type="image/svg+xml" href="/runbot/static/img/icon_warn.svg"/> </t> <t t-elif="page_info_state == 'skipped'"> - <link rel="icon" type="image/png" href="/runbot/static/src/img/icon_skipped.png"/> - <link rel="icon" type="image/svg+xml" href="/runbot/static/src/img/icon_skipped.svg"/> + <link rel="icon" type="image/png" href="/runbot/static/img/icon_skipped.png"/> + <link rel="icon" type="image/svg+xml" href="/runbot/static/img/icon_skipped.svg"/> </t> <t t-elif="page_info_state == 'killed' or page_info_state == 'manually_killed'"> - <link rel="icon" type="image/png" href="/runbot/static/src/img/icon_killed.png"/> - <link rel="icon" type="image/svg+xml" href="/runbot/static/src/img/icon_killed.svg"/> + <link rel="icon" type="image/png" href="/runbot/static/img/icon_killed.png"/> + <link rel="icon" type="image/svg+xml" href="/runbot/static/img/icon_killed.svg"/> </t> </head> <body> From 8c17b91452beea19c07da02d2ad2946aba830b74 Mon Sep 17 00:00:00 2001 From: William Braeckman <wbr@odoo.com> Date: Mon, 10 Mar 2025 14:39:40 +0100 Subject: [PATCH 2/7] [FIX] runbot: export diff_match_patch properly The lib exports its data through the global object, an odoo module then re-exports the module to make it available to other odoo modules. --- runbot/__manifest__.py | 1 + runbot/static/libs/diff_match_patch/diff_match_patch.js | 2 +- runbot/static/src/diff_match_patch_module.js | 5 +++++ runbot/static/src/fields/tracking_value.js | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 runbot/static/src/diff_match_patch_module.js diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 66f56d21d..584a4170f 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -64,6 +64,7 @@ 'assets': { 'web.assets_backend': [ 'runbot/static/libs/diff_match_patch/diff_match_patch.js', + 'runbot/static/src/diff_match_patch_module.js', 'runbot/static/src/fields/*', ], }, diff --git a/runbot/static/libs/diff_match_patch/diff_match_patch.js b/runbot/static/libs/diff_match_patch/diff_match_patch.js index 0cce87f0f..cf7465367 100644 --- a/runbot/static/libs/diff_match_patch/diff_match_patch.js +++ b/runbot/static/libs/diff_match_patch/diff_match_patch.js @@ -2235,7 +2235,7 @@ var diff_match_patch = function() { // /** @suppress {globalThis} */ // this['DIFF_EQUAL'] = DIFF_EQUAL; -export { +(typeof window !== "undefined" ? window : this).DiffMatchPatch = { diff_match_patch, DIFF_DELETE, DIFF_INSERT, diff --git a/runbot/static/src/diff_match_patch_module.js b/runbot/static/src/diff_match_patch_module.js new file mode 100644 index 000000000..8591c88ca --- /dev/null +++ b/runbot/static/src/diff_match_patch_module.js @@ -0,0 +1,5 @@ +odoo.define("@runbot/diff_match_patch", [], function () { + 'use strict'; + + return DiffMatchPatch; +}); diff --git a/runbot/static/src/fields/tracking_value.js b/runbot/static/src/fields/tracking_value.js index b6403748b..14d0c82fc 100644 --- a/runbot/static/src/fields/tracking_value.js +++ b/runbot/static/src/fields/tracking_value.js @@ -1,7 +1,7 @@ /** @odoo-module **/ import { patch } from "@web/core/utils/patch"; import { Message } from "@mail/core/common/message"; -import { diff_match_patch } from "@runbot/libs/diff_match_patch/diff_match_patch"; +import { diff_match_patch } from "@runbot/diff_match_patch"; patch(Message.prototype, { setup() { From 67407e3ec232bd12c6015e72f61fb7e1a2e5ab23 Mon Sep 17 00:00:00 2001 From: William Braeckman <wbr@odoo.com> Date: Mon, 10 Mar 2025 14:51:45 +0100 Subject: [PATCH 3/7] [IMP] runbot: vendor owl and expose through odoo module --- runbot/static/libs/owl.js | 6223 +++++++++++++++++++++++++++++++ runbot/static/src/owl_module.js | 5 + 2 files changed, 6228 insertions(+) create mode 100644 runbot/static/libs/owl.js create mode 100644 runbot/static/src/owl_module.js diff --git a/runbot/static/libs/owl.js b/runbot/static/libs/owl.js new file mode 100644 index 000000000..a3b0eaba9 --- /dev/null +++ b/runbot/static/libs/owl.js @@ -0,0 +1,6223 @@ +(function (exports) { + 'use strict'; + + function filterOutModifiersFromData(dataList) { + dataList = dataList.slice(); + const modifiers = []; + let elm; + while ((elm = dataList[0]) && typeof elm === "string") { + modifiers.push(dataList.shift()); + } + return { modifiers, data: dataList }; + } + const config = { + // whether or not blockdom should normalize DOM whenever a block is created. + // Normalizing dom mean removing empty text nodes (or containing only spaces) + shouldNormalizeDom: true, + // this is the main event handler. Every event handler registered with blockdom + // will go through this function, giving it the data registered in the block + // and the event + mainEventHandler: (data, ev, currentTarget) => { + if (typeof data === "function") { + data(ev); + } + else if (Array.isArray(data)) { + data = filterOutModifiersFromData(data).data; + data[0](data[1], ev); + } + return false; + }, + }; + + // ----------------------------------------------------------------------------- + // Toggler node + // ----------------------------------------------------------------------------- + class VToggler { + constructor(key, child) { + this.key = key; + this.child = child; + } + mount(parent, afterNode) { + this.parentEl = parent; + this.child.mount(parent, afterNode); + } + moveBeforeDOMNode(node, parent) { + this.child.moveBeforeDOMNode(node, parent); + } + moveBeforeVNode(other, afterNode) { + this.moveBeforeDOMNode((other && other.firstNode()) || afterNode); + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + let child1 = this.child; + let child2 = other.child; + if (this.key === other.key) { + child1.patch(child2, withBeforeRemove); + } + else { + child2.mount(this.parentEl, child1.firstNode()); + if (withBeforeRemove) { + child1.beforeRemove(); + } + child1.remove(); + this.child = child2; + this.key = other.key; + } + } + beforeRemove() { + this.child.beforeRemove(); + } + remove() { + this.child.remove(); + } + firstNode() { + return this.child.firstNode(); + } + toString() { + return this.child.toString(); + } + } + function toggler(key, child) { + return new VToggler(key, child); + } + + // Custom error class that wraps error that happen in the owl lifecycle + class OwlError extends Error { + } + + const { setAttribute: elemSetAttribute, removeAttribute } = Element.prototype; + const tokenList = DOMTokenList.prototype; + const tokenListAdd = tokenList.add; + const tokenListRemove = tokenList.remove; + const isArray = Array.isArray; + const { split, trim } = String.prototype; + const wordRegexp = /\s+/; + /** + * We regroup here all code related to updating attributes in a very loose sense: + * attributes, properties and classs are all managed by the functions in this + * file. + */ + function setAttribute(key, value) { + switch (value) { + case false: + case undefined: + removeAttribute.call(this, key); + break; + case true: + elemSetAttribute.call(this, key, ""); + break; + default: + elemSetAttribute.call(this, key, value); + } + } + function createAttrUpdater(attr) { + return function (value) { + setAttribute.call(this, attr, value); + }; + } + function attrsSetter(attrs) { + if (isArray(attrs)) { + if (attrs[0] === "class") { + setClass.call(this, attrs[1]); + } + else { + setAttribute.call(this, attrs[0], attrs[1]); + } + } + else { + for (let k in attrs) { + if (k === "class") { + setClass.call(this, attrs[k]); + } + else { + setAttribute.call(this, k, attrs[k]); + } + } + } + } + function attrsUpdater(attrs, oldAttrs) { + if (isArray(attrs)) { + const name = attrs[0]; + const val = attrs[1]; + if (name === oldAttrs[0]) { + if (val === oldAttrs[1]) { + return; + } + if (name === "class") { + updateClass.call(this, val, oldAttrs[1]); + } + else { + setAttribute.call(this, name, val); + } + } + else { + removeAttribute.call(this, oldAttrs[0]); + setAttribute.call(this, name, val); + } + } + else { + for (let k in oldAttrs) { + if (!(k in attrs)) { + if (k === "class") { + updateClass.call(this, "", oldAttrs[k]); + } + else { + removeAttribute.call(this, k); + } + } + } + for (let k in attrs) { + const val = attrs[k]; + if (val !== oldAttrs[k]) { + if (k === "class") { + updateClass.call(this, val, oldAttrs[k]); + } + else { + setAttribute.call(this, k, val); + } + } + } + } + } + function toClassObj(expr) { + const result = {}; + switch (typeof expr) { + case "string": + // we transform here a list of classes into an object: + // 'hey you' becomes {hey: true, you: true} + const str = trim.call(expr); + if (!str) { + return {}; + } + let words = split.call(str, wordRegexp); + for (let i = 0, l = words.length; i < l; i++) { + result[words[i]] = true; + } + return result; + case "object": + // this is already an object but we may need to split keys: + // {'a': true, 'b c': true} should become {a: true, b: true, c: true} + for (let key in expr) { + const value = expr[key]; + if (value) { + key = trim.call(key); + if (!key) { + continue; + } + const words = split.call(key, wordRegexp); + for (let word of words) { + result[word] = value; + } + } + } + return result; + case "undefined": + return {}; + case "number": + return { [expr]: true }; + default: + return { [expr]: true }; + } + } + function setClass(val) { + val = val === "" ? {} : toClassObj(val); + // add classes + const cl = this.classList; + for (let c in val) { + tokenListAdd.call(cl, c); + } + } + function updateClass(val, oldVal) { + oldVal = oldVal === "" ? {} : toClassObj(oldVal); + val = val === "" ? {} : toClassObj(val); + const cl = this.classList; + // remove classes + for (let c in oldVal) { + if (!(c in val)) { + tokenListRemove.call(cl, c); + } + } + // add classes + for (let c in val) { + if (!(c in oldVal)) { + tokenListAdd.call(cl, c); + } + } + } + + /** + * Creates a batched version of a callback so that all calls to it in the same + * microtick will only call the original callback once. + * + * @param callback the callback to batch + * @returns a batched version of the original callback + */ + function batched(callback) { + let scheduled = false; + return async (...args) => { + if (!scheduled) { + scheduled = true; + await Promise.resolve(); + scheduled = false; + callback(...args); + } + }; + } + /** + * Determine whether the given element is contained in its ownerDocument: + * either directly or with a shadow root in between. + */ + function inOwnerDocument(el) { + if (!el) { + return false; + } + if (el.ownerDocument.contains(el)) { + return true; + } + const rootNode = el.getRootNode(); + return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host); + } + function validateTarget(target) { + // Get the document and HTMLElement corresponding to the target to allow mounting in iframes + const document = target && target.ownerDocument; + if (document) { + const HTMLElement = document.defaultView.HTMLElement; + if (target instanceof HTMLElement || target instanceof ShadowRoot) { + if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) { + throw new OwlError("Cannot mount a component on a detached dom node"); + } + return; + } + } + throw new OwlError("Cannot mount component: the target is not a valid DOM element"); + } + class EventBus extends EventTarget { + trigger(name, payload) { + this.dispatchEvent(new CustomEvent(name, { detail: payload })); + } + } + function whenReady(fn) { + return new Promise(function (resolve) { + if (document.readyState !== "loading") { + resolve(true); + } + else { + document.addEventListener("DOMContentLoaded", resolve, false); + } + }).then(fn || function () { }); + } + async function loadFile(url) { + const result = await fetch(url); + if (!result.ok) { + throw new OwlError("Error while fetching xml templates"); + } + return await result.text(); + } + /* + * This class just transports the fact that a string is safe + * to be injected as HTML. Overriding a JS primitive is quite painful though + * so we need to redfine toString and valueOf. + */ + class Markup extends String { + } + /* + * Marks a value as safe, that is, a value that can be injected as HTML directly. + * It should be used to wrap the value passed to a t-out directive to allow a raw rendering. + */ + function markup(value) { + return new Markup(value); + } + + function createEventHandler(rawEvent) { + const eventName = rawEvent.split(".")[0]; + const capture = rawEvent.includes(".capture"); + if (rawEvent.includes(".synthetic")) { + return createSyntheticHandler(eventName, capture); + } + else { + return createElementHandler(eventName, capture); + } + } + // Native listener + let nextNativeEventId = 1; + function createElementHandler(evName, capture = false) { + let eventKey = `__event__${evName}_${nextNativeEventId++}`; + if (capture) { + eventKey = `${eventKey}_capture`; + } + function listener(ev) { + const currentTarget = ev.currentTarget; + if (!currentTarget || !inOwnerDocument(currentTarget)) + return; + const data = currentTarget[eventKey]; + if (!data) + return; + config.mainEventHandler(data, ev, currentTarget); + } + function setup(data) { + this[eventKey] = data; + this.addEventListener(evName, listener, { capture }); + } + function remove() { + delete this[eventKey]; + this.removeEventListener(evName, listener, { capture }); + } + function update(data) { + this[eventKey] = data; + } + return { setup, update, remove }; + } + // Synthetic handler: a form of event delegation that allows placing only one + // listener per event type. + let nextSyntheticEventId = 1; + function createSyntheticHandler(evName, capture = false) { + let eventKey = `__event__synthetic_${evName}`; + if (capture) { + eventKey = `${eventKey}_capture`; + } + setupSyntheticEvent(evName, eventKey, capture); + const currentId = nextSyntheticEventId++; + function setup(data) { + const _data = this[eventKey] || {}; + _data[currentId] = data; + this[eventKey] = _data; + } + function remove() { + delete this[eventKey]; + } + return { setup, update: setup, remove }; + } + function nativeToSyntheticEvent(eventKey, event) { + let dom = event.target; + while (dom !== null) { + const _data = dom[eventKey]; + if (_data) { + for (const data of Object.values(_data)) { + const stopped = config.mainEventHandler(data, event, dom); + if (stopped) + return; + } + } + dom = dom.parentNode; + } + } + const CONFIGURED_SYNTHETIC_EVENTS = {}; + function setupSyntheticEvent(evName, eventKey, capture = false) { + if (CONFIGURED_SYNTHETIC_EVENTS[eventKey]) { + return; + } + document.addEventListener(evName, (event) => nativeToSyntheticEvent(eventKey, event), { + capture, + }); + CONFIGURED_SYNTHETIC_EVENTS[eventKey] = true; + } + + const getDescriptor$3 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$4 = Node.prototype; + const nodeInsertBefore$3 = nodeProto$4.insertBefore; + const nodeSetTextContent$1 = getDescriptor$3(nodeProto$4, "textContent").set; + const nodeRemoveChild$3 = nodeProto$4.removeChild; + // ----------------------------------------------------------------------------- + // Multi NODE + // ----------------------------------------------------------------------------- + class VMulti { + constructor(children) { + this.children = children; + } + mount(parent, afterNode) { + const children = this.children; + const l = children.length; + const anchors = new Array(l); + for (let i = 0; i < l; i++) { + let child = children[i]; + if (child) { + child.mount(parent, afterNode); + } + else { + const childAnchor = document.createTextNode(""); + anchors[i] = childAnchor; + nodeInsertBefore$3.call(parent, childAnchor, afterNode); + } + } + this.anchors = anchors; + this.parentEl = parent; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + const children = this.children; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + let child = children[i]; + if (child) { + child.moveBeforeDOMNode(node, parent); + } + else { + const anchor = anchors[i]; + nodeInsertBefore$3.call(parent, anchor, node); + } + } + } + moveBeforeVNode(other, afterNode) { + if (other) { + const next = other.children[0]; + afterNode = (next ? next.firstNode() : other.anchors[0]) || null; + } + const children = this.children; + const parent = this.parentEl; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + let child = children[i]; + if (child) { + child.moveBeforeVNode(null, afterNode); + } + else { + const anchor = anchors[i]; + nodeInsertBefore$3.call(parent, anchor, afterNode); + } + } + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + const children1 = this.children; + const children2 = other.children; + const anchors = this.anchors; + const parentEl = this.parentEl; + for (let i = 0, l = children1.length; i < l; i++) { + const vn1 = children1[i]; + const vn2 = children2[i]; + if (vn1) { + if (vn2) { + vn1.patch(vn2, withBeforeRemove); + } + else { + const afterNode = vn1.firstNode(); + const anchor = document.createTextNode(""); + anchors[i] = anchor; + nodeInsertBefore$3.call(parentEl, anchor, afterNode); + if (withBeforeRemove) { + vn1.beforeRemove(); + } + vn1.remove(); + children1[i] = undefined; + } + } + else if (vn2) { + children1[i] = vn2; + const anchor = anchors[i]; + vn2.mount(parentEl, anchor); + nodeRemoveChild$3.call(parentEl, anchor); + } + } + } + beforeRemove() { + const children = this.children; + for (let i = 0, l = children.length; i < l; i++) { + const child = children[i]; + if (child) { + child.beforeRemove(); + } + } + } + remove() { + const parentEl = this.parentEl; + if (this.isOnlyChild) { + nodeSetTextContent$1.call(parentEl, ""); + } + else { + const children = this.children; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + const child = children[i]; + if (child) { + child.remove(); + } + else { + nodeRemoveChild$3.call(parentEl, anchors[i]); + } + } + } + } + firstNode() { + const child = this.children[0]; + return child ? child.firstNode() : this.anchors[0]; + } + toString() { + return this.children.map((c) => (c ? c.toString() : "")).join(""); + } + } + function multi(children) { + return new VMulti(children); + } + + const getDescriptor$2 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$3 = Node.prototype; + const characterDataProto$1 = CharacterData.prototype; + const nodeInsertBefore$2 = nodeProto$3.insertBefore; + const characterDataSetData$1 = getDescriptor$2(characterDataProto$1, "data").set; + const nodeRemoveChild$2 = nodeProto$3.removeChild; + class VSimpleNode { + constructor(text) { + this.text = text; + } + mountNode(node, parent, afterNode) { + this.parentEl = parent; + nodeInsertBefore$2.call(parent, node, afterNode); + this.el = node; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + nodeInsertBefore$2.call(parent, this.el, node); + } + moveBeforeVNode(other, afterNode) { + nodeInsertBefore$2.call(this.parentEl, this.el, other ? other.el : afterNode); + } + beforeRemove() { } + remove() { + nodeRemoveChild$2.call(this.parentEl, this.el); + } + firstNode() { + return this.el; + } + toString() { + return this.text; + } + } + class VText$1 extends VSimpleNode { + mount(parent, afterNode) { + this.mountNode(document.createTextNode(toText(this.text)), parent, afterNode); + } + patch(other) { + const text2 = other.text; + if (this.text !== text2) { + characterDataSetData$1.call(this.el, toText(text2)); + this.text = text2; + } + } + } + class VComment extends VSimpleNode { + mount(parent, afterNode) { + this.mountNode(document.createComment(toText(this.text)), parent, afterNode); + } + patch() { } + } + function text(str) { + return new VText$1(str); + } + function comment(str) { + return new VComment(str); + } + function toText(value) { + switch (typeof value) { + case "string": + return value; + case "number": + return String(value); + case "boolean": + return value ? "true" : "false"; + default: + return value || ""; + } + } + + const getDescriptor$1 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$2 = Node.prototype; + const elementProto = Element.prototype; + const characterDataProto = CharacterData.prototype; + const characterDataSetData = getDescriptor$1(characterDataProto, "data").set; + const nodeGetFirstChild = getDescriptor$1(nodeProto$2, "firstChild").get; + const nodeGetNextSibling = getDescriptor$1(nodeProto$2, "nextSibling").get; + const NO_OP = () => { }; + function makePropSetter(name) { + return function setProp(value) { + // support 0, fallback to empty string for other falsy values + this[name] = value === 0 ? 0 : value ? value.valueOf() : ""; + }; + } + const cache$1 = {}; + /** + * Compiling blocks is a multi-step process: + * + * 1. build an IntermediateTree from the HTML element. This intermediate tree + * is a binary tree structure that encode dynamic info sub nodes, and the + * path required to reach them + * 2. process the tree to build a block context, which is an object that aggregate + * all dynamic info in a list, and also, all ref indexes. + * 3. process the context to build appropriate builder/setter functions + * 4. make a dynamic block class, which will efficiently collect references and + * create/update dynamic locations/children + * + * @param str + * @returns a new block type, that can build concrete blocks + */ + function createBlock(str) { + if (str in cache$1) { + return cache$1[str]; + } + // step 0: prepare html base element + const doc = new DOMParser().parseFromString(`<t>${str}</t>`, "text/xml"); + const node = doc.firstChild.firstChild; + if (config.shouldNormalizeDom) { + normalizeNode(node); + } + // step 1: prepare intermediate tree + const tree = buildTree(node); + // step 2: prepare block context + const context = buildContext(tree); + // step 3: build the final block class + const template = tree.el; + const Block = buildBlock(template, context); + cache$1[str] = Block; + return Block; + } + // ----------------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------------- + function normalizeNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + if (!/\S/.test(node.textContent)) { + node.remove(); + return; + } + } + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.tagName === "pre") { + return; + } + } + for (let i = node.childNodes.length - 1; i >= 0; --i) { + normalizeNode(node.childNodes.item(i)); + } + } + function buildTree(node, parent = null, domParentTree = null) { + switch (node.nodeType) { + case Node.ELEMENT_NODE: { + // HTMLElement + let currentNS = domParentTree && domParentTree.currentNS; + const tagName = node.tagName; + let el = undefined; + const info = []; + if (tagName.startsWith("block-text-")) { + const index = parseInt(tagName.slice(11), 10); + info.push({ type: "text", idx: index }); + el = document.createTextNode(""); + } + if (tagName.startsWith("block-child-")) { + if (!domParentTree.isRef) { + addRef(domParentTree); + } + const index = parseInt(tagName.slice(12), 10); + info.push({ type: "child", idx: index }); + el = document.createTextNode(""); + } + currentNS || (currentNS = node.namespaceURI); + if (!el) { + el = currentNS + ? document.createElementNS(currentNS, tagName) + : document.createElement(tagName); + } + if (el instanceof Element) { + if (!domParentTree) { + // some html elements may have side effects when setting their attributes. + // For example, setting the src attribute of an <img/> will trigger a + // request to get the corresponding image. This is something that we + // don't want at compile time. We avoid that by putting the content of + // the block in a <template/> element + const fragment = document.createElement("template").content; + fragment.appendChild(el); + } + const attrs = node.attributes; + for (let i = 0; i < attrs.length; i++) { + const attrName = attrs[i].name; + const attrValue = attrs[i].value; + if (attrName.startsWith("block-handler-")) { + const idx = parseInt(attrName.slice(14), 10); + info.push({ + type: "handler", + idx, + event: attrValue, + }); + } + else if (attrName.startsWith("block-attribute-")) { + const idx = parseInt(attrName.slice(16), 10); + info.push({ + type: "attribute", + idx, + name: attrValue, + tag: tagName, + }); + } + else if (attrName.startsWith("block-property-")) { + const idx = parseInt(attrName.slice(15), 10); + info.push({ + type: "property", + idx, + name: attrValue, + tag: tagName, + }); + } + else if (attrName === "block-attributes") { + info.push({ + type: "attributes", + idx: parseInt(attrValue, 10), + }); + } + else if (attrName === "block-ref") { + info.push({ + type: "ref", + idx: parseInt(attrValue, 10), + }); + } + else { + el.setAttribute(attrs[i].name, attrValue); + } + } + } + const tree = { + parent, + firstChild: null, + nextSibling: null, + el, + info, + refN: 0, + currentNS, + }; + if (node.firstChild) { + const childNode = node.childNodes[0]; + if (node.childNodes.length === 1 && + childNode.nodeType === Node.ELEMENT_NODE && + childNode.tagName.startsWith("block-child-")) { + const tagName = childNode.tagName; + const index = parseInt(tagName.slice(12), 10); + info.push({ idx: index, type: "child", isOnlyChild: true }); + } + else { + tree.firstChild = buildTree(node.firstChild, tree, tree); + el.appendChild(tree.firstChild.el); + let curNode = node.firstChild; + let curTree = tree.firstChild; + while ((curNode = curNode.nextSibling)) { + curTree.nextSibling = buildTree(curNode, curTree, tree); + el.appendChild(curTree.nextSibling.el); + curTree = curTree.nextSibling; + } + } + } + if (tree.info.length) { + addRef(tree); + } + return tree; + } + case Node.TEXT_NODE: + case Node.COMMENT_NODE: { + // text node or comment node + const el = node.nodeType === Node.TEXT_NODE + ? document.createTextNode(node.textContent) + : document.createComment(node.textContent); + return { + parent: parent, + firstChild: null, + nextSibling: null, + el, + info: [], + refN: 0, + currentNS: null, + }; + } + } + throw new OwlError("boom"); + } + function addRef(tree) { + tree.isRef = true; + do { + tree.refN++; + } while ((tree = tree.parent)); + } + function parentTree(tree) { + let parent = tree.parent; + while (parent && parent.nextSibling === tree) { + tree = parent; + parent = parent.parent; + } + return parent; + } + function buildContext(tree, ctx, fromIdx) { + if (!ctx) { + const children = new Array(tree.info.filter((v) => v.type === "child").length); + ctx = { collectors: [], locations: [], children, cbRefs: [], refN: tree.refN, refList: [] }; + fromIdx = 0; + } + if (tree.refN) { + const initialIdx = fromIdx; + const isRef = tree.isRef; + const firstChild = tree.firstChild ? tree.firstChild.refN : 0; + const nextSibling = tree.nextSibling ? tree.nextSibling.refN : 0; + //node + if (isRef) { + for (let info of tree.info) { + info.refIdx = initialIdx; + } + tree.refIdx = initialIdx; + updateCtx(ctx, tree); + fromIdx++; + } + // right + if (nextSibling) { + const idx = fromIdx + firstChild; + ctx.collectors.push({ idx, prevIdx: initialIdx, getVal: nodeGetNextSibling }); + buildContext(tree.nextSibling, ctx, idx); + } + // left + if (firstChild) { + ctx.collectors.push({ idx: fromIdx, prevIdx: initialIdx, getVal: nodeGetFirstChild }); + buildContext(tree.firstChild, ctx, fromIdx); + } + } + return ctx; + } + function updateCtx(ctx, tree) { + for (let info of tree.info) { + switch (info.type) { + case "text": + ctx.locations.push({ + idx: info.idx, + refIdx: info.refIdx, + setData: setText, + updateData: setText, + }); + break; + case "child": + if (info.isOnlyChild) { + // tree is the parentnode here + ctx.children[info.idx] = { + parentRefIdx: info.refIdx, + isOnlyChild: true, + }; + } + else { + // tree is the anchor text node + ctx.children[info.idx] = { + parentRefIdx: parentTree(tree).refIdx, + afterRefIdx: info.refIdx, + }; + } + break; + case "property": { + const refIdx = info.refIdx; + const setProp = makePropSetter(info.name); + ctx.locations.push({ + idx: info.idx, + refIdx, + setData: setProp, + updateData: setProp, + }); + break; + } + case "attribute": { + const refIdx = info.refIdx; + let updater; + let setter; + if (info.name === "class") { + setter = setClass; + updater = updateClass; + } + else { + setter = createAttrUpdater(info.name); + updater = setter; + } + ctx.locations.push({ + idx: info.idx, + refIdx, + setData: setter, + updateData: updater, + }); + break; + } + case "attributes": + ctx.locations.push({ + idx: info.idx, + refIdx: info.refIdx, + setData: attrsSetter, + updateData: attrsUpdater, + }); + break; + case "handler": { + const { setup, update } = createEventHandler(info.event); + ctx.locations.push({ + idx: info.idx, + refIdx: info.refIdx, + setData: setup, + updateData: update, + }); + break; + } + case "ref": + const index = ctx.cbRefs.push(info.idx) - 1; + ctx.locations.push({ + idx: info.idx, + refIdx: info.refIdx, + setData: makeRefSetter(index, ctx.refList), + updateData: NO_OP, + }); + } + } + } + // ----------------------------------------------------------------------------- + // building the concrete block class + // ----------------------------------------------------------------------------- + function buildBlock(template, ctx) { + let B = createBlockClass(template, ctx); + if (ctx.cbRefs.length) { + const cbRefs = ctx.cbRefs; + const refList = ctx.refList; + let cbRefsNumber = cbRefs.length; + B = class extends B { + mount(parent, afterNode) { + refList.push(new Array(cbRefsNumber)); + super.mount(parent, afterNode); + for (let cbRef of refList.pop()) { + cbRef(); + } + } + remove() { + super.remove(); + for (let cbRef of cbRefs) { + let fn = this.data[cbRef]; + fn(null); + } + } + }; + } + if (ctx.children.length) { + B = class extends B { + constructor(data, children) { + super(data); + this.children = children; + } + }; + B.prototype.beforeRemove = VMulti.prototype.beforeRemove; + return (data, children = []) => new B(data, children); + } + return (data) => new B(data); + } + function createBlockClass(template, ctx) { + const { refN, collectors, children } = ctx; + const colN = collectors.length; + ctx.locations.sort((a, b) => a.idx - b.idx); + const locations = ctx.locations.map((loc) => ({ + refIdx: loc.refIdx, + setData: loc.setData, + updateData: loc.updateData, + })); + const locN = locations.length; + const childN = children.length; + const childrenLocs = children; + const isDynamic = refN > 0; + // these values are defined here to make them faster to lookup in the class + // block scope + const nodeCloneNode = nodeProto$2.cloneNode; + const nodeInsertBefore = nodeProto$2.insertBefore; + const elementRemove = elementProto.remove; + class Block { + constructor(data) { + this.data = data; + } + beforeRemove() { } + remove() { + elementRemove.call(this.el); + } + firstNode() { + return this.el; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + nodeInsertBefore.call(parent, this.el, node); + } + moveBeforeVNode(other, afterNode) { + nodeInsertBefore.call(this.parentEl, this.el, other ? other.el : afterNode); + } + toString() { + const div = document.createElement("div"); + this.mount(div, null); + return div.innerHTML; + } + mount(parent, afterNode) { + const el = nodeCloneNode.call(template, true); + nodeInsertBefore.call(parent, el, afterNode); + this.el = el; + this.parentEl = parent; + } + patch(other, withBeforeRemove) { } + } + if (isDynamic) { + Block.prototype.mount = function mount(parent, afterNode) { + const el = nodeCloneNode.call(template, true); + // collecting references + const refs = new Array(refN); + this.refs = refs; + refs[0] = el; + for (let i = 0; i < colN; i++) { + const w = collectors[i]; + refs[w.idx] = w.getVal.call(refs[w.prevIdx]); + } + // applying data to all update points + if (locN) { + const data = this.data; + for (let i = 0; i < locN; i++) { + const loc = locations[i]; + loc.setData.call(refs[loc.refIdx], data[i]); + } + } + nodeInsertBefore.call(parent, el, afterNode); + // preparing all children + if (childN) { + const children = this.children; + for (let i = 0; i < childN; i++) { + const child = children[i]; + if (child) { + const loc = childrenLocs[i]; + const afterNode = loc.afterRefIdx ? refs[loc.afterRefIdx] : null; + child.isOnlyChild = loc.isOnlyChild; + child.mount(refs[loc.parentRefIdx], afterNode); + } + } + } + this.el = el; + this.parentEl = parent; + }; + Block.prototype.patch = function patch(other, withBeforeRemove) { + if (this === other) { + return; + } + const refs = this.refs; + // update texts/attributes/ + if (locN) { + const data1 = this.data; + const data2 = other.data; + for (let i = 0; i < locN; i++) { + const val1 = data1[i]; + const val2 = data2[i]; + if (val1 !== val2) { + const loc = locations[i]; + loc.updateData.call(refs[loc.refIdx], val2, val1); + } + } + this.data = data2; + } + // update children + if (childN) { + let children1 = this.children; + const children2 = other.children; + for (let i = 0; i < childN; i++) { + const child1 = children1[i]; + const child2 = children2[i]; + if (child1) { + if (child2) { + child1.patch(child2, withBeforeRemove); + } + else { + if (withBeforeRemove) { + child1.beforeRemove(); + } + child1.remove(); + children1[i] = undefined; + } + } + else if (child2) { + const loc = childrenLocs[i]; + const afterNode = loc.afterRefIdx ? refs[loc.afterRefIdx] : null; + child2.mount(refs[loc.parentRefIdx], afterNode); + children1[i] = child2; + } + } + } + }; + } + return Block; + } + function setText(value) { + characterDataSetData.call(this, toText(value)); + } + function makeRefSetter(index, refs) { + return function setRef(fn) { + refs[refs.length - 1][index] = () => fn(this); + }; + } + + const getDescriptor = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$1 = Node.prototype; + const nodeInsertBefore$1 = nodeProto$1.insertBefore; + const nodeAppendChild = nodeProto$1.appendChild; + const nodeRemoveChild$1 = nodeProto$1.removeChild; + const nodeSetTextContent = getDescriptor(nodeProto$1, "textContent").set; + // ----------------------------------------------------------------------------- + // List Node + // ----------------------------------------------------------------------------- + class VList { + constructor(children) { + this.children = children; + } + mount(parent, afterNode) { + const children = this.children; + const _anchor = document.createTextNode(""); + this.anchor = _anchor; + nodeInsertBefore$1.call(parent, _anchor, afterNode); + const l = children.length; + if (l) { + const mount = children[0].mount; + for (let i = 0; i < l; i++) { + mount.call(children[i], parent, _anchor); + } + } + this.parentEl = parent; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + const children = this.children; + for (let i = 0, l = children.length; i < l; i++) { + children[i].moveBeforeDOMNode(node, parent); + } + parent.insertBefore(this.anchor, node); + } + moveBeforeVNode(other, afterNode) { + if (other) { + const next = other.children[0]; + afterNode = (next ? next.firstNode() : other.anchor) || null; + } + const children = this.children; + for (let i = 0, l = children.length; i < l; i++) { + children[i].moveBeforeVNode(null, afterNode); + } + this.parentEl.insertBefore(this.anchor, afterNode); + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + const ch1 = this.children; + const ch2 = other.children; + if (ch2.length === 0 && ch1.length === 0) { + return; + } + this.children = ch2; + const proto = ch2[0] || ch1[0]; + const { mount: cMount, patch: cPatch, remove: cRemove, beforeRemove, moveBeforeVNode: cMoveBefore, firstNode: cFirstNode, } = proto; + const _anchor = this.anchor; + const isOnlyChild = this.isOnlyChild; + const parent = this.parentEl; + // fast path: no new child => only remove + if (ch2.length === 0 && isOnlyChild) { + if (withBeforeRemove) { + for (let i = 0, l = ch1.length; i < l; i++) { + beforeRemove.call(ch1[i]); + } + } + nodeSetTextContent.call(parent, ""); + nodeAppendChild.call(parent, _anchor); + return; + } + let startIdx1 = 0; + let startIdx2 = 0; + let startVn1 = ch1[0]; + let startVn2 = ch2[0]; + let endIdx1 = ch1.length - 1; + let endIdx2 = ch2.length - 1; + let endVn1 = ch1[endIdx1]; + let endVn2 = ch2[endIdx2]; + let mapping = undefined; + while (startIdx1 <= endIdx1 && startIdx2 <= endIdx2) { + // ------------------------------------------------------------------- + if (startVn1 === null) { + startVn1 = ch1[++startIdx1]; + continue; + } + // ------------------------------------------------------------------- + if (endVn1 === null) { + endVn1 = ch1[--endIdx1]; + continue; + } + // ------------------------------------------------------------------- + let startKey1 = startVn1.key; + let startKey2 = startVn2.key; + if (startKey1 === startKey2) { + cPatch.call(startVn1, startVn2, withBeforeRemove); + ch2[startIdx2] = startVn1; + startVn1 = ch1[++startIdx1]; + startVn2 = ch2[++startIdx2]; + continue; + } + // ------------------------------------------------------------------- + let endKey1 = endVn1.key; + let endKey2 = endVn2.key; + if (endKey1 === endKey2) { + cPatch.call(endVn1, endVn2, withBeforeRemove); + ch2[endIdx2] = endVn1; + endVn1 = ch1[--endIdx1]; + endVn2 = ch2[--endIdx2]; + continue; + } + // ------------------------------------------------------------------- + if (startKey1 === endKey2) { + // bnode moved right + cPatch.call(startVn1, endVn2, withBeforeRemove); + ch2[endIdx2] = startVn1; + const nextChild = ch2[endIdx2 + 1]; + cMoveBefore.call(startVn1, nextChild, _anchor); + startVn1 = ch1[++startIdx1]; + endVn2 = ch2[--endIdx2]; + continue; + } + // ------------------------------------------------------------------- + if (endKey1 === startKey2) { + // bnode moved left + cPatch.call(endVn1, startVn2, withBeforeRemove); + ch2[startIdx2] = endVn1; + const nextChild = ch1[startIdx1]; + cMoveBefore.call(endVn1, nextChild, _anchor); + endVn1 = ch1[--endIdx1]; + startVn2 = ch2[++startIdx2]; + continue; + } + // ------------------------------------------------------------------- + mapping = mapping || createMapping(ch1, startIdx1, endIdx1); + let idxInOld = mapping[startKey2]; + if (idxInOld === undefined) { + cMount.call(startVn2, parent, cFirstNode.call(startVn1) || null); + } + else { + const elmToMove = ch1[idxInOld]; + cMoveBefore.call(elmToMove, startVn1, null); + cPatch.call(elmToMove, startVn2, withBeforeRemove); + ch2[startIdx2] = elmToMove; + ch1[idxInOld] = null; + } + startVn2 = ch2[++startIdx2]; + } + // --------------------------------------------------------------------- + if (startIdx1 <= endIdx1 || startIdx2 <= endIdx2) { + if (startIdx1 > endIdx1) { + const nextChild = ch2[endIdx2 + 1]; + const anchor = nextChild ? cFirstNode.call(nextChild) || null : _anchor; + for (let i = startIdx2; i <= endIdx2; i++) { + cMount.call(ch2[i], parent, anchor); + } + } + else { + for (let i = startIdx1; i <= endIdx1; i++) { + let ch = ch1[i]; + if (ch) { + if (withBeforeRemove) { + beforeRemove.call(ch); + } + cRemove.call(ch); + } + } + } + } + } + beforeRemove() { + const children = this.children; + const l = children.length; + if (l) { + const beforeRemove = children[0].beforeRemove; + for (let i = 0; i < l; i++) { + beforeRemove.call(children[i]); + } + } + } + remove() { + const { parentEl, anchor } = this; + if (this.isOnlyChild) { + nodeSetTextContent.call(parentEl, ""); + } + else { + const children = this.children; + const l = children.length; + if (l) { + const remove = children[0].remove; + for (let i = 0; i < l; i++) { + remove.call(children[i]); + } + } + nodeRemoveChild$1.call(parentEl, anchor); + } + } + firstNode() { + const child = this.children[0]; + return child ? child.firstNode() : undefined; + } + toString() { + return this.children.map((c) => c.toString()).join(""); + } + } + function list(children) { + return new VList(children); + } + function createMapping(ch1, startIdx1, endIdx2) { + let mapping = {}; + for (let i = startIdx1; i <= endIdx2; i++) { + mapping[ch1[i].key] = i; + } + return mapping; + } + + const nodeProto = Node.prototype; + const nodeInsertBefore = nodeProto.insertBefore; + const nodeRemoveChild = nodeProto.removeChild; + class VHtml { + constructor(html) { + this.content = []; + this.html = html; + } + mount(parent, afterNode) { + this.parentEl = parent; + const template = document.createElement("template"); + template.innerHTML = this.html; + this.content = [...template.content.childNodes]; + for (let elem of this.content) { + nodeInsertBefore.call(parent, elem, afterNode); + } + if (!this.content.length) { + const textNode = document.createTextNode(""); + this.content.push(textNode); + nodeInsertBefore.call(parent, textNode, afterNode); + } + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + for (let elem of this.content) { + nodeInsertBefore.call(parent, elem, node); + } + } + moveBeforeVNode(other, afterNode) { + const target = other ? other.content[0] : afterNode; + this.moveBeforeDOMNode(target); + } + patch(other) { + if (this === other) { + return; + } + const html2 = other.html; + if (this.html !== html2) { + const parent = this.parentEl; + // insert new html in front of current + const afterNode = this.content[0]; + const template = document.createElement("template"); + template.innerHTML = html2; + const content = [...template.content.childNodes]; + for (let elem of content) { + nodeInsertBefore.call(parent, elem, afterNode); + } + if (!content.length) { + const textNode = document.createTextNode(""); + content.push(textNode); + nodeInsertBefore.call(parent, textNode, afterNode); + } + // remove current content + this.remove(); + this.content = content; + this.html = other.html; + } + } + beforeRemove() { } + remove() { + const parent = this.parentEl; + for (let elem of this.content) { + nodeRemoveChild.call(parent, elem); + } + } + firstNode() { + return this.content[0]; + } + toString() { + return this.html; + } + } + function html(str) { + return new VHtml(str); + } + + function createCatcher(eventsSpec) { + const n = Object.keys(eventsSpec).length; + class VCatcher { + constructor(child, handlers) { + this.handlerFns = []; + this.afterNode = null; + this.child = child; + this.handlerData = handlers; + } + mount(parent, afterNode) { + this.parentEl = parent; + this.child.mount(parent, afterNode); + this.afterNode = document.createTextNode(""); + parent.insertBefore(this.afterNode, afterNode); + this.wrapHandlerData(); + for (let name in eventsSpec) { + const index = eventsSpec[name]; + const handler = createEventHandler(name); + this.handlerFns[index] = handler; + handler.setup.call(parent, this.handlerData[index]); + } + } + wrapHandlerData() { + for (let i = 0; i < n; i++) { + let handler = this.handlerData[i]; + // handler = [...mods, fn, comp], so we need to replace second to last elem + let idx = handler.length - 2; + let origFn = handler[idx]; + const self = this; + handler[idx] = function (ev) { + const target = ev.target; + let currentNode = self.child.firstNode(); + const afterNode = self.afterNode; + while (currentNode && currentNode !== afterNode) { + if (currentNode.contains(target)) { + return origFn.call(this, ev); + } + currentNode = currentNode.nextSibling; + } + }; + } + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + this.child.moveBeforeDOMNode(node, parent); + parent.insertBefore(this.afterNode, node); + } + moveBeforeVNode(other, afterNode) { + if (other) { + // check this with @ged-odoo for use in foreach + afterNode = other.firstNode() || afterNode; + } + this.child.moveBeforeVNode(other ? other.child : null, afterNode); + this.parentEl.insertBefore(this.afterNode, afterNode); + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + this.handlerData = other.handlerData; + this.wrapHandlerData(); + for (let i = 0; i < n; i++) { + this.handlerFns[i].update.call(this.parentEl, this.handlerData[i]); + } + this.child.patch(other.child, withBeforeRemove); + } + beforeRemove() { + this.child.beforeRemove(); + } + remove() { + for (let i = 0; i < n; i++) { + this.handlerFns[i].remove.call(this.parentEl); + } + this.child.remove(); + this.afterNode.remove(); + } + firstNode() { + return this.child.firstNode(); + } + toString() { + return this.child.toString(); + } + } + return function (child, handlers) { + return new VCatcher(child, handlers); + }; + } + + function mount$1(vnode, fixture, afterNode = null) { + vnode.mount(fixture, afterNode); + } + function patch(vnode1, vnode2, withBeforeRemove = false) { + vnode1.patch(vnode2, withBeforeRemove); + } + function remove(vnode, withBeforeRemove = false) { + if (withBeforeRemove) { + vnode.beforeRemove(); + } + vnode.remove(); + } + + // Maps fibers to thrown errors + const fibersInError = new WeakMap(); + const nodeErrorHandlers = new WeakMap(); + function _handleError(node, error) { + if (!node) { + return false; + } + const fiber = node.fiber; + if (fiber) { + fibersInError.set(fiber, error); + } + const errorHandlers = nodeErrorHandlers.get(node); + if (errorHandlers) { + let handled = false; + // execute in the opposite order + for (let i = errorHandlers.length - 1; i >= 0; i--) { + try { + errorHandlers[i](error); + handled = true; + break; + } + catch (e) { + error = e; + } + } + if (handled) { + return true; + } + } + return _handleError(node.parent, error); + } + function handleError(params) { + let { error } = params; + // Wrap error if it wasn't wrapped by wrapError (ie when not in dev mode) + if (!(error instanceof OwlError)) { + error = Object.assign(new OwlError(`An error occured in the owl lifecycle (see this Error's "cause" property)`), { cause: error }); + } + const node = "node" in params ? params.node : params.fiber.node; + const fiber = "fiber" in params ? params.fiber : node.fiber; + if (fiber) { + // resets the fibers on components if possible. This is important so that + // new renderings can be properly included in the initial one, if any. + let current = fiber; + do { + current.node.fiber = current; + current = current.parent; + } while (current); + fibersInError.set(fiber.root, error); + } + const handled = _handleError(node, error); + if (!handled) { + console.warn(`[Owl] Unhandled error. Destroying the root component`); + try { + node.app.destroy(); + } + catch (e) { + console.error(e); + } + throw error; + } + } + + function makeChildFiber(node, parent) { + let current = node.fiber; + if (current) { + cancelFibers(current.children); + current.root = null; + } + return new Fiber(node, parent); + } + function makeRootFiber(node) { + let current = node.fiber; + if (current) { + let root = current.root; + // lock root fiber because canceling children fibers may destroy components, + // which means any arbitrary code can be run in onWillDestroy, which may + // trigger new renderings + root.locked = true; + root.setCounter(root.counter + 1 - cancelFibers(current.children)); + root.locked = false; + current.children = []; + current.childrenMap = {}; + current.bdom = null; + if (fibersInError.has(current)) { + fibersInError.delete(current); + fibersInError.delete(root); + current.appliedToDom = false; + if (current instanceof RootFiber) { + // it is possible that this fiber is a fiber that crashed while being + // mounted, so the mounted list is possibly corrupted. We restore it to + // its normal initial state (which is empty list or a list with a mount + // fiber. + current.mounted = current instanceof MountFiber ? [current] : []; + } + } + return current; + } + const fiber = new RootFiber(node, null); + if (node.willPatch.length) { + fiber.willPatch.push(fiber); + } + if (node.patched.length) { + fiber.patched.push(fiber); + } + return fiber; + } + function throwOnRender() { + throw new OwlError("Attempted to render cancelled fiber"); + } + /** + * @returns number of not-yet rendered fibers cancelled + */ + function cancelFibers(fibers) { + let result = 0; + for (let fiber of fibers) { + let node = fiber.node; + fiber.render = throwOnRender; + if (node.status === 0 /* NEW */) { + node.cancel(); + } + node.fiber = null; + if (fiber.bdom) { + // if fiber has been rendered, this means that the component props have + // been updated. however, this fiber will not be patched to the dom, so + // it could happen that the next render compare the current props with + // the same props, and skip the render completely. With the next line, + // we kindly request the component code to force a render, so it works as + // expected. + node.forceNextRender = true; + } + else { + result++; + } + result += cancelFibers(fiber.children); + } + return result; + } + class Fiber { + constructor(node, parent) { + this.bdom = null; + this.children = []; + this.appliedToDom = false; + this.deep = false; + this.childrenMap = {}; + this.node = node; + this.parent = parent; + if (parent) { + this.deep = parent.deep; + const root = parent.root; + root.setCounter(root.counter + 1); + this.root = root; + parent.children.push(this); + } + else { + this.root = this; + } + } + render() { + // if some parent has a fiber => register in followup + let prev = this.root.node; + let scheduler = prev.app.scheduler; + let current = prev.parent; + while (current) { + if (current.fiber) { + let root = current.fiber.root; + if (root.counter === 0 && prev.parentKey in current.fiber.childrenMap) { + current = root.node; + } + else { + scheduler.delayedRenders.push(this); + return; + } + } + prev = current; + current = current.parent; + } + // there are no current rendering from above => we can render + this._render(); + } + _render() { + const node = this.node; + const root = this.root; + if (root) { + try { + this.bdom = true; + this.bdom = node.renderFn(); + } + catch (e) { + node.app.handleError({ node, error: e }); + } + root.setCounter(root.counter - 1); + } + } + } + class RootFiber extends Fiber { + constructor() { + super(...arguments); + this.counter = 1; + // only add stuff in this if they have registered some hooks + this.willPatch = []; + this.patched = []; + this.mounted = []; + // A fiber is typically locked when it is completing and the patch has not, or is being applied. + // i.e.: render triggered in onWillUnmount or in willPatch will be delayed + this.locked = false; + } + complete() { + const node = this.node; + this.locked = true; + let current = undefined; + let mountedFibers = this.mounted; + try { + // Step 1: calling all willPatch lifecycle hooks + for (current of this.willPatch) { + // because of the asynchronous nature of the rendering, some parts of the + // UI may have been rendered, then deleted in a followup rendering, and we + // do not want to call onWillPatch in that case. + let node = current.node; + if (node.fiber === current) { + const component = node.component; + for (let cb of node.willPatch) { + cb.call(component); + } + } + } + current = undefined; + // Step 2: patching the dom + node._patch(); + this.locked = false; + // Step 4: calling all mounted lifecycle hooks + while ((current = mountedFibers.pop())) { + current = current; + if (current.appliedToDom) { + for (let cb of current.node.mounted) { + cb(); + } + } + } + // Step 5: calling all patched hooks + let patchedFibers = this.patched; + while ((current = patchedFibers.pop())) { + current = current; + if (current.appliedToDom) { + for (let cb of current.node.patched) { + cb(); + } + } + } + } + catch (e) { + // if mountedFibers is not empty, this means that a crash occured while + // calling the mounted hooks of some component. So, there may still be + // some component that have been mounted, but for which the mounted hooks + // have not been called. Here, we remove the willUnmount hooks for these + // specific component to prevent a worse situation (willUnmount being + // called even though mounted has not been called) + for (let fiber of mountedFibers) { + fiber.node.willUnmount = []; + } + this.locked = false; + node.app.handleError({ fiber: current || this, error: e }); + } + } + setCounter(newValue) { + this.counter = newValue; + if (newValue === 0) { + this.node.app.scheduler.flush(); + } + } + } + class MountFiber extends RootFiber { + constructor(node, target, options = {}) { + super(node, null); + this.target = target; + this.position = options.position || "last-child"; + } + complete() { + let current = this; + try { + const node = this.node; + node.children = this.childrenMap; + node.app.constructor.validateTarget(this.target); + if (node.bdom) { + // this is a complicated situation: if we mount a fiber with an existing + // bdom, this means that this same fiber was already completed, mounted, + // but a crash occurred in some mounted hook. Then, it was handled and + // the new rendering is being applied. + node.updateDom(); + } + else { + node.bdom = this.bdom; + if (this.position === "last-child" || this.target.childNodes.length === 0) { + mount$1(node.bdom, this.target); + } + else { + const firstChild = this.target.childNodes[0]; + mount$1(node.bdom, this.target, firstChild); + } + } + // unregistering the fiber before mounted since it can do another render + // and that the current rendering is obviously completed + node.fiber = null; + node.status = 1 /* MOUNTED */; + this.appliedToDom = true; + let mountedFibers = this.mounted; + while ((current = mountedFibers.pop())) { + if (current.appliedToDom) { + for (let cb of current.node.mounted) { + cb(); + } + } + } + } + catch (e) { + this.node.app.handleError({ fiber: current, error: e }); + } + } + } + + // Special key to subscribe to, to be notified of key creation/deletion + const KEYCHANGES = Symbol("Key changes"); + // Used to specify the absence of a callback, can be used as WeakMap key but + // should only be used as a sentinel value and never called. + const NO_CALLBACK = () => { + throw new Error("Called NO_CALLBACK. Owl is broken, please report this to the maintainers."); + }; + const objectToString = Object.prototype.toString; + const objectHasOwnProperty = Object.prototype.hasOwnProperty; + // Use arrays because Array.includes is faster than Set.has for small arrays + const SUPPORTED_RAW_TYPES = ["Object", "Array", "Set", "Map", "WeakMap"]; + const COLLECTION_RAW_TYPES = ["Set", "Map", "WeakMap"]; + /** + * extract "RawType" from strings like "[object RawType]" => this lets us ignore + * many native objects such as Promise (whose toString is [object Promise]) + * or Date ([object Date]), while also supporting collections without using + * instanceof in a loop + * + * @param obj the object to check + * @returns the raw type of the object + */ + function rawType(obj) { + return objectToString.call(toRaw(obj)).slice(8, -1); + } + /** + * Checks whether a given value can be made into a reactive object. + * + * @param value the value to check + * @returns whether the value can be made reactive + */ + function canBeMadeReactive(value) { + if (typeof value !== "object") { + return false; + } + return SUPPORTED_RAW_TYPES.includes(rawType(value)); + } + /** + * Creates a reactive from the given object/callback if possible and returns it, + * returns the original object otherwise. + * + * @param value the value make reactive + * @returns a reactive for the given object when possible, the original otherwise + */ + function possiblyReactive(val, cb) { + return canBeMadeReactive(val) ? reactive(val, cb) : val; + } + const skipped = new WeakSet(); + /** + * Mark an object or array so that it is ignored by the reactivity system + * + * @param value the value to mark + * @returns the object itself + */ + function markRaw(value) { + skipped.add(value); + return value; + } + /** + * Given a reactive objet, return the raw (non reactive) underlying object + * + * @param value a reactive value + * @returns the underlying value + */ + function toRaw(value) { + return targets.has(value) ? targets.get(value) : value; + } + const targetToKeysToCallbacks = new WeakMap(); + /** + * Observes a given key on a target with an callback. The callback will be + * called when the given key changes on the target. + * + * @param target the target whose key should be observed + * @param key the key to observe (or Symbol(KEYCHANGES) for key creation + * or deletion) + * @param callback the function to call when the key changes + */ + function observeTargetKey(target, key, callback) { + if (callback === NO_CALLBACK) { + return; + } + if (!targetToKeysToCallbacks.get(target)) { + targetToKeysToCallbacks.set(target, new Map()); + } + const keyToCallbacks = targetToKeysToCallbacks.get(target); + if (!keyToCallbacks.get(key)) { + keyToCallbacks.set(key, new Set()); + } + keyToCallbacks.get(key).add(callback); + if (!callbacksToTargets.has(callback)) { + callbacksToTargets.set(callback, new Set()); + } + callbacksToTargets.get(callback).add(target); + } + /** + * Notify Reactives that are observing a given target that a key has changed on + * the target. + * + * @param target target whose Reactives should be notified that the target was + * changed. + * @param key the key that changed (or Symbol `KEYCHANGES` if a key was created + * or deleted) + */ + function notifyReactives(target, key) { + const keyToCallbacks = targetToKeysToCallbacks.get(target); + if (!keyToCallbacks) { + return; + } + const callbacks = keyToCallbacks.get(key); + if (!callbacks) { + return; + } + // Loop on copy because clearReactivesForCallback will modify the set in place + for (const callback of [...callbacks]) { + clearReactivesForCallback(callback); + callback(); + } + } + const callbacksToTargets = new WeakMap(); + /** + * Clears all subscriptions of the Reactives associated with a given callback. + * + * @param callback the callback for which the reactives need to be cleared + */ + function clearReactivesForCallback(callback) { + const targetsToClear = callbacksToTargets.get(callback); + if (!targetsToClear) { + return; + } + for (const target of targetsToClear) { + const observedKeys = targetToKeysToCallbacks.get(target); + if (!observedKeys) { + continue; + } + for (const [key, callbacks] of observedKeys.entries()) { + callbacks.delete(callback); + if (!callbacks.size) { + observedKeys.delete(key); + } + } + } + targetsToClear.clear(); + } + function getSubscriptions(callback) { + const targets = callbacksToTargets.get(callback) || []; + return [...targets].map((target) => { + const keysToCallbacks = targetToKeysToCallbacks.get(target); + let keys = []; + if (keysToCallbacks) { + for (const [key, cbs] of keysToCallbacks) { + if (cbs.has(callback)) { + keys.push(key); + } + } + } + return { target, keys }; + }); + } + // Maps reactive objects to the underlying target + const targets = new WeakMap(); + const reactiveCache = new WeakMap(); + /** + * Creates a reactive proxy for an object. Reading data on the reactive object + * subscribes to changes to the data. Writing data on the object will cause the + * notify callback to be called if there are suscriptions to that data. Nested + * objects and arrays are automatically made reactive as well. + * + * Whenever you are notified of a change, all subscriptions are cleared, and if + * you would like to be notified of any further changes, you should go read + * the underlying data again. We assume that if you don't go read it again after + * being notified, it means that you are no longer interested in that data. + * + * Subscriptions: + * + Reading a property on an object will subscribe you to changes in the value + * of that property. + * + Accessing an object's keys (eg with Object.keys or with `for..in`) will + * subscribe you to the creation/deletion of keys. Checking the presence of a + * key on the object with 'in' has the same effect. + * - getOwnPropertyDescriptor does not currently subscribe you to the property. + * This is a choice that was made because changing a key's value will trigger + * this trap and we do not want to subscribe by writes. This also means that + * Object.hasOwnProperty doesn't subscribe as it goes through this trap. + * + * @param target the object for which to create a reactive proxy + * @param callback the function to call when an observed property of the + * reactive has changed + * @returns a proxy that tracks changes to it + */ + function reactive(target, callback = NO_CALLBACK) { + if (!canBeMadeReactive(target)) { + throw new OwlError(`Cannot make the given value reactive`); + } + if (skipped.has(target)) { + return target; + } + if (targets.has(target)) { + // target is reactive, create a reactive on the underlying object instead + return reactive(targets.get(target), callback); + } + if (!reactiveCache.has(target)) { + reactiveCache.set(target, new WeakMap()); + } + const reactivesForTarget = reactiveCache.get(target); + if (!reactivesForTarget.has(callback)) { + const targetRawType = rawType(target); + const handler = COLLECTION_RAW_TYPES.includes(targetRawType) + ? collectionsProxyHandler(target, callback, targetRawType) + : basicProxyHandler(callback); + const proxy = new Proxy(target, handler); + reactivesForTarget.set(callback, proxy); + targets.set(proxy, target); + } + return reactivesForTarget.get(callback); + } + /** + * Creates a basic proxy handler for regular objects and arrays. + * + * @param callback @see reactive + * @returns a proxy handler object + */ + function basicProxyHandler(callback) { + return { + get(target, key, receiver) { + // non-writable non-configurable properties cannot be made reactive + const desc = Object.getOwnPropertyDescriptor(target, key); + if (desc && !desc.writable && !desc.configurable) { + return Reflect.get(target, key, receiver); + } + observeTargetKey(target, key, callback); + return possiblyReactive(Reflect.get(target, key, receiver), callback); + }, + set(target, key, value, receiver) { + const hadKey = objectHasOwnProperty.call(target, key); + const originalValue = Reflect.get(target, key, receiver); + const ret = Reflect.set(target, key, toRaw(value), receiver); + if (!hadKey && objectHasOwnProperty.call(target, key)) { + notifyReactives(target, KEYCHANGES); + } + // While Array length may trigger the set trap, it's not actually set by this + // method but is updated behind the scenes, and the trap is not called with the + // new value. We disable the "same-value-optimization" for it because of that. + if (originalValue !== Reflect.get(target, key, receiver) || + (key === "length" && Array.isArray(target))) { + notifyReactives(target, key); + } + return ret; + }, + deleteProperty(target, key) { + const ret = Reflect.deleteProperty(target, key); + // TODO: only notify when something was actually deleted + notifyReactives(target, KEYCHANGES); + notifyReactives(target, key); + return ret; + }, + ownKeys(target) { + observeTargetKey(target, KEYCHANGES, callback); + return Reflect.ownKeys(target); + }, + has(target, key) { + // TODO: this observes all key changes instead of only the presence of the argument key + // observing the key itself would observe value changes instead of presence changes + // so we may need a finer grained system to distinguish observing value vs presence. + observeTargetKey(target, KEYCHANGES, callback); + return Reflect.has(target, key); + }, + }; + } + /** + * Creates a function that will observe the key that is passed to it when called + * and delegates to the underlying method. + * + * @param methodName name of the method to delegate to + * @param target @see reactive + * @param callback @see reactive + */ + function makeKeyObserver(methodName, target, callback) { + return (key) => { + key = toRaw(key); + observeTargetKey(target, key, callback); + return possiblyReactive(target[methodName](key), callback); + }; + } + /** + * Creates an iterable that will delegate to the underlying iteration method and + * observe keys as necessary. + * + * @param methodName name of the method to delegate to + * @param target @see reactive + * @param callback @see reactive + */ + function makeIteratorObserver(methodName, target, callback) { + return function* () { + observeTargetKey(target, KEYCHANGES, callback); + const keys = target.keys(); + for (const item of target[methodName]()) { + const key = keys.next().value; + observeTargetKey(target, key, callback); + yield possiblyReactive(item, callback); + } + }; + } + /** + * Creates a forEach function that will delegate to forEach on the underlying + * collection while observing key changes, and keys as they're iterated over, + * and making the passed keys/values reactive. + * + * @param target @see reactive + * @param callback @see reactive + */ + function makeForEachObserver(target, callback) { + return function forEach(forEachCb, thisArg) { + observeTargetKey(target, KEYCHANGES, callback); + target.forEach(function (val, key, targetObj) { + observeTargetKey(target, key, callback); + forEachCb.call(thisArg, possiblyReactive(val, callback), possiblyReactive(key, callback), possiblyReactive(targetObj, callback)); + }, thisArg); + }; + } + /** + * Creates a function that will delegate to an underlying method, and check if + * that method has modified the presence or value of a key, and notify the + * reactives appropriately. + * + * @param setterName name of the method to delegate to + * @param getterName name of the method which should be used to retrieve the + * value before calling the delegate method for comparison purposes + * @param target @see reactive + */ + function delegateAndNotify(setterName, getterName, target) { + return (key, value) => { + key = toRaw(key); + const hadKey = target.has(key); + const originalValue = target[getterName](key); + const ret = target[setterName](key, value); + const hasKey = target.has(key); + if (hadKey !== hasKey) { + notifyReactives(target, KEYCHANGES); + } + if (originalValue !== target[getterName](key)) { + notifyReactives(target, key); + } + return ret; + }; + } + /** + * Creates a function that will clear the underlying collection and notify that + * the keys of the collection have changed. + * + * @param target @see reactive + */ + function makeClearNotifier(target) { + return () => { + const allKeys = [...target.keys()]; + target.clear(); + notifyReactives(target, KEYCHANGES); + for (const key of allKeys) { + notifyReactives(target, key); + } + }; + } + /** + * Maps raw type of an object to an object containing functions that can be used + * to build an appropritate proxy handler for that raw type. Eg: when making a + * reactive set, calling the has method should mark the key that is being + * retrieved as observed, and calling the add or delete method should notify the + * reactives that the key which is being added or deleted has been modified. + */ + const rawTypeToFuncHandlers = { + Set: (target, callback) => ({ + has: makeKeyObserver("has", target, callback), + add: delegateAndNotify("add", "has", target), + delete: delegateAndNotify("delete", "has", target), + keys: makeIteratorObserver("keys", target, callback), + values: makeIteratorObserver("values", target, callback), + entries: makeIteratorObserver("entries", target, callback), + [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback), + forEach: makeForEachObserver(target, callback), + clear: makeClearNotifier(target), + get size() { + observeTargetKey(target, KEYCHANGES, callback); + return target.size; + }, + }), + Map: (target, callback) => ({ + has: makeKeyObserver("has", target, callback), + get: makeKeyObserver("get", target, callback), + set: delegateAndNotify("set", "get", target), + delete: delegateAndNotify("delete", "has", target), + keys: makeIteratorObserver("keys", target, callback), + values: makeIteratorObserver("values", target, callback), + entries: makeIteratorObserver("entries", target, callback), + [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback), + forEach: makeForEachObserver(target, callback), + clear: makeClearNotifier(target), + get size() { + observeTargetKey(target, KEYCHANGES, callback); + return target.size; + }, + }), + WeakMap: (target, callback) => ({ + has: makeKeyObserver("has", target, callback), + get: makeKeyObserver("get", target, callback), + set: delegateAndNotify("set", "get", target), + delete: delegateAndNotify("delete", "has", target), + }), + }; + /** + * Creates a proxy handler for collections (Set/Map/WeakMap) + * + * @param callback @see reactive + * @param target @see reactive + * @returns a proxy handler object + */ + function collectionsProxyHandler(target, callback, targetRawType) { + // TODO: if performance is an issue we can create the special handlers lazily when each + // property is read. + const specialHandlers = rawTypeToFuncHandlers[targetRawType](target, callback); + return Object.assign(basicProxyHandler(callback), { + // FIXME: probably broken when part of prototype chain since we ignore the receiver + get(target, key) { + if (objectHasOwnProperty.call(specialHandlers, key)) { + return specialHandlers[key]; + } + observeTargetKey(target, key, callback); + return possiblyReactive(target[key], callback); + }, + }); + } + + let currentNode = null; + function saveCurrent() { + let n = currentNode; + return () => { + currentNode = n; + }; + } + function getCurrent() { + if (!currentNode) { + throw new OwlError("No active component (a hook function should only be called in 'setup')"); + } + return currentNode; + } + function useComponent() { + return currentNode.component; + } + /** + * Apply default props (only top level). + */ + function applyDefaultProps(props, defaultProps) { + for (let propName in defaultProps) { + if (props[propName] === undefined) { + props[propName] = defaultProps[propName]; + } + } + } + // ----------------------------------------------------------------------------- + // Integration with reactivity system (useState) + // ----------------------------------------------------------------------------- + const batchedRenderFunctions = new WeakMap(); + /** + * Creates a reactive object that will be observed by the current component. + * Reading data from the returned object (eg during rendering) will cause the + * component to subscribe to that data and be rerendered when it changes. + * + * @param state the state to observe + * @returns a reactive object that will cause the component to re-render on + * relevant changes + * @see reactive + */ + function useState(state) { + const node = getCurrent(); + let render = batchedRenderFunctions.get(node); + if (!render) { + render = batched(node.render.bind(node, false)); + batchedRenderFunctions.set(node, render); + // manual implementation of onWillDestroy to break cyclic dependency + node.willDestroy.push(clearReactivesForCallback.bind(null, render)); + } + return reactive(state, render); + } + class ComponentNode { + constructor(C, props, app, parent, parentKey) { + this.fiber = null; + this.bdom = null; + this.status = 0 /* NEW */; + this.forceNextRender = false; + this.nextProps = null; + this.children = Object.create(null); + this.refs = {}; + this.willStart = []; + this.willUpdateProps = []; + this.willUnmount = []; + this.mounted = []; + this.willPatch = []; + this.patched = []; + this.willDestroy = []; + currentNode = this; + this.app = app; + this.parent = parent; + this.props = props; + this.parentKey = parentKey; + const defaultProps = C.defaultProps; + props = Object.assign({}, props); + if (defaultProps) { + applyDefaultProps(props, defaultProps); + } + const env = (parent && parent.childEnv) || app.env; + this.childEnv = env; + for (const key in props) { + const prop = props[key]; + if (prop && typeof prop === "object" && targets.has(prop)) { + props[key] = useState(prop); + } + } + this.component = new C(props, env, this); + const ctx = Object.assign(Object.create(this.component), { this: this.component }); + this.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this); + this.component.setup(); + currentNode = null; + } + mountComponent(target, options) { + const fiber = new MountFiber(this, target, options); + this.app.scheduler.addFiber(fiber); + this.initiateRender(fiber); + } + async initiateRender(fiber) { + this.fiber = fiber; + if (this.mounted.length) { + fiber.root.mounted.push(fiber); + } + const component = this.component; + try { + await Promise.all(this.willStart.map((f) => f.call(component))); + } + catch (e) { + this.app.handleError({ node: this, error: e }); + return; + } + if (this.status === 0 /* NEW */ && this.fiber === fiber) { + fiber.render(); + } + } + async render(deep) { + if (this.status >= 2 /* CANCELLED */) { + return; + } + let current = this.fiber; + if (current && (current.root.locked || current.bdom === true)) { + await Promise.resolve(); + // situation may have changed after the microtask tick + current = this.fiber; + } + if (current) { + if (!current.bdom && !fibersInError.has(current)) { + if (deep) { + // we want the render from this point on to be with deep=true + current.deep = deep; + } + return; + } + // if current rendering was with deep=true, we want this one to be the same + deep = deep || current.deep; + } + else if (!this.bdom) { + return; + } + const fiber = makeRootFiber(this); + fiber.deep = deep; + this.fiber = fiber; + this.app.scheduler.addFiber(fiber); + await Promise.resolve(); + if (this.status >= 2 /* CANCELLED */) { + return; + } + // We only want to actually render the component if the following two + // conditions are true: + // * this.fiber: it could be null, in which case the render has been cancelled + // * (current || !fiber.parent): if current is not null, this means that the + // render function was called when a render was already occurring. In this + // case, the pending rendering was cancelled, and the fiber needs to be + // rendered to complete the work. If current is null, we check that the + // fiber has no parent. If that is the case, the fiber was downgraded from + // a root fiber to a child fiber in the previous microtick, because it was + // embedded in a rendering coming from above, so the fiber will be rendered + // in the next microtick anyway, so we should not render it again. + if (this.fiber === fiber && (current || !fiber.parent)) { + fiber.render(); + } + } + cancel() { + this._cancel(); + delete this.parent.children[this.parentKey]; + this.app.scheduler.scheduleDestroy(this); + } + _cancel() { + this.status = 2 /* CANCELLED */; + const children = this.children; + for (let childKey in children) { + children[childKey]._cancel(); + } + } + destroy() { + let shouldRemove = this.status === 1 /* MOUNTED */; + this._destroy(); + if (shouldRemove) { + this.bdom.remove(); + } + } + _destroy() { + const component = this.component; + if (this.status === 1 /* MOUNTED */) { + for (let cb of this.willUnmount) { + cb.call(component); + } + } + for (let child of Object.values(this.children)) { + child._destroy(); + } + if (this.willDestroy.length) { + try { + for (let cb of this.willDestroy) { + cb.call(component); + } + } + catch (e) { + this.app.handleError({ error: e, node: this }); + } + } + this.status = 3 /* DESTROYED */; + } + async updateAndRender(props, parentFiber) { + this.nextProps = props; + props = Object.assign({}, props); + // update + const fiber = makeChildFiber(this, parentFiber); + this.fiber = fiber; + const component = this.component; + const defaultProps = component.constructor.defaultProps; + if (defaultProps) { + applyDefaultProps(props, defaultProps); + } + currentNode = this; + for (const key in props) { + const prop = props[key]; + if (prop && typeof prop === "object" && targets.has(prop)) { + props[key] = useState(prop); + } + } + currentNode = null; + const prom = Promise.all(this.willUpdateProps.map((f) => f.call(component, props))); + await prom; + if (fiber !== this.fiber) { + return; + } + component.props = props; + fiber.render(); + const parentRoot = parentFiber.root; + if (this.willPatch.length) { + parentRoot.willPatch.push(fiber); + } + if (this.patched.length) { + parentRoot.patched.push(fiber); + } + } + /** + * Finds a child that has dom that is not yet updated, and update it. This + * method is meant to be used only in the context of repatching the dom after + * a mounted hook failed and was handled. + */ + updateDom() { + if (!this.fiber) { + return; + } + if (this.bdom === this.fiber.bdom) { + // If the error was handled by some child component, we need to find it to + // apply its change + for (let k in this.children) { + const child = this.children[k]; + child.updateDom(); + } + } + else { + // if we get here, this is the component that handled the error and rerendered + // itself, so we can simply patch the dom + this.bdom.patch(this.fiber.bdom, false); + this.fiber.appliedToDom = true; + this.fiber = null; + } + } + /** + * Sets a ref to a given HTMLElement. + * + * @param name the name of the ref to set + * @param el the HTMLElement to set the ref to. The ref is not set if the el + * is null, but useRef will not return elements that are not in the DOM + */ + setRef(name, el) { + if (el) { + this.refs[name] = el; + } + } + // --------------------------------------------------------------------------- + // Block DOM methods + // --------------------------------------------------------------------------- + firstNode() { + const bdom = this.bdom; + return bdom ? bdom.firstNode() : undefined; + } + mount(parent, anchor) { + const bdom = this.fiber.bdom; + this.bdom = bdom; + bdom.mount(parent, anchor); + this.status = 1 /* MOUNTED */; + this.fiber.appliedToDom = true; + this.children = this.fiber.childrenMap; + this.fiber = null; + } + moveBeforeDOMNode(node, parent) { + this.bdom.moveBeforeDOMNode(node, parent); + } + moveBeforeVNode(other, afterNode) { + this.bdom.moveBeforeVNode(other ? other.bdom : null, afterNode); + } + patch() { + if (this.fiber && this.fiber.parent) { + // we only patch here renderings coming from above. renderings initiated + // by the component will be patched independently in the appropriate + // fiber.complete + this._patch(); + this.props = this.nextProps; + } + } + _patch() { + let hasChildren = false; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (let _k in this.children) { + hasChildren = true; + break; + } + const fiber = this.fiber; + this.children = fiber.childrenMap; + this.bdom.patch(fiber.bdom, hasChildren); + fiber.appliedToDom = true; + this.fiber = null; + } + beforeRemove() { + this._destroy(); + } + remove() { + this.bdom.remove(); + } + // --------------------------------------------------------------------------- + // Some debug helpers + // --------------------------------------------------------------------------- + get name() { + return this.component.constructor.name; + } + get subscriptions() { + const render = batchedRenderFunctions.get(this); + return render ? getSubscriptions(render) : []; + } + } + + const TIMEOUT = Symbol("timeout"); + const HOOK_TIMEOUT = { + onWillStart: 3000, + onWillUpdateProps: 3000, + }; + function wrapError(fn, hookName) { + const error = new OwlError(); + const timeoutError = new OwlError(); + const node = getCurrent(); + return (...args) => { + const onError = (cause) => { + error.cause = cause; + error.message = + cause instanceof Error + ? `The following error occurred in ${hookName}: "${cause.message}"` + : `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`; + throw error; + }; + let result; + try { + result = fn(...args); + } + catch (cause) { + onError(cause); + } + if (!(result instanceof Promise)) { + return result; + } + const timeout = HOOK_TIMEOUT[hookName]; + if (timeout) { + const fiber = node.fiber; + Promise.race([ + result.catch(() => { }), + new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), timeout)), + ]).then((res) => { + if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) { + timeoutError.message = `${hookName}'s promise hasn't resolved after ${timeout / 1000} seconds`; + console.log(timeoutError); + } + }); + } + return result.catch(onError); + }; + } + // ----------------------------------------------------------------------------- + // hooks + // ----------------------------------------------------------------------------- + function onWillStart(fn) { + const node = getCurrent(); + const decorate = node.app.dev ? wrapError : (fn) => fn; + node.willStart.push(decorate(fn.bind(node.component), "onWillStart")); + } + function onWillUpdateProps(fn) { + const node = getCurrent(); + const decorate = node.app.dev ? wrapError : (fn) => fn; + node.willUpdateProps.push(decorate(fn.bind(node.component), "onWillUpdateProps")); + } + function onMounted(fn) { + const node = getCurrent(); + const decorate = node.app.dev ? wrapError : (fn) => fn; + node.mounted.push(decorate(fn.bind(node.component), "onMounted")); + } + function onWillPatch(fn) { + const node = getCurrent(); + const decorate = node.app.dev ? wrapError : (fn) => fn; + node.willPatch.unshift(decorate(fn.bind(node.component), "onWillPatch")); + } + function onPatched(fn) { + const node = getCurrent(); + const decorate = node.app.dev ? wrapError : (fn) => fn; + node.patched.push(decorate(fn.bind(node.component), "onPatched")); + } + function onWillUnmount(fn) { + const node = getCurrent(); + const decorate = node.app.dev ? wrapError : (fn) => fn; + node.willUnmount.unshift(decorate(fn.bind(node.component), "onWillUnmount")); + } + function onWillDestroy(fn) { + const node = getCurrent(); + const decorate = node.app.dev ? wrapError : (fn) => fn; + node.willDestroy.push(decorate(fn.bind(node.component), "onWillDestroy")); + } + function onWillRender(fn) { + const node = getCurrent(); + const renderFn = node.renderFn; + const decorate = node.app.dev ? wrapError : (fn) => fn; + fn = decorate(fn.bind(node.component), "onWillRender"); + node.renderFn = () => { + fn(); + return renderFn(); + }; + } + function onRendered(fn) { + const node = getCurrent(); + const renderFn = node.renderFn; + const decorate = node.app.dev ? wrapError : (fn) => fn; + fn = decorate(fn.bind(node.component), "onRendered"); + node.renderFn = () => { + const result = renderFn(); + fn(); + return result; + }; + } + function onError(callback) { + const node = getCurrent(); + let handlers = nodeErrorHandlers.get(node); + if (!handlers) { + handlers = []; + nodeErrorHandlers.set(node, handlers); + } + handlers.push(callback.bind(node.component)); + } + + class Component { + constructor(props, env, node) { + this.props = props; + this.env = env; + this.__owl__ = node; + } + setup() { } + render(deep = false) { + this.__owl__.render(deep === true); + } + } + Component.template = ""; + + const VText = text("").constructor; + class VPortal extends VText { + constructor(selector, content) { + super(""); + this.target = null; + this.selector = selector; + this.content = content; + } + mount(parent, anchor) { + super.mount(parent, anchor); + this.target = document.querySelector(this.selector); + if (this.target) { + this.content.mount(this.target, null); + } + else { + this.content.mount(parent, anchor); + } + } + beforeRemove() { + this.content.beforeRemove(); + } + remove() { + if (this.content) { + super.remove(); + this.content.remove(); + this.content = null; + } + } + patch(other) { + super.patch(other); + if (this.content) { + this.content.patch(other.content, true); + } + else { + this.content = other.content; + this.content.mount(this.target, null); + } + } + } + /** + * kind of similar to <t t-slot="default"/>, but it wraps it around a VPortal + */ + function portalTemplate(app, bdom, helpers) { + let { callSlot } = helpers; + return function template(ctx, node, key = "") { + return new VPortal(ctx.props.target, callSlot(ctx, node, key, "default", false, null)); + }; + } + class Portal extends Component { + setup() { + const node = this.__owl__; + onMounted(() => { + const portal = node.bdom; + if (!portal.target) { + const target = document.querySelector(this.props.target); + if (target) { + portal.content.moveBeforeDOMNode(target.firstChild, target); + } + else { + throw new OwlError("invalid portal target"); + } + } + }); + onWillUnmount(() => { + const portal = node.bdom; + portal.remove(); + }); + } + } + Portal.template = "__portal__"; + Portal.props = { + target: { + type: String, + }, + slots: true, + }; + + // ----------------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------------- + const isUnionType = (t) => Array.isArray(t); + const isBaseType = (t) => typeof t !== "object"; + const isValueType = (t) => typeof t === "object" && t && "value" in t; + function isOptional(t) { + return typeof t === "object" && "optional" in t ? t.optional || false : false; + } + function describeType(type) { + return type === "*" || type === true ? "value" : type.name.toLowerCase(); + } + function describe(info) { + if (isBaseType(info)) { + return describeType(info); + } + else if (isUnionType(info)) { + return info.map(describe).join(" or "); + } + else if (isValueType(info)) { + return String(info.value); + } + if ("element" in info) { + return `list of ${describe({ type: info.element, optional: false })}s`; + } + if ("shape" in info) { + return `object`; + } + return describe(info.type || "*"); + } + function toSchema(spec) { + return Object.fromEntries(spec.map((e) => e.endsWith("?") ? [e.slice(0, -1), { optional: true }] : [e, { type: "*", optional: false }])); + } + /** + * Main validate function + */ + function validate(obj, spec) { + let errors = validateSchema(obj, spec); + if (errors.length) { + throw new OwlError("Invalid object: " + errors.join(", ")); + } + } + /** + * Helper validate function, to get the list of errors. useful if one want to + * manipulate the errors without parsing an error object + */ + function validateSchema(obj, schema) { + if (Array.isArray(schema)) { + schema = toSchema(schema); + } + obj = toRaw(obj); + let errors = []; + // check if each value in obj has correct shape + for (let key in obj) { + if (key in schema) { + let result = validateType(key, obj[key], schema[key]); + if (result) { + errors.push(result); + } + } + else if (!("*" in schema)) { + errors.push(`unknown key '${key}'`); + } + } + // check that all specified keys are defined in obj + for (let key in schema) { + const spec = schema[key]; + if (key !== "*" && !isOptional(spec) && !(key in obj)) { + const isObj = typeof spec === "object" && !Array.isArray(spec); + const isAny = spec === "*" || (isObj && "type" in spec ? spec.type === "*" : isObj); + let detail = isAny ? "" : ` (should be a ${describe(spec)})`; + errors.push(`'${key}' is missing${detail}`); + } + } + return errors; + } + function validateBaseType(key, value, type) { + if (typeof type === "function") { + if (typeof value === "object") { + if (!(value instanceof type)) { + return `'${key}' is not a ${describeType(type)}`; + } + } + else if (typeof value !== type.name.toLowerCase()) { + return `'${key}' is not a ${describeType(type)}`; + } + } + return null; + } + function validateArrayType(key, value, descr) { + if (!Array.isArray(value)) { + return `'${key}' is not a list of ${describe(descr)}s`; + } + for (let i = 0; i < value.length; i++) { + const error = validateType(`${key}[${i}]`, value[i], descr); + if (error) { + return error; + } + } + return null; + } + function validateType(key, value, descr) { + if (value === undefined) { + return isOptional(descr) ? null : `'${key}' is undefined (should be a ${describe(descr)})`; + } + else if (isBaseType(descr)) { + return validateBaseType(key, value, descr); + } + else if (isValueType(descr)) { + return value === descr.value ? null : `'${key}' is not equal to '${descr.value}'`; + } + else if (isUnionType(descr)) { + let validDescr = descr.find((p) => !validateType(key, value, p)); + return validDescr ? null : `'${key}' is not a ${describe(descr)}`; + } + let result = null; + if ("element" in descr) { + result = validateArrayType(key, value, descr.element); + } + else if ("shape" in descr) { + if (typeof value !== "object" || Array.isArray(value)) { + result = `'${key}' is not an object`; + } + else { + const errors = validateSchema(value, descr.shape); + if (errors.length) { + result = `'${key}' doesn't have the correct shape (${errors.join(", ")})`; + } + } + } + else if ("values" in descr) { + if (typeof value !== "object" || Array.isArray(value)) { + result = `'${key}' is not an object`; + } + else { + const errors = Object.entries(value) + .map(([key, value]) => validateType(key, value, descr.values)) + .filter(Boolean); + if (errors.length) { + result = `some of the values in '${key}' are invalid (${errors.join(", ")})`; + } + } + } + if ("type" in descr && !result) { + result = validateType(key, value, descr.type); + } + if ("validate" in descr && !result) { + result = !descr.validate(value) ? `'${key}' is not valid` : null; + } + return result; + } + + const ObjectCreate = Object.create; + /** + * This file contains utility functions that will be injected in each template, + * to perform various useful tasks in the compiled code. + */ + function withDefault(value, defaultValue) { + return value === undefined || value === null || value === false ? defaultValue : value; + } + function callSlot(ctx, parent, key, name, dynamic, extra, defaultContent) { + key = key + "__slot_" + name; + const slots = ctx.props.slots || {}; + const { __render, __ctx, __scope } = slots[name] || {}; + const slotScope = ObjectCreate(__ctx || {}); + if (__scope) { + slotScope[__scope] = extra; + } + const slotBDom = __render ? __render(slotScope, parent, key) : null; + if (defaultContent) { + let child1 = undefined; + let child2 = undefined; + if (slotBDom) { + child1 = dynamic ? toggler(name, slotBDom) : slotBDom; + } + else { + child2 = defaultContent(ctx, parent, key); + } + return multi([child1, child2]); + } + return slotBDom || text(""); + } + function capture(ctx) { + const result = ObjectCreate(ctx); + for (let k in ctx) { + result[k] = ctx[k]; + } + return result; + } + function withKey(elem, k) { + elem.key = k; + return elem; + } + function prepareList(collection) { + let keys; + let values; + if (Array.isArray(collection)) { + keys = collection; + values = collection; + } + else if (collection instanceof Map) { + keys = [...collection.keys()]; + values = [...collection.values()]; + } + else if (Symbol.iterator in Object(collection)) { + keys = [...collection]; + values = keys; + } + else if (collection && typeof collection === "object") { + values = Object.values(collection); + keys = Object.keys(collection); + } + else { + throw new OwlError(`Invalid loop expression: "${collection}" is not iterable`); + } + const n = values.length; + return [keys, values, n, new Array(n)]; + } + const isBoundary = Symbol("isBoundary"); + function setContextValue(ctx, key, value) { + const ctx0 = ctx; + while (!ctx.hasOwnProperty(key) && !ctx.hasOwnProperty(isBoundary)) { + const newCtx = ctx.__proto__; + if (!newCtx) { + ctx = ctx0; + break; + } + ctx = newCtx; + } + ctx[key] = value; + } + function toNumber(val) { + const n = parseFloat(val); + return isNaN(n) ? val : n; + } + function shallowEqual(l1, l2) { + for (let i = 0, l = l1.length; i < l; i++) { + if (l1[i] !== l2[i]) { + return false; + } + } + return true; + } + class LazyValue { + constructor(fn, ctx, component, node, key) { + this.fn = fn; + this.ctx = capture(ctx); + this.component = component; + this.node = node; + this.key = key; + } + evaluate() { + return this.fn.call(this.component, this.ctx, this.node, this.key); + } + toString() { + return this.evaluate().toString(); + } + } + /* + * Safely outputs `value` as a block depending on the nature of `value` + */ + function safeOutput(value, defaultValue) { + if (value === undefined || value === null) { + return defaultValue ? toggler("default", defaultValue) : toggler("undefined", text("")); + } + let safeKey; + let block; + switch (typeof value) { + case "object": + if (value instanceof Markup) { + safeKey = `string_safe`; + block = html(value); + } + else if (value instanceof LazyValue) { + safeKey = `lazy_value`; + block = value.evaluate(); + } + else if (value instanceof String) { + safeKey = "string_unsafe"; + block = text(value); + } + else { + // Assuming it is a block + safeKey = "block_safe"; + block = value; + } + break; + case "string": + safeKey = "string_unsafe"; + block = text(value); + break; + default: + safeKey = "string_unsafe"; + block = text(String(value)); + } + return toggler(safeKey, block); + } + /** + * Validate the component props (or next props) against the (static) props + * description. This is potentially an expensive operation: it may needs to + * visit recursively the props and all the children to check if they are valid. + * This is why it is only done in 'dev' mode. + */ + function validateProps(name, props, comp) { + const ComponentClass = typeof name !== "string" + ? name + : comp.constructor.components[name]; + if (!ComponentClass) { + // this is an error, wrong component. We silently return here instead so the + // error is triggered by the usual path ('component' function) + return; + } + const schema = ComponentClass.props; + if (!schema) { + if (comp.__owl__.app.warnIfNoStaticProps) { + console.warn(`Component '${ComponentClass.name}' does not have a static props description`); + } + return; + } + const defaultProps = ComponentClass.defaultProps; + if (defaultProps) { + let isMandatory = (name) => Array.isArray(schema) + ? schema.includes(name) + : name in schema && !("*" in schema) && !isOptional(schema[name]); + for (let p in defaultProps) { + if (isMandatory(p)) { + throw new OwlError(`A default value cannot be defined for a mandatory prop (name: '${p}', component: ${ComponentClass.name})`); + } + } + } + const errors = validateSchema(props, schema); + if (errors.length) { + throw new OwlError(`Invalid props for component '${ComponentClass.name}': ` + errors.join(", ")); + } + } + function makeRefWrapper(node) { + let refNames = new Set(); + return (name, fn) => { + if (refNames.has(name)) { + throw new OwlError(`Cannot set the same ref more than once in the same component, ref "${name}" was set multiple times in ${node.name}`); + } + refNames.add(name); + return fn; + }; + } + const helpers = { + withDefault, + zero: Symbol("zero"), + isBoundary, + callSlot, + capture, + withKey, + prepareList, + setContextValue, + shallowEqual, + toNumber, + validateProps, + LazyValue, + safeOutput, + createCatcher, + markRaw, + OwlError, + makeRefWrapper, + }; + + /** + * Parses an XML string into an XML document, throwing errors on parser errors + * instead of returning an XML document containing the parseerror. + * + * @param xml the string to parse + * @returns an XML document corresponding to the content of the string + */ + function parseXML(xml) { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, "text/xml"); + if (doc.getElementsByTagName("parsererror").length) { + let msg = "Invalid XML in template."; + const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent; + if (parsererrorText) { + msg += "\nThe parser has produced the following error message:\n" + parsererrorText; + const re = /\d+/g; + const firstMatch = re.exec(parsererrorText); + if (firstMatch) { + const lineNumber = Number(firstMatch[0]); + const line = xml.split("\n")[lineNumber - 1]; + const secondMatch = re.exec(parsererrorText); + if (line && secondMatch) { + const columnIndex = Number(secondMatch[0]) - 1; + if (line[columnIndex]) { + msg += + `\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` + + `${line}\n${"-".repeat(columnIndex - 1)}^`; + } + } + } + } + throw new OwlError(msg); + } + return doc; + } + + const bdom = { text, createBlock, list, multi, html, toggler, comment }; + class TemplateSet { + constructor(config = {}) { + this.rawTemplates = Object.create(globalTemplates); + this.templates = {}; + this.Portal = Portal; + this.dev = config.dev || false; + this.translateFn = config.translateFn; + this.translatableAttributes = config.translatableAttributes; + if (config.templates) { + if (config.templates instanceof Document || typeof config.templates === "string") { + this.addTemplates(config.templates); + } + else { + for (const name in config.templates) { + this.addTemplate(name, config.templates[name]); + } + } + } + this.getRawTemplate = config.getTemplate; + this.customDirectives = config.customDirectives || {}; + this.runtimeUtils = { ...helpers, __globals__: config.globalValues || {} }; + this.hasGlobalValues = Boolean(config.globalValues && Object.keys(config.globalValues).length); + } + static registerTemplate(name, fn) { + globalTemplates[name] = fn; + } + addTemplate(name, template) { + if (name in this.rawTemplates) { + // this check can be expensive, just silently ignore double definitions outside dev mode + if (!this.dev) { + return; + } + const rawTemplate = this.rawTemplates[name]; + const currentAsString = typeof rawTemplate === "string" + ? rawTemplate + : rawTemplate instanceof Element + ? rawTemplate.outerHTML + : rawTemplate.toString(); + const newAsString = typeof template === "string" ? template : template.outerHTML; + if (currentAsString === newAsString) { + return; + } + throw new OwlError(`Template ${name} already defined with different content`); + } + this.rawTemplates[name] = template; + } + addTemplates(xml) { + if (!xml) { + // empty string + return; + } + xml = xml instanceof Document ? xml : parseXML(xml); + for (const template of xml.querySelectorAll("[t-name]")) { + const name = template.getAttribute("t-name"); + this.addTemplate(name, template); + } + } + getTemplate(name) { + var _a; + if (!(name in this.templates)) { + const rawTemplate = ((_a = this.getRawTemplate) === null || _a === void 0 ? void 0 : _a.call(this, name)) || this.rawTemplates[name]; + if (rawTemplate === undefined) { + let extraInfo = ""; + try { + const componentName = getCurrent().component.constructor.name; + extraInfo = ` (for component "${componentName}")`; + } + catch { } + throw new OwlError(`Missing template: "${name}"${extraInfo}`); + } + const isFn = typeof rawTemplate === "function" && !(rawTemplate instanceof Element); + const templateFn = isFn ? rawTemplate : this._compileTemplate(name, rawTemplate); + // first add a function to lazily get the template, in case there is a + // recursive call to the template name + const templates = this.templates; + this.templates[name] = function (context, parent) { + return templates[name].call(this, context, parent); + }; + const template = templateFn(this, bdom, this.runtimeUtils); + this.templates[name] = template; + } + return this.templates[name]; + } + _compileTemplate(name, template) { + throw new OwlError(`Unable to compile a template. Please use owl full build instead`); + } + callTemplate(owner, subTemplate, ctx, parent, key) { + const template = this.getTemplate(subTemplate); + return toggler(subTemplate, template.call(owner, ctx, parent, key + subTemplate)); + } + } + // ----------------------------------------------------------------------------- + // xml tag helper + // ----------------------------------------------------------------------------- + const globalTemplates = {}; + function xml(...args) { + const name = `__template__${xml.nextId++}`; + const value = String.raw(...args); + globalTemplates[name] = value; + return name; + } + xml.nextId = 1; + TemplateSet.registerTemplate("__portal__", portalTemplate); + + /** + * Owl QWeb Expression Parser + * + * Owl needs in various contexts to be able to understand the structure of a + * string representing a javascript expression. The usual goal is to be able + * to rewrite some variables. For example, if a template has + * + * ```xml + * <t t-if="computeSomething({val: state.val})">...</t> + * ``` + * + * this needs to be translated in something like this: + * + * ```js + * if (context["computeSomething"]({val: context["state"].val})) { ... } + * ``` + * + * This file contains the implementation of an extremely naive tokenizer/parser + * and evaluator for javascript expressions. The supported grammar is basically + * only expressive enough to understand the shape of objects, of arrays, and + * various operators. + */ + //------------------------------------------------------------------------------ + // Misc types, constants and helpers + //------------------------------------------------------------------------------ + const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,eval,void,Math,RegExp,Array,Object,Date,__globals__".split(","); + const WORD_REPLACEMENT = Object.assign(Object.create(null), { + and: "&&", + or: "||", + gt: ">", + gte: ">=", + lt: "<", + lte: "<=", + }); + const STATIC_TOKEN_MAP = Object.assign(Object.create(null), { + "{": "LEFT_BRACE", + "}": "RIGHT_BRACE", + "[": "LEFT_BRACKET", + "]": "RIGHT_BRACKET", + ":": "COLON", + ",": "COMMA", + "(": "LEFT_PAREN", + ")": "RIGHT_PAREN", + }); + // note that the space after typeof is relevant. It makes sure that the formatted + // expression has a space after typeof. Currently we don't support delete and void + const OPERATORS = "...,.,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%,typeof ,=>,=,;,in ,new ,|,&,^,~".split(","); + let tokenizeString = function (expr) { + let s = expr[0]; + let start = s; + if (s !== "'" && s !== '"' && s !== "`") { + return false; + } + let i = 1; + let cur; + while (expr[i] && expr[i] !== start) { + cur = expr[i]; + s += cur; + if (cur === "\\") { + i++; + cur = expr[i]; + if (!cur) { + throw new OwlError("Invalid expression"); + } + s += cur; + } + i++; + } + if (expr[i] !== start) { + throw new OwlError("Invalid expression"); + } + s += start; + if (start === "`") { + return { + type: "TEMPLATE_STRING", + value: s, + replace(replacer) { + return s.replace(/\$\{(.*?)\}/g, (match, group) => { + return "${" + replacer(group) + "}"; + }); + }, + }; + } + return { type: "VALUE", value: s }; + }; + let tokenizeNumber = function (expr) { + let s = expr[0]; + if (s && s.match(/[0-9]/)) { + let i = 1; + while (expr[i] && expr[i].match(/[0-9]|\./)) { + s += expr[i]; + i++; + } + return { type: "VALUE", value: s }; + } + else { + return false; + } + }; + let tokenizeSymbol = function (expr) { + let s = expr[0]; + if (s && s.match(/[a-zA-Z_\$]/)) { + let i = 1; + while (expr[i] && expr[i].match(/\w/)) { + s += expr[i]; + i++; + } + if (s in WORD_REPLACEMENT) { + return { type: "OPERATOR", value: WORD_REPLACEMENT[s], size: s.length }; + } + return { type: "SYMBOL", value: s }; + } + else { + return false; + } + }; + const tokenizeStatic = function (expr) { + const char = expr[0]; + if (char && char in STATIC_TOKEN_MAP) { + return { type: STATIC_TOKEN_MAP[char], value: char }; + } + return false; + }; + const tokenizeOperator = function (expr) { + for (let op of OPERATORS) { + if (expr.startsWith(op)) { + return { type: "OPERATOR", value: op }; + } + } + return false; + }; + const TOKENIZERS = [ + tokenizeString, + tokenizeNumber, + tokenizeOperator, + tokenizeSymbol, + tokenizeStatic, + ]; + /** + * Convert a javascript expression (as a string) into a list of tokens. For + * example: `tokenize("1 + b")` will return: + * ```js + * [ + * {type: "VALUE", value: "1"}, + * {type: "OPERATOR", value: "+"}, + * {type: "SYMBOL", value: "b"} + * ] + * ``` + */ + function tokenize(expr) { + const result = []; + let token = true; + let error; + let current = expr; + try { + while (token) { + current = current.trim(); + if (current) { + for (let tokenizer of TOKENIZERS) { + token = tokenizer(current); + if (token) { + result.push(token); + current = current.slice(token.size || token.value.length); + break; + } + } + } + else { + token = false; + } + } + } + catch (e) { + error = e; // Silence all errors and throw a generic error below + } + if (current.length || error) { + throw new OwlError(`Tokenizer error: could not tokenize \`${expr}\``); + } + return result; + } + //------------------------------------------------------------------------------ + // Expression "evaluator" + //------------------------------------------------------------------------------ + const isLeftSeparator = (token) => token && (token.type === "LEFT_BRACE" || token.type === "COMMA"); + const isRightSeparator = (token) => token && (token.type === "RIGHT_BRACE" || token.type === "COMMA"); + /** + * This is the main function exported by this file. This is the code that will + * process an expression (given as a string) and returns another expression with + * proper lookups in the context. + * + * Usually, this kind of code would be very simple to do if we had an AST (so, + * if we had a javascript parser), since then, we would only need to find the + * variables and replace them. However, a parser is more complicated, and there + * are no standard builtin parser API. + * + * Since this method is applied to simple javasript expressions, and the work to + * be done is actually quite simple, we actually can get away with not using a + * parser, which helps with the code size. + * + * Here is the heuristic used by this method to determine if a token is a + * variable: + * - by default, all symbols are considered a variable + * - unless the previous token is a dot (in that case, this is a property: `a.b`) + * - or if the previous token is a left brace or a comma, and the next token is + * a colon (in that case, this is an object key: `{a: b}`) + * + * Some specific code is also required to support arrow functions. If we detect + * the arrow operator, then we add the current (or some previous tokens) token to + * the list of variables so it does not get replaced by a lookup in the context + */ + function compileExprToArray(expr) { + const localVars = new Set(); + const tokens = tokenize(expr); + let i = 0; + let stack = []; // to track last opening (, [ or { + while (i < tokens.length) { + let token = tokens[i]; + let prevToken = tokens[i - 1]; + let nextToken = tokens[i + 1]; + let groupType = stack[stack.length - 1]; + switch (token.type) { + case "LEFT_BRACE": + case "LEFT_BRACKET": + case "LEFT_PAREN": + stack.push(token.type); + break; + case "RIGHT_BRACE": + case "RIGHT_BRACKET": + case "RIGHT_PAREN": + stack.pop(); + } + let isVar = token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value); + if (token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value)) { + if (prevToken) { + // normalize missing tokens: {a} should be equivalent to {a:a} + if (groupType === "LEFT_BRACE" && + isLeftSeparator(prevToken) && + isRightSeparator(nextToken)) { + tokens.splice(i + 1, 0, { type: "COLON", value: ":" }, { ...token }); + nextToken = tokens[i + 1]; + } + if (prevToken.type === "OPERATOR" && prevToken.value === ".") { + isVar = false; + } + else if (prevToken.type === "LEFT_BRACE" || prevToken.type === "COMMA") { + if (nextToken && nextToken.type === "COLON") { + isVar = false; + } + } + } + } + if (token.type === "TEMPLATE_STRING") { + token.value = token.replace((expr) => compileExpr(expr)); + } + if (nextToken && nextToken.type === "OPERATOR" && nextToken.value === "=>") { + if (token.type === "RIGHT_PAREN") { + let j = i - 1; + while (j > 0 && tokens[j].type !== "LEFT_PAREN") { + if (tokens[j].type === "SYMBOL" && tokens[j].originalValue) { + tokens[j].value = tokens[j].originalValue; + localVars.add(tokens[j].value); //] = { id: tokens[j].value, expr: tokens[j].value }; + } + j--; + } + } + else { + localVars.add(token.value); //] = { id: token.value, expr: token.value }; + } + } + if (isVar) { + token.varName = token.value; + if (!localVars.has(token.value)) { + token.originalValue = token.value; + token.value = `ctx['${token.value}']`; + } + } + i++; + } + // Mark all variables that have been used locally. + // This assumes the expression has only one scope (incorrect but "good enough for now") + for (const token of tokens) { + if (token.type === "SYMBOL" && token.varName && localVars.has(token.value)) { + token.originalValue = token.value; + token.value = `_${token.value}`; + token.isLocal = true; + } + } + return tokens; + } + // Leading spaces are trimmed during tokenization, so they need to be added back for some values + const paddedValues = new Map([["in ", " in "]]); + function compileExpr(expr) { + return compileExprToArray(expr) + .map((t) => paddedValues.get(t.value) || t.value) + .join(""); + } + const INTERP_REGEXP = /\{\{.*?\}\}|\#\{.*?\}/g; + function replaceDynamicParts(s, replacer) { + let matches = s.match(INTERP_REGEXP); + if (matches && matches[0].length === s.length) { + return `(${replacer(s.slice(2, matches[0][0] === "{" ? -2 : -1))})`; + } + let r = s.replace(INTERP_REGEXP, (s) => "${" + replacer(s.slice(2, s[0] === "{" ? -2 : -1)) + "}"); + return "`" + r + "`"; + } + function interpolate(s) { + return replaceDynamicParts(s, compileExpr); + } + + const whitespaceRE = /\s+/g; + // using a non-html document so that <inner/outer>HTML serializes as XML instead + // of HTML (as we will parse it as xml later) + const xmlDoc = document.implementation.createDocument(null, null, null); + const MODS = new Set(["stop", "capture", "prevent", "self", "synthetic"]); + let nextDataIds = {}; + function generateId(prefix = "") { + nextDataIds[prefix] = (nextDataIds[prefix] || 0) + 1; + return prefix + nextDataIds[prefix]; + } + function isProp(tag, key) { + switch (tag) { + case "input": + return (key === "checked" || + key === "indeterminate" || + key === "value" || + key === "readonly" || + key === "readOnly" || + key === "disabled"); + case "option": + return key === "selected" || key === "disabled"; + case "textarea": + return key === "value" || key === "readonly" || key === "readOnly" || key === "disabled"; + case "select": + return key === "value" || key === "disabled"; + case "button": + case "optgroup": + return key === "disabled"; + } + return false; + } + /** + * Returns a template literal that evaluates to str. You can add interpolation + * sigils into the string if required + */ + function toStringExpression(str) { + return `\`${str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/, "\\${")}\``; + } + // ----------------------------------------------------------------------------- + // BlockDescription + // ----------------------------------------------------------------------------- + class BlockDescription { + constructor(target, type) { + this.dynamicTagName = null; + this.isRoot = false; + this.hasDynamicChildren = false; + this.children = []; + this.data = []; + this.childNumber = 0; + this.parentVar = ""; + this.id = BlockDescription.nextBlockId++; + this.varName = "b" + this.id; + this.blockName = "block" + this.id; + this.target = target; + this.type = type; + } + insertData(str, prefix = "d") { + const id = generateId(prefix); + this.target.addLine(`let ${id} = ${str};`); + return this.data.push(id) - 1; + } + insert(dom) { + if (this.currentDom) { + this.currentDom.appendChild(dom); + } + else { + this.dom = dom; + } + } + generateExpr(expr) { + if (this.type === "block") { + const hasChildren = this.children.length; + let params = this.data.length ? `[${this.data.join(", ")}]` : hasChildren ? "[]" : ""; + if (hasChildren) { + params += ", [" + this.children.map((c) => c.varName).join(", ") + "]"; + } + if (this.dynamicTagName) { + return `toggler(${this.dynamicTagName}, ${this.blockName}(${this.dynamicTagName})(${params}))`; + } + return `${this.blockName}(${params})`; + } + else if (this.type === "list") { + return `list(c_block${this.id})`; + } + return expr; + } + asXmlString() { + // Can't use outerHTML on text/comment nodes + // append dom to any element and use innerHTML instead + const t = xmlDoc.createElement("t"); + t.appendChild(this.dom); + return t.innerHTML; + } + } + BlockDescription.nextBlockId = 1; + function createContext(parentCtx, params) { + return Object.assign({ + block: null, + index: 0, + forceNewBlock: true, + translate: parentCtx.translate, + translationCtx: parentCtx.translationCtx, + tKeyExpr: null, + nameSpace: parentCtx.nameSpace, + tModelSelectedExpr: parentCtx.tModelSelectedExpr, + }, params); + } + class CodeTarget { + constructor(name, on) { + this.indentLevel = 0; + this.loopLevel = 0; + this.code = []; + this.hasRoot = false; + this.hasCache = false; + this.shouldProtectScope = false; + this.hasRefWrapper = false; + this.name = name; + this.on = on || null; + } + addLine(line, idx) { + const prefix = new Array(this.indentLevel + 2).join(" "); + if (idx === undefined) { + this.code.push(prefix + line); + } + else { + this.code.splice(idx, 0, prefix + line); + } + } + generateCode() { + let result = []; + result.push(`function ${this.name}(ctx, node, key = "") {`); + if (this.shouldProtectScope) { + result.push(` ctx = Object.create(ctx);`); + result.push(` ctx[isBoundary] = 1`); + } + if (this.hasRefWrapper) { + result.push(` let refWrapper = makeRefWrapper(this.__owl__);`); + } + if (this.hasCache) { + result.push(` let cache = ctx.cache || {};`); + result.push(` let nextCache = ctx.cache = {};`); + } + for (let line of this.code) { + result.push(line); + } + if (!this.hasRoot) { + result.push(`return text('');`); + } + result.push(`}`); + return result.join("\n "); + } + currentKey(ctx) { + let key = this.loopLevel ? `key${this.loopLevel}` : "key"; + if (ctx.tKeyExpr) { + key = `${ctx.tKeyExpr} + ${key}`; + } + return key; + } + } + const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"]; + const translationRE = /^(\s*)([\s\S]+?)(\s*)$/; + class CodeGenerator { + constructor(ast, options) { + this.blocks = []; + this.nextBlockId = 1; + this.isDebug = false; + this.targets = []; + this.target = new CodeTarget("template"); + this.translatableAttributes = TRANSLATABLE_ATTRS; + this.staticDefs = []; + this.slotNames = new Set(); + this.helpers = new Set(); + this.translateFn = options.translateFn || ((s) => s); + if (options.translatableAttributes) { + const attrs = new Set(TRANSLATABLE_ATTRS); + for (let attr of options.translatableAttributes) { + if (attr.startsWith("-")) { + attrs.delete(attr.slice(1)); + } + else { + attrs.add(attr); + } + } + this.translatableAttributes = [...attrs]; + } + this.hasSafeContext = options.hasSafeContext || false; + this.dev = options.dev || false; + this.ast = ast; + this.templateName = options.name; + if (options.hasGlobalValues) { + this.helpers.add("__globals__"); + } + } + generateCode() { + const ast = this.ast; + this.isDebug = ast.type === 12 /* TDebug */; + BlockDescription.nextBlockId = 1; + nextDataIds = {}; + this.compileAST(ast, { + block: null, + index: 0, + forceNewBlock: false, + isLast: true, + translate: true, + translationCtx: "", + tKeyExpr: null, + }); + // define blocks and utility functions + let mainCode = [` let { text, createBlock, list, multi, html, toggler, comment } = bdom;`]; + if (this.helpers.size) { + mainCode.push(`let { ${[...this.helpers].join(", ")} } = helpers;`); + } + if (this.templateName) { + mainCode.push(`// Template name: "${this.templateName}"`); + } + for (let { id, expr } of this.staticDefs) { + mainCode.push(`const ${id} = ${expr};`); + } + // define all blocks + if (this.blocks.length) { + mainCode.push(``); + for (let block of this.blocks) { + if (block.dom) { + let xmlString = toStringExpression(block.asXmlString()); + if (block.dynamicTagName) { + xmlString = xmlString.replace(/^`<\w+/, `\`<\${tag || '${block.dom.nodeName}'}`); + xmlString = xmlString.replace(/\w+>`$/, `\${tag || '${block.dom.nodeName}'}>\``); + mainCode.push(`let ${block.blockName} = tag => createBlock(${xmlString});`); + } + else { + mainCode.push(`let ${block.blockName} = createBlock(${xmlString});`); + } + } + } + } + // define all slots/defaultcontent function + if (this.targets.length) { + for (let fn of this.targets) { + mainCode.push(""); + mainCode = mainCode.concat(fn.generateCode()); + } + } + // generate main code + mainCode.push(""); + mainCode = mainCode.concat("return " + this.target.generateCode()); + const code = mainCode.join("\n "); + if (this.isDebug) { + const msg = `[Owl Debug]\n${code}`; + console.log(msg); + } + return code; + } + compileInNewTarget(prefix, ast, ctx, on) { + const name = generateId(prefix); + const initialTarget = this.target; + const target = new CodeTarget(name, on); + this.targets.push(target); + this.target = target; + this.compileAST(ast, createContext(ctx)); + this.target = initialTarget; + return name; + } + addLine(line, idx) { + this.target.addLine(line, idx); + } + define(varName, expr) { + this.addLine(`const ${varName} = ${expr};`); + } + insertAnchor(block, index = block.children.length) { + const tag = `block-child-${index}`; + const anchor = xmlDoc.createElement(tag); + block.insert(anchor); + } + createBlock(parentBlock, type, ctx) { + const hasRoot = this.target.hasRoot; + const block = new BlockDescription(this.target, type); + if (!hasRoot) { + this.target.hasRoot = true; + block.isRoot = true; + } + if (parentBlock) { + parentBlock.children.push(block); + if (parentBlock.type === "list") { + block.parentVar = `c_block${parentBlock.id}`; + } + } + return block; + } + insertBlock(expression, block, ctx) { + let blockExpr = block.generateExpr(expression); + if (block.parentVar) { + let key = this.target.currentKey(ctx); + this.helpers.add("withKey"); + this.addLine(`${block.parentVar}[${ctx.index}] = withKey(${blockExpr}, ${key});`); + return; + } + if (ctx.tKeyExpr) { + blockExpr = `toggler(${ctx.tKeyExpr}, ${blockExpr})`; + } + if (block.isRoot) { + if (this.target.on) { + blockExpr = this.wrapWithEventCatcher(blockExpr, this.target.on); + } + this.addLine(`return ${blockExpr};`); + } + else { + this.define(block.varName, blockExpr); + } + } + /** + * Captures variables that are used inside of an expression. This is useful + * because in compiled code, almost all variables are accessed through the ctx + * object. In the case of functions, that lookup in the context can be delayed + * which can cause issues if the value has changed since the function was + * defined. + * + * @param expr the expression to capture + * @param forceCapture whether the expression should capture its scope even if + * it doesn't contain a function. Useful when the expression will be used as + * a function body. + * @returns a new expression that uses the captured values + */ + captureExpression(expr, forceCapture = false) { + if (!forceCapture && !expr.includes("=>")) { + return compileExpr(expr); + } + const tokens = compileExprToArray(expr); + const mapping = new Map(); + return tokens + .map((tok) => { + if (tok.varName && !tok.isLocal) { + if (!mapping.has(tok.varName)) { + const varId = generateId("v"); + mapping.set(tok.varName, varId); + this.define(varId, tok.value); + } + tok.value = mapping.get(tok.varName); + } + return tok.value; + }) + .join(""); + } + translate(str, translationCtx) { + const match = translationRE.exec(str); + return match[1] + this.translateFn(match[2], translationCtx) + match[3]; + } + /** + * @returns the newly created block name, if any + */ + compileAST(ast, ctx) { + switch (ast.type) { + case 1 /* Comment */: + return this.compileComment(ast, ctx); + case 0 /* Text */: + return this.compileText(ast, ctx); + case 2 /* DomNode */: + return this.compileTDomNode(ast, ctx); + case 4 /* TEsc */: + return this.compileTEsc(ast, ctx); + case 8 /* TOut */: + return this.compileTOut(ast, ctx); + case 5 /* TIf */: + return this.compileTIf(ast, ctx); + case 9 /* TForEach */: + return this.compileTForeach(ast, ctx); + case 10 /* TKey */: + return this.compileTKey(ast, ctx); + case 3 /* Multi */: + return this.compileMulti(ast, ctx); + case 7 /* TCall */: + return this.compileTCall(ast, ctx); + case 15 /* TCallBlock */: + return this.compileTCallBlock(ast, ctx); + case 6 /* TSet */: + return this.compileTSet(ast, ctx); + case 11 /* TComponent */: + return this.compileComponent(ast, ctx); + case 12 /* TDebug */: + return this.compileDebug(ast, ctx); + case 13 /* TLog */: + return this.compileLog(ast, ctx); + case 14 /* TSlot */: + return this.compileTSlot(ast, ctx); + case 16 /* TTranslation */: + return this.compileTTranslation(ast, ctx); + case 17 /* TTranslationContext */: + return this.compileTTranslationContext(ast, ctx); + case 18 /* TPortal */: + return this.compileTPortal(ast, ctx); + } + } + compileDebug(ast, ctx) { + this.addLine(`debugger;`); + if (ast.content) { + return this.compileAST(ast.content, ctx); + } + return null; + } + compileLog(ast, ctx) { + this.addLine(`console.log(${compileExpr(ast.expr)});`); + if (ast.content) { + return this.compileAST(ast.content, ctx); + } + return null; + } + compileComment(ast, ctx) { + let { block, forceNewBlock } = ctx; + const isNewBlock = !block || forceNewBlock; + if (isNewBlock) { + block = this.createBlock(block, "comment", ctx); + this.insertBlock(`comment(${toStringExpression(ast.value)})`, block, { + ...ctx, + forceNewBlock: forceNewBlock && !block, + }); + } + else { + const text = xmlDoc.createComment(ast.value); + block.insert(text); + } + return block.varName; + } + compileText(ast, ctx) { + let { block, forceNewBlock } = ctx; + let value = ast.value; + if (value && ctx.translate !== false) { + value = this.translate(value, ctx.translationCtx); + } + if (!ctx.inPreTag) { + value = value.replace(whitespaceRE, " "); + } + if (!block || forceNewBlock) { + block = this.createBlock(block, "text", ctx); + this.insertBlock(`text(${toStringExpression(value)})`, block, { + ...ctx, + forceNewBlock: forceNewBlock && !block, + }); + } + else { + const createFn = ast.type === 0 /* Text */ ? xmlDoc.createTextNode : xmlDoc.createComment; + block.insert(createFn.call(xmlDoc, value)); + } + return block.varName; + } + generateHandlerCode(rawEvent, handler) { + const modifiers = rawEvent + .split(".") + .slice(1) + .map((m) => { + if (!MODS.has(m)) { + throw new OwlError(`Unknown event modifier: '${m}'`); + } + return `"${m}"`; + }); + let modifiersCode = ""; + if (modifiers.length) { + modifiersCode = `${modifiers.join(",")}, `; + } + return `[${modifiersCode}${this.captureExpression(handler)}, ctx]`; + } + compileTDomNode(ast, ctx) { + var _a; + let { block, forceNewBlock } = ctx; + const isNewBlock = !block || forceNewBlock || ast.dynamicTag !== null || ast.ns; + let codeIdx = this.target.code.length; + if (isNewBlock) { + if ((ast.dynamicTag || ctx.tKeyExpr || ast.ns) && ctx.block) { + this.insertAnchor(ctx.block); + } + block = this.createBlock(block, "block", ctx); + this.blocks.push(block); + if (ast.dynamicTag) { + const tagExpr = generateId("tag"); + this.define(tagExpr, compileExpr(ast.dynamicTag)); + block.dynamicTagName = tagExpr; + } + } + // attributes + const attrs = {}; + for (let key in ast.attrs) { + let expr, attrName; + if (key.startsWith("t-attf")) { + expr = interpolate(ast.attrs[key]); + const idx = block.insertData(expr, "attr"); + attrName = key.slice(7); + attrs["block-attribute-" + idx] = attrName; + } + else if (key.startsWith("t-att")) { + attrName = key === "t-att" ? null : key.slice(6); + expr = compileExpr(ast.attrs[key]); + if (attrName && isProp(ast.tag, attrName)) { + if (attrName === "readonly") { + // the property has a different name than the attribute + attrName = "readOnly"; + } + // we force a new string or new boolean to bypass the equality check in blockdom when patching same value + if (attrName === "value") { + // When the expression is falsy (except 0), fall back to an empty string + expr = `new String((${expr}) === 0 ? 0 : ((${expr}) || ""))`; + } + else { + expr = `new Boolean(${expr})`; + } + const idx = block.insertData(expr, "prop"); + attrs[`block-property-${idx}`] = attrName; + } + else { + const idx = block.insertData(expr, "attr"); + if (key === "t-att") { + attrs[`block-attributes`] = String(idx); + } + else { + attrs[`block-attribute-${idx}`] = attrName; + } + } + } + else if (this.translatableAttributes.includes(key)) { + const attrTranslationCtx = ((_a = ast.attrsTranslationCtx) === null || _a === void 0 ? void 0 : _a[key]) || ctx.translationCtx; + attrs[key] = this.translateFn(ast.attrs[key], attrTranslationCtx); + } + else { + expr = `"${ast.attrs[key]}"`; + attrName = key; + attrs[key] = ast.attrs[key]; + } + if (attrName === "value" && ctx.tModelSelectedExpr) { + let selectedId = block.insertData(`${ctx.tModelSelectedExpr} === ${expr}`, "attr"); + attrs[`block-attribute-${selectedId}`] = "selected"; + } + } + // t-model + let tModelSelectedExpr; + if (ast.model) { + const { hasDynamicChildren, baseExpr, expr, eventType, shouldNumberize, shouldTrim, targetAttr, specialInitTargetAttr, } = ast.model; + const baseExpression = compileExpr(baseExpr); + const bExprId = generateId("bExpr"); + this.define(bExprId, baseExpression); + const expression = compileExpr(expr); + const exprId = generateId("expr"); + this.define(exprId, expression); + const fullExpression = `${bExprId}[${exprId}]`; + let idx; + if (specialInitTargetAttr) { + let targetExpr = targetAttr in attrs && `'${attrs[targetAttr]}'`; + if (!targetExpr && ast.attrs) { + // look at the dynamic attribute counterpart + const dynamicTgExpr = ast.attrs[`t-att-${targetAttr}`]; + if (dynamicTgExpr) { + targetExpr = compileExpr(dynamicTgExpr); + } + } + idx = block.insertData(`${fullExpression} === ${targetExpr}`, "prop"); + attrs[`block-property-${idx}`] = specialInitTargetAttr; + } + else if (hasDynamicChildren) { + const bValueId = generateId("bValue"); + tModelSelectedExpr = `${bValueId}`; + this.define(tModelSelectedExpr, fullExpression); + } + else { + idx = block.insertData(`${fullExpression}`, "prop"); + attrs[`block-property-${idx}`] = targetAttr; + } + this.helpers.add("toNumber"); + let valueCode = `ev.target.${targetAttr}`; + valueCode = shouldTrim ? `${valueCode}.trim()` : valueCode; + valueCode = shouldNumberize ? `toNumber(${valueCode})` : valueCode; + const handler = `[(ev) => { ${fullExpression} = ${valueCode}; }]`; + idx = block.insertData(handler, "hdlr"); + attrs[`block-handler-${idx}`] = eventType; + } + // event handlers + for (let ev in ast.on) { + const name = this.generateHandlerCode(ev, ast.on[ev]); + const idx = block.insertData(name, "hdlr"); + attrs[`block-handler-${idx}`] = ev; + } + // t-ref + if (ast.ref) { + if (this.dev) { + this.helpers.add("makeRefWrapper"); + this.target.hasRefWrapper = true; + } + const isDynamic = INTERP_REGEXP.test(ast.ref); + let name = `\`${ast.ref}\``; + if (isDynamic) { + name = replaceDynamicParts(ast.ref, (expr) => this.captureExpression(expr, true)); + } + let setRefStr = `(el) => this.__owl__.setRef((${name}), el)`; + if (this.dev) { + setRefStr = `refWrapper(${name}, ${setRefStr})`; + } + const idx = block.insertData(setRefStr, "ref"); + attrs["block-ref"] = String(idx); + } + const nameSpace = ast.ns || ctx.nameSpace; + const dom = nameSpace + ? xmlDoc.createElementNS(nameSpace, ast.tag) + : xmlDoc.createElement(ast.tag); + for (const [attr, val] of Object.entries(attrs)) { + if (!(attr === "class" && val === "")) { + dom.setAttribute(attr, val); + } + } + block.insert(dom); + if (ast.content.length) { + const initialDom = block.currentDom; + block.currentDom = dom; + const children = ast.content; + for (let i = 0; i < children.length; i++) { + const child = ast.content[i]; + const subCtx = createContext(ctx, { + block, + index: block.childNumber, + forceNewBlock: false, + isLast: ctx.isLast && i === children.length - 1, + tKeyExpr: ctx.tKeyExpr, + nameSpace, + tModelSelectedExpr, + inPreTag: ctx.inPreTag || ast.tag === "pre", + }); + this.compileAST(child, subCtx); + } + block.currentDom = initialDom; + } + if (isNewBlock) { + this.insertBlock(`${block.blockName}(ddd)`, block, ctx); + // may need to rewrite code! + if (block.children.length && block.hasDynamicChildren) { + const code = this.target.code; + const children = block.children.slice(); + let current = children.shift(); + for (let i = codeIdx; i < code.length; i++) { + if (code[i].trimStart().startsWith(`const ${current.varName} `)) { + code[i] = code[i].replace(`const ${current.varName}`, current.varName); + current = children.shift(); + if (!current) + break; + } + } + this.addLine(`let ${block.children.map((c) => c.varName).join(", ")};`, codeIdx); + } + } + return block.varName; + } + compileTEsc(ast, ctx) { + let { block, forceNewBlock } = ctx; + let expr; + if (ast.expr === "0") { + this.helpers.add("zero"); + expr = `ctx[zero]`; + } + else { + expr = compileExpr(ast.expr); + if (ast.defaultValue) { + this.helpers.add("withDefault"); + // FIXME: defaultValue is not translated + expr = `withDefault(${expr}, ${toStringExpression(ast.defaultValue)})`; + } + } + if (!block || forceNewBlock) { + block = this.createBlock(block, "text", ctx); + this.insertBlock(`text(${expr})`, block, { ...ctx, forceNewBlock: forceNewBlock && !block }); + } + else { + const idx = block.insertData(expr, "txt"); + const text = xmlDoc.createElement(`block-text-${idx}`); + block.insert(text); + } + return block.varName; + } + compileTOut(ast, ctx) { + let { block } = ctx; + if (block) { + this.insertAnchor(block); + } + block = this.createBlock(block, "html", ctx); + let blockStr; + if (ast.expr === "0") { + this.helpers.add("zero"); + blockStr = `ctx[zero]`; + } + else if (ast.body) { + let bodyValue = null; + bodyValue = BlockDescription.nextBlockId; + const subCtx = createContext(ctx); + this.compileAST({ type: 3 /* Multi */, content: ast.body }, subCtx); + this.helpers.add("safeOutput"); + blockStr = `safeOutput(${compileExpr(ast.expr)}, b${bodyValue})`; + } + else { + this.helpers.add("safeOutput"); + blockStr = `safeOutput(${compileExpr(ast.expr)})`; + } + this.insertBlock(blockStr, block, ctx); + return block.varName; + } + compileTIfBranch(content, block, ctx) { + this.target.indentLevel++; + let childN = block.children.length; + this.compileAST(content, createContext(ctx, { block, index: ctx.index })); + if (block.children.length > childN) { + // we have some content => need to insert an anchor at correct index + this.insertAnchor(block, childN); + } + this.target.indentLevel--; + } + compileTIf(ast, ctx, nextNode) { + let { block, forceNewBlock } = ctx; + const codeIdx = this.target.code.length; + const isNewBlock = !block || (block.type !== "multi" && forceNewBlock); + if (block) { + block.hasDynamicChildren = true; + } + if (!block || (block.type !== "multi" && forceNewBlock)) { + block = this.createBlock(block, "multi", ctx); + } + this.addLine(`if (${compileExpr(ast.condition)}) {`); + this.compileTIfBranch(ast.content, block, ctx); + if (ast.tElif) { + for (let clause of ast.tElif) { + this.addLine(`} else if (${compileExpr(clause.condition)}) {`); + this.compileTIfBranch(clause.content, block, ctx); + } + } + if (ast.tElse) { + this.addLine(`} else {`); + this.compileTIfBranch(ast.tElse, block, ctx); + } + this.addLine("}"); + if (isNewBlock) { + // note: this part is duplicated from end of compiledomnode: + if (block.children.length) { + const code = this.target.code; + const children = block.children.slice(); + let current = children.shift(); + for (let i = codeIdx; i < code.length; i++) { + if (code[i].trimStart().startsWith(`const ${current.varName} `)) { + code[i] = code[i].replace(`const ${current.varName}`, current.varName); + current = children.shift(); + if (!current) + break; + } + } + this.addLine(`let ${block.children.map((c) => c.varName).join(", ")};`, codeIdx); + } + // note: this part is duplicated from end of compilemulti: + const args = block.children.map((c) => c.varName).join(", "); + this.insertBlock(`multi([${args}])`, block, ctx); + } + return block.varName; + } + compileTForeach(ast, ctx) { + let { block } = ctx; + if (block) { + this.insertAnchor(block); + } + block = this.createBlock(block, "list", ctx); + this.target.loopLevel++; + const loopVar = `i${this.target.loopLevel}`; + this.addLine(`ctx = Object.create(ctx);`); + const vals = `v_block${block.id}`; + const keys = `k_block${block.id}`; + const l = `l_block${block.id}`; + const c = `c_block${block.id}`; + this.helpers.add("prepareList"); + this.define(`[${keys}, ${vals}, ${l}, ${c}]`, `prepareList(${compileExpr(ast.collection)});`); + // Throw errors on duplicate keys in dev mode + if (this.dev) { + this.define(`keys${block.id}`, `new Set()`); + } + this.addLine(`for (let ${loopVar} = 0; ${loopVar} < ${l}; ${loopVar}++) {`); + this.target.indentLevel++; + this.addLine(`ctx[\`${ast.elem}\`] = ${keys}[${loopVar}];`); + if (!ast.hasNoFirst) { + this.addLine(`ctx[\`${ast.elem}_first\`] = ${loopVar} === 0;`); + } + if (!ast.hasNoLast) { + this.addLine(`ctx[\`${ast.elem}_last\`] = ${loopVar} === ${keys}.length - 1;`); + } + if (!ast.hasNoIndex) { + this.addLine(`ctx[\`${ast.elem}_index\`] = ${loopVar};`); + } + if (!ast.hasNoValue) { + this.addLine(`ctx[\`${ast.elem}_value\`] = ${vals}[${loopVar}];`); + } + this.define(`key${this.target.loopLevel}`, ast.key ? compileExpr(ast.key) : loopVar); + if (this.dev) { + // Throw error on duplicate keys in dev mode + this.helpers.add("OwlError"); + this.addLine(`if (keys${block.id}.has(String(key${this.target.loopLevel}))) { throw new OwlError(\`Got duplicate key in t-foreach: \${key${this.target.loopLevel}}\`)}`); + this.addLine(`keys${block.id}.add(String(key${this.target.loopLevel}));`); + } + let id; + if (ast.memo) { + this.target.hasCache = true; + id = generateId(); + this.define(`memo${id}`, compileExpr(ast.memo)); + this.define(`vnode${id}`, `cache[key${this.target.loopLevel}];`); + this.addLine(`if (vnode${id}) {`); + this.target.indentLevel++; + this.addLine(`if (shallowEqual(vnode${id}.memo, memo${id})) {`); + this.target.indentLevel++; + this.addLine(`${c}[${loopVar}] = vnode${id};`); + this.addLine(`nextCache[key${this.target.loopLevel}] = vnode${id};`); + this.addLine(`continue;`); + this.target.indentLevel--; + this.addLine("}"); + this.target.indentLevel--; + this.addLine("}"); + } + const subCtx = createContext(ctx, { block, index: loopVar }); + this.compileAST(ast.body, subCtx); + if (ast.memo) { + this.addLine(`nextCache[key${this.target.loopLevel}] = Object.assign(${c}[${loopVar}], {memo: memo${id}});`); + } + this.target.indentLevel--; + this.target.loopLevel--; + this.addLine(`}`); + if (!ctx.isLast) { + this.addLine(`ctx = ctx.__proto__;`); + } + this.insertBlock("l", block, ctx); + return block.varName; + } + compileTKey(ast, ctx) { + const tKeyExpr = generateId("tKey_"); + this.define(tKeyExpr, compileExpr(ast.expr)); + ctx = createContext(ctx, { + tKeyExpr, + block: ctx.block, + index: ctx.index, + }); + return this.compileAST(ast.content, ctx); + } + compileMulti(ast, ctx) { + let { block, forceNewBlock } = ctx; + const isNewBlock = !block || forceNewBlock; + let codeIdx = this.target.code.length; + if (isNewBlock) { + const n = ast.content.filter((c) => c.type !== 6 /* TSet */).length; + let result = null; + if (n <= 1) { + for (let child of ast.content) { + const blockName = this.compileAST(child, ctx); + result = result || blockName; + } + return result; + } + block = this.createBlock(block, "multi", ctx); + } + let index = 0; + for (let i = 0, l = ast.content.length; i < l; i++) { + const child = ast.content[i]; + const isTSet = child.type === 6 /* TSet */; + const subCtx = createContext(ctx, { + block, + index, + forceNewBlock: !isTSet, + isLast: ctx.isLast && i === l - 1, + }); + this.compileAST(child, subCtx); + if (!isTSet) { + index++; + } + } + if (isNewBlock) { + if (block.hasDynamicChildren && block.children.length) { + const code = this.target.code; + const children = block.children.slice(); + let current = children.shift(); + for (let i = codeIdx; i < code.length; i++) { + if (code[i].trimStart().startsWith(`const ${current.varName} `)) { + code[i] = code[i].replace(`const ${current.varName}`, current.varName); + current = children.shift(); + if (!current) + break; + } + } + this.addLine(`let ${block.children.map((c) => c.varName).join(", ")};`, codeIdx); + } + const args = block.children.map((c) => c.varName).join(", "); + this.insertBlock(`multi([${args}])`, block, ctx); + } + return block.varName; + } + compileTCall(ast, ctx) { + let { block, forceNewBlock } = ctx; + let ctxVar = ctx.ctxVar || "ctx"; + if (ast.context) { + ctxVar = generateId("ctx"); + this.addLine(`let ${ctxVar} = ${compileExpr(ast.context)};`); + } + const isDynamic = INTERP_REGEXP.test(ast.name); + const subTemplate = isDynamic ? interpolate(ast.name) : "`" + ast.name + "`"; + if (block && !forceNewBlock) { + this.insertAnchor(block); + } + block = this.createBlock(block, "multi", ctx); + if (ast.body) { + this.addLine(`${ctxVar} = Object.create(${ctxVar});`); + this.addLine(`${ctxVar}[isBoundary] = 1;`); + this.helpers.add("isBoundary"); + const subCtx = createContext(ctx, { ctxVar }); + const bl = this.compileMulti({ type: 3 /* Multi */, content: ast.body }, subCtx); + if (bl) { + this.helpers.add("zero"); + this.addLine(`${ctxVar}[zero] = ${bl};`); + } + } + const key = this.generateComponentKey(); + if (isDynamic) { + const templateVar = generateId("template"); + if (!this.staticDefs.find((d) => d.id === "call")) { + this.staticDefs.push({ id: "call", expr: `app.callTemplate.bind(app)` }); + } + this.define(templateVar, subTemplate); + this.insertBlock(`call(this, ${templateVar}, ${ctxVar}, node, ${key})`, block, { + ...ctx, + forceNewBlock: !block, + }); + } + else { + const id = generateId(`callTemplate_`); + this.staticDefs.push({ id, expr: `app.getTemplate(${subTemplate})` }); + this.insertBlock(`${id}.call(this, ${ctxVar}, node, ${key})`, block, { + ...ctx, + forceNewBlock: !block, + }); + } + if (ast.body && !ctx.isLast) { + this.addLine(`${ctxVar} = ${ctxVar}.__proto__;`); + } + return block.varName; + } + compileTCallBlock(ast, ctx) { + let { block, forceNewBlock } = ctx; + if (block) { + if (!forceNewBlock) { + this.insertAnchor(block); + } + } + block = this.createBlock(block, "multi", ctx); + this.insertBlock(compileExpr(ast.name), block, { ...ctx, forceNewBlock: !block }); + return block.varName; + } + compileTSet(ast, ctx) { + this.target.shouldProtectScope = true; + this.helpers.add("isBoundary").add("withDefault"); + const expr = ast.value ? compileExpr(ast.value || "") : "null"; + if (ast.body) { + this.helpers.add("LazyValue"); + const bodyAst = { type: 3 /* Multi */, content: ast.body }; + const name = this.compileInNewTarget("value", bodyAst, ctx); + let key = this.target.currentKey(ctx); + let value = `new LazyValue(${name}, ctx, this, node, ${key})`; + value = ast.value ? (value ? `withDefault(${expr}, ${value})` : expr) : value; + this.addLine(`ctx[\`${ast.name}\`] = ${value};`); + } + else { + let value; + if (ast.defaultValue) { + const defaultValue = toStringExpression(ctx.translate ? this.translate(ast.defaultValue, ctx.translationCtx) : ast.defaultValue); + if (ast.value) { + value = `withDefault(${expr}, ${defaultValue})`; + } + else { + value = defaultValue; + } + } + else { + value = expr; + } + this.helpers.add("setContextValue"); + this.addLine(`setContextValue(${ctx.ctxVar || "ctx"}, "${ast.name}", ${value});`); + } + return null; + } + generateComponentKey(currentKey = "key") { + const parts = [generateId("__")]; + for (let i = 0; i < this.target.loopLevel; i++) { + parts.push(`\${key${i + 1}}`); + } + return `${currentKey} + \`${parts.join("__")}\``; + } + /** + * Formats a prop name and value into a string suitable to be inserted in the + * generated code. For example: + * + * Name Value Result + * --------------------------------------------------------- + * "number" "state" "number: ctx['state']" + * "something" "" "something: undefined" + * "some-prop" "state" "'some-prop': ctx['state']" + * "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])" + */ + formatProp(name, value, attrsTranslationCtx, translationCtx) { + if (name.endsWith(".translate")) { + const attrTranslationCtx = (attrsTranslationCtx === null || attrsTranslationCtx === void 0 ? void 0 : attrsTranslationCtx[name]) || translationCtx; + value = toStringExpression(this.translateFn(value, attrTranslationCtx)); + } + else { + value = this.captureExpression(value); + } + if (name.includes(".")) { + let [_name, suffix] = name.split("."); + name = _name; + switch (suffix) { + case "bind": + value = `(${value}).bind(this)`; + break; + case "alike": + case "translate": + break; + default: + throw new OwlError(`Invalid prop suffix: ${suffix}`); + } + } + name = /^[a-z_]+$/i.test(name) ? name : `'${name}'`; + return `${name}: ${value || undefined}`; + } + formatPropObject(obj, attrsTranslationCtx, translationCtx) { + return Object.entries(obj).map(([k, v]) => this.formatProp(k, v, attrsTranslationCtx, translationCtx)); + } + getPropString(props, dynProps) { + let propString = `{${props.join(",")}}`; + if (dynProps) { + propString = `Object.assign({}, ${compileExpr(dynProps)}${props.length ? ", " + propString : ""})`; + } + return propString; + } + compileComponent(ast, ctx) { + let { block } = ctx; + // props + const hasSlotsProp = "slots" in (ast.props || {}); + const props = ast.props + ? this.formatPropObject(ast.props, ast.propsTranslationCtx, ctx.translationCtx) + : []; + // slots + let slotDef = ""; + if (ast.slots) { + let ctxStr = "ctx"; + if (this.target.loopLevel || !this.hasSafeContext) { + ctxStr = generateId("ctx"); + this.helpers.add("capture"); + this.define(ctxStr, `capture(ctx)`); + } + let slotStr = []; + for (let slotName in ast.slots) { + const slotAst = ast.slots[slotName]; + const params = []; + if (slotAst.content) { + const name = this.compileInNewTarget("slot", slotAst.content, ctx, slotAst.on); + params.push(`__render: ${name}.bind(this), __ctx: ${ctxStr}`); + } + const scope = ast.slots[slotName].scope; + if (scope) { + params.push(`__scope: "${scope}"`); + } + if (ast.slots[slotName].attrs) { + params.push(...this.formatPropObject(ast.slots[slotName].attrs, ast.slots[slotName].attrsTranslationCtx, ctx.translationCtx)); + } + const slotInfo = `{${params.join(", ")}}`; + slotStr.push(`'${slotName}': ${slotInfo}`); + } + slotDef = `{${slotStr.join(", ")}}`; + } + if (slotDef && !(ast.dynamicProps || hasSlotsProp)) { + this.helpers.add("markRaw"); + props.push(`slots: markRaw(${slotDef})`); + } + let propString = this.getPropString(props, ast.dynamicProps); + let propVar; + if ((slotDef && (ast.dynamicProps || hasSlotsProp)) || this.dev) { + propVar = generateId("props"); + this.define(propVar, propString); + propString = propVar; + } + if (slotDef && (ast.dynamicProps || hasSlotsProp)) { + this.helpers.add("markRaw"); + this.addLine(`${propVar}.slots = markRaw(Object.assign(${slotDef}, ${propVar}.slots))`); + } + // cmap key + let expr; + if (ast.isDynamic) { + expr = generateId("Comp"); + this.define(expr, compileExpr(ast.name)); + } + else { + expr = `\`${ast.name}\``; + } + if (this.dev) { + this.addLine(`helpers.validateProps(${expr}, ${propVar}, this);`); + } + if (block && (ctx.forceNewBlock === false || ctx.tKeyExpr)) { + // todo: check the forcenewblock condition + this.insertAnchor(block); + } + let keyArg = this.generateComponentKey(); + if (ctx.tKeyExpr) { + keyArg = `${ctx.tKeyExpr} + ${keyArg}`; + } + let id = generateId("comp"); + const propList = []; + for (let p in ast.props || {}) { + let [name, suffix] = p.split("."); + if (!suffix) { + propList.push(`"${name}"`); + } + } + this.staticDefs.push({ + id, + expr: `app.createComponent(${ast.isDynamic ? null : expr}, ${!ast.isDynamic}, ${!!ast.slots}, ${!!ast.dynamicProps}, [${propList}])`, + }); + if (ast.isDynamic) { + // If the component class changes, this can cause delayed renders to go + // through if the key doesn't change. Use the component name for now. + // This means that two component classes with the same name isn't supported + // in t-component. We can generate a unique id per class later if needed. + keyArg = `(${expr}).name + ${keyArg}`; + } + let blockExpr = `${id}(${propString}, ${keyArg}, node, this, ${ast.isDynamic ? expr : null})`; + if (ast.isDynamic) { + blockExpr = `toggler(${expr}, ${blockExpr})`; + } + // event handling + if (ast.on) { + blockExpr = this.wrapWithEventCatcher(blockExpr, ast.on); + } + block = this.createBlock(block, "multi", ctx); + this.insertBlock(blockExpr, block, ctx); + return block.varName; + } + wrapWithEventCatcher(expr, on) { + this.helpers.add("createCatcher"); + let name = generateId("catcher"); + let spec = {}; + let handlers = []; + for (let ev in on) { + let handlerId = generateId("hdlr"); + let idx = handlers.push(handlerId) - 1; + spec[ev] = idx; + const handler = this.generateHandlerCode(ev, on[ev]); + this.define(handlerId, handler); + } + this.staticDefs.push({ id: name, expr: `createCatcher(${JSON.stringify(spec)})` }); + return `${name}(${expr}, [${handlers.join(",")}])`; + } + compileTSlot(ast, ctx) { + this.helpers.add("callSlot"); + let { block } = ctx; + let blockString; + let slotName; + let dynamic = false; + let isMultiple = false; + if (ast.name.match(INTERP_REGEXP)) { + dynamic = true; + isMultiple = true; + slotName = interpolate(ast.name); + } + else { + slotName = "'" + ast.name + "'"; + isMultiple = isMultiple || this.slotNames.has(ast.name); + this.slotNames.add(ast.name); + } + const attrs = { ...ast.attrs }; + const dynProps = attrs["t-props"]; + delete attrs["t-props"]; + let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key"; + if (isMultiple) { + key = this.generateComponentKey(key); + } + const props = ast.attrs + ? this.formatPropObject(attrs, ast.attrsTranslationCtx, ctx.translationCtx) + : []; + const scope = this.getPropString(props, dynProps); + if (ast.defaultContent) { + const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx); + blockString = `callSlot(ctx, node, ${key}, ${slotName}, ${dynamic}, ${scope}, ${name}.bind(this))`; + } + else { + if (dynamic) { + let name = generateId("slot"); + this.define(name, slotName); + blockString = `toggler(${name}, callSlot(ctx, node, ${key}, ${name}, ${dynamic}, ${scope}))`; + } + else { + blockString = `callSlot(ctx, node, ${key}, ${slotName}, ${dynamic}, ${scope})`; + } + } + // event handling + if (ast.on) { + blockString = this.wrapWithEventCatcher(blockString, ast.on); + } + if (block) { + this.insertAnchor(block); + } + block = this.createBlock(block, "multi", ctx); + this.insertBlock(blockString, block, { ...ctx, forceNewBlock: false }); + return block.varName; + } + compileTTranslation(ast, ctx) { + if (ast.content) { + return this.compileAST(ast.content, Object.assign({}, ctx, { translate: false })); + } + return null; + } + compileTTranslationContext(ast, ctx) { + if (ast.content) { + return this.compileAST(ast.content, Object.assign({}, ctx, { translationCtx: ast.translationCtx })); + } + return null; + } + compileTPortal(ast, ctx) { + if (!this.staticDefs.find((d) => d.id === "Portal")) { + this.staticDefs.push({ id: "Portal", expr: `app.Portal` }); + } + let { block } = ctx; + const name = this.compileInNewTarget("slot", ast.content, ctx); + let ctxStr = "ctx"; + if (this.target.loopLevel || !this.hasSafeContext) { + ctxStr = generateId("ctx"); + this.helpers.add("capture"); + this.define(ctxStr, `capture(ctx)`); + } + let id = generateId("comp"); + this.staticDefs.push({ + id, + expr: `app.createComponent(null, false, true, false, false)`, + }); + const target = compileExpr(ast.target); + const key = this.generateComponentKey(); + const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, ${key}, node, ctx, Portal)`; + if (block) { + this.insertAnchor(block); + } + block = this.createBlock(block, "multi", ctx); + this.insertBlock(blockString, block, { ...ctx, forceNewBlock: false }); + return block.varName; + } + } + + // ----------------------------------------------------------------------------- + // Parser + // ----------------------------------------------------------------------------- + const cache = new WeakMap(); + function parse(xml, customDir) { + const ctx = { + inPreTag: false, + customDirectives: customDir, + }; + if (typeof xml === "string") { + const elem = parseXML(`<t>${xml}</t>`).firstChild; + return _parse(elem, ctx); + } + let ast = cache.get(xml); + if (!ast) { + // we clone here the xml to prevent modifying it in place + ast = _parse(xml.cloneNode(true), ctx); + cache.set(xml, ast); + } + return ast; + } + function _parse(xml, ctx) { + normalizeXML(xml); + return parseNode(xml, ctx) || { type: 0 /* Text */, value: "" }; + } + function parseNode(node, ctx) { + if (!(node instanceof Element)) { + return parseTextCommentNode(node, ctx); + } + return (parseTCustom(node, ctx) || + parseTDebugLog(node, ctx) || + parseTForEach(node, ctx) || + parseTIf(node, ctx) || + parseTPortal(node, ctx) || + parseTCall(node, ctx) || + parseTCallBlock(node) || + parseTEscNode(node, ctx) || + parseTOutNode(node, ctx) || + parseTKey(node, ctx) || + parseTTranslation(node, ctx) || + parseTTranslationContext(node, ctx) || + parseTSlot(node, ctx) || + parseComponent(node, ctx) || + parseDOMNode(node, ctx) || + parseTSetNode(node, ctx) || + parseTNode(node, ctx)); + } + // ----------------------------------------------------------------------------- + // <t /> tag + // ----------------------------------------------------------------------------- + function parseTNode(node, ctx) { + if (node.tagName !== "t") { + return null; + } + return parseChildNodes(node, ctx); + } + // ----------------------------------------------------------------------------- + // Text and Comment Nodes + // ----------------------------------------------------------------------------- + const lineBreakRE = /[\r\n]/; + function parseTextCommentNode(node, ctx) { + if (node.nodeType === Node.TEXT_NODE) { + let value = node.textContent || ""; + if (!ctx.inPreTag && lineBreakRE.test(value) && !value.trim()) { + return null; + } + return { type: 0 /* Text */, value }; + } + else if (node.nodeType === Node.COMMENT_NODE) { + return { type: 1 /* Comment */, value: node.textContent || "" }; + } + return null; + } + function parseTCustom(node, ctx) { + if (!ctx.customDirectives) { + return null; + } + const nodeAttrsNames = node.getAttributeNames(); + for (let attr of nodeAttrsNames) { + if (attr === "t-custom" || attr === "t-custom-") { + throw new OwlError("Missing custom directive name with t-custom directive"); + } + if (attr.startsWith("t-custom-")) { + const directiveName = attr.split(".")[0].slice(9); + const customDirective = ctx.customDirectives[directiveName]; + if (!customDirective) { + throw new OwlError(`Custom directive "${directiveName}" is not defined`); + } + const value = node.getAttribute(attr); + const modifiers = attr.split(".").slice(1); + node.removeAttribute(attr); + try { + customDirective(node, value, modifiers); + } + catch (error) { + throw new OwlError(`Custom directive "${directiveName}" throw the following error: ${error}`); + } + return parseNode(node, ctx); + } + } + return null; + } + // ----------------------------------------------------------------------------- + // debugging + // ----------------------------------------------------------------------------- + function parseTDebugLog(node, ctx) { + if (node.hasAttribute("t-debug")) { + node.removeAttribute("t-debug"); + return { + type: 12 /* TDebug */, + content: parseNode(node, ctx), + }; + } + if (node.hasAttribute("t-log")) { + const expr = node.getAttribute("t-log"); + node.removeAttribute("t-log"); + return { + type: 13 /* TLog */, + expr, + content: parseNode(node, ctx), + }; + } + return null; + } + // ----------------------------------------------------------------------------- + // Regular dom node + // ----------------------------------------------------------------------------- + const hasDotAtTheEnd = /\.[\w_]+\s*$/; + const hasBracketsAtTheEnd = /\[[^\[]+\]\s*$/; + const ROOT_SVG_TAGS = new Set(["svg", "g", "path"]); + function parseDOMNode(node, ctx) { + const { tagName } = node; + const dynamicTag = node.getAttribute("t-tag"); + node.removeAttribute("t-tag"); + if (tagName === "t" && !dynamicTag) { + return null; + } + if (tagName.startsWith("block-")) { + throw new OwlError(`Invalid tag name: '${tagName}'`); + } + ctx = Object.assign({}, ctx); + if (tagName === "pre") { + ctx.inPreTag = true; + } + let ns = !ctx.nameSpace && ROOT_SVG_TAGS.has(tagName) ? "http://www.w3.org/2000/svg" : null; + const ref = node.getAttribute("t-ref"); + node.removeAttribute("t-ref"); + const nodeAttrsNames = node.getAttributeNames(); + let attrs = null; + let attrsTranslationCtx = null; + let on = null; + let model = null; + for (let attr of nodeAttrsNames) { + const value = node.getAttribute(attr); + if (attr === "t-on" || attr === "t-on-") { + throw new OwlError("Missing event name with t-on directive"); + } + if (attr.startsWith("t-on-")) { + on = on || {}; + on[attr.slice(5)] = value; + } + else if (attr.startsWith("t-model")) { + if (!["input", "select", "textarea"].includes(tagName)) { + throw new OwlError("The t-model directive only works with <input>, <textarea> and <select>"); + } + let baseExpr, expr; + if (hasDotAtTheEnd.test(value)) { + const index = value.lastIndexOf("."); + baseExpr = value.slice(0, index); + expr = `'${value.slice(index + 1)}'`; + } + else if (hasBracketsAtTheEnd.test(value)) { + const index = value.lastIndexOf("["); + baseExpr = value.slice(0, index); + expr = value.slice(index + 1, -1); + } + else { + throw new OwlError(`Invalid t-model expression: "${value}" (it should be assignable)`); + } + const typeAttr = node.getAttribute("type"); + const isInput = tagName === "input"; + const isSelect = tagName === "select"; + const isCheckboxInput = isInput && typeAttr === "checkbox"; + const isRadioInput = isInput && typeAttr === "radio"; + const hasTrimMod = attr.includes(".trim"); + const hasLazyMod = hasTrimMod || attr.includes(".lazy"); + const hasNumberMod = attr.includes(".number"); + const eventType = isRadioInput ? "click" : isSelect || hasLazyMod ? "change" : "input"; + model = { + baseExpr, + expr, + targetAttr: isCheckboxInput ? "checked" : "value", + specialInitTargetAttr: isRadioInput ? "checked" : null, + eventType, + hasDynamicChildren: false, + shouldTrim: hasTrimMod, + shouldNumberize: hasNumberMod, + }; + if (isSelect) { + // don't pollute the original ctx + ctx = Object.assign({}, ctx); + ctx.tModelInfo = model; + } + } + else if (attr.startsWith("block-")) { + throw new OwlError(`Invalid attribute: '${attr}'`); + } + else if (attr === "xmlns") { + ns = value; + } + else if (attr.startsWith("t-translation-context-")) { + const attrName = attr.slice(22); + attrsTranslationCtx = attrsTranslationCtx || {}; + attrsTranslationCtx[attrName] = value; + } + else if (attr !== "t-name") { + if (attr.startsWith("t-") && !attr.startsWith("t-att")) { + throw new OwlError(`Unknown QWeb directive: '${attr}'`); + } + const tModel = ctx.tModelInfo; + if (tModel && ["t-att-value", "t-attf-value"].includes(attr)) { + tModel.hasDynamicChildren = true; + } + attrs = attrs || {}; + attrs[attr] = value; + } + } + if (ns) { + ctx.nameSpace = ns; + } + const children = parseChildren(node, ctx); + return { + type: 2 /* DomNode */, + tag: tagName, + dynamicTag, + attrs, + attrsTranslationCtx, + on, + ref, + content: children, + model, + ns, + }; + } + // ----------------------------------------------------------------------------- + // t-esc + // ----------------------------------------------------------------------------- + function parseTEscNode(node, ctx) { + if (!node.hasAttribute("t-esc")) { + return null; + } + const escValue = node.getAttribute("t-esc"); + node.removeAttribute("t-esc"); + const tesc = { + type: 4 /* TEsc */, + expr: escValue, + defaultValue: node.textContent || "", + }; + let ref = node.getAttribute("t-ref"); + node.removeAttribute("t-ref"); + const ast = parseNode(node, ctx); + if (!ast) { + return tesc; + } + if (ast.type === 2 /* DomNode */) { + return { + ...ast, + ref, + content: [tesc], + }; + } + return tesc; + } + // ----------------------------------------------------------------------------- + // t-out + // ----------------------------------------------------------------------------- + function parseTOutNode(node, ctx) { + if (!node.hasAttribute("t-out") && !node.hasAttribute("t-raw")) { + return null; + } + if (node.hasAttribute("t-raw")) { + console.warn(`t-raw has been deprecated in favor of t-out. If the value to render is not wrapped by the "markup" function, it will be escaped`); + } + const expr = (node.getAttribute("t-out") || node.getAttribute("t-raw")); + node.removeAttribute("t-out"); + node.removeAttribute("t-raw"); + const tOut = { type: 8 /* TOut */, expr, body: null }; + const ref = node.getAttribute("t-ref"); + node.removeAttribute("t-ref"); + const ast = parseNode(node, ctx); + if (!ast) { + return tOut; + } + if (ast.type === 2 /* DomNode */) { + tOut.body = ast.content.length ? ast.content : null; + return { + ...ast, + ref, + content: [tOut], + }; + } + return tOut; + } + // ----------------------------------------------------------------------------- + // t-foreach and t-key + // ----------------------------------------------------------------------------- + function parseTForEach(node, ctx) { + if (!node.hasAttribute("t-foreach")) { + return null; + } + const html = node.outerHTML; + const collection = node.getAttribute("t-foreach"); + node.removeAttribute("t-foreach"); + const elem = node.getAttribute("t-as") || ""; + node.removeAttribute("t-as"); + const key = node.getAttribute("t-key"); + if (!key) { + throw new OwlError(`"Directive t-foreach should always be used with a t-key!" (expression: t-foreach="${collection}" t-as="${elem}")`); + } + node.removeAttribute("t-key"); + const memo = node.getAttribute("t-memo") || ""; + node.removeAttribute("t-memo"); + const body = parseNode(node, ctx); + if (!body) { + return null; + } + const hasNoTCall = !html.includes("t-call"); + const hasNoFirst = hasNoTCall && !html.includes(`${elem}_first`); + const hasNoLast = hasNoTCall && !html.includes(`${elem}_last`); + const hasNoIndex = hasNoTCall && !html.includes(`${elem}_index`); + const hasNoValue = hasNoTCall && !html.includes(`${elem}_value`); + return { + type: 9 /* TForEach */, + collection, + elem, + body, + memo, + key, + hasNoFirst, + hasNoLast, + hasNoIndex, + hasNoValue, + }; + } + function parseTKey(node, ctx) { + if (!node.hasAttribute("t-key")) { + return null; + } + const key = node.getAttribute("t-key"); + node.removeAttribute("t-key"); + const body = parseNode(node, ctx); + if (!body) { + return null; + } + return { type: 10 /* TKey */, expr: key, content: body }; + } + // ----------------------------------------------------------------------------- + // t-call + // ----------------------------------------------------------------------------- + function parseTCall(node, ctx) { + if (!node.hasAttribute("t-call")) { + return null; + } + const subTemplate = node.getAttribute("t-call"); + const context = node.getAttribute("t-call-context"); + node.removeAttribute("t-call"); + node.removeAttribute("t-call-context"); + if (node.tagName !== "t") { + const ast = parseNode(node, ctx); + const tcall = { type: 7 /* TCall */, name: subTemplate, body: null, context }; + if (ast && ast.type === 2 /* DomNode */) { + ast.content = [tcall]; + return ast; + } + if (ast && ast.type === 11 /* TComponent */) { + return { + ...ast, + slots: { + default: { + content: tcall, + scope: null, + on: null, + attrs: null, + attrsTranslationCtx: null, + }, + }, + }; + } + } + const body = parseChildren(node, ctx); + return { + type: 7 /* TCall */, + name: subTemplate, + body: body.length ? body : null, + context, + }; + } + // ----------------------------------------------------------------------------- + // t-call-block + // ----------------------------------------------------------------------------- + function parseTCallBlock(node, ctx) { + if (!node.hasAttribute("t-call-block")) { + return null; + } + const name = node.getAttribute("t-call-block"); + return { + type: 15 /* TCallBlock */, + name, + }; + } + // ----------------------------------------------------------------------------- + // t-if + // ----------------------------------------------------------------------------- + function parseTIf(node, ctx) { + if (!node.hasAttribute("t-if")) { + return null; + } + const condition = node.getAttribute("t-if"); + node.removeAttribute("t-if"); + const content = parseNode(node, ctx) || { type: 0 /* Text */, value: "" }; + let nextElement = node.nextElementSibling; + // t-elifs + const tElifs = []; + while (nextElement && nextElement.hasAttribute("t-elif")) { + const condition = nextElement.getAttribute("t-elif"); + nextElement.removeAttribute("t-elif"); + const tElif = parseNode(nextElement, ctx); + const next = nextElement.nextElementSibling; + nextElement.remove(); + nextElement = next; + if (tElif) { + tElifs.push({ condition, content: tElif }); + } + } + // t-else + let tElse = null; + if (nextElement && nextElement.hasAttribute("t-else")) { + nextElement.removeAttribute("t-else"); + tElse = parseNode(nextElement, ctx); + nextElement.remove(); + } + return { + type: 5 /* TIf */, + condition, + content, + tElif: tElifs.length ? tElifs : null, + tElse, + }; + } + // ----------------------------------------------------------------------------- + // t-set directive + // ----------------------------------------------------------------------------- + function parseTSetNode(node, ctx) { + if (!node.hasAttribute("t-set")) { + return null; + } + const name = node.getAttribute("t-set"); + const value = node.getAttribute("t-value") || null; + const defaultValue = node.innerHTML === node.textContent ? node.textContent || null : null; + let body = null; + if (node.textContent !== node.innerHTML) { + body = parseChildren(node, ctx); + } + return { type: 6 /* TSet */, name, value, defaultValue, body }; + } + // ----------------------------------------------------------------------------- + // Components + // ----------------------------------------------------------------------------- + // Error messages when trying to use an unsupported directive on a component + const directiveErrorMap = new Map([ + [ + "t-ref", + "t-ref is no longer supported on components. Consider exposing only the public part of the component's API through a callback prop.", + ], + ["t-att", "t-att makes no sense on component: props are already treated as expressions"], + [ + "t-attf", + "t-attf is not supported on components: use template strings for string interpolation in props", + ], + ]); + function parseComponent(node, ctx) { + let name = node.tagName; + const firstLetter = name[0]; + let isDynamic = node.hasAttribute("t-component"); + if (isDynamic && name !== "t") { + throw new OwlError(`Directive 't-component' can only be used on <t> nodes (used on a <${name}>)`); + } + if (!(firstLetter === firstLetter.toUpperCase() || isDynamic)) { + return null; + } + if (isDynamic) { + name = node.getAttribute("t-component"); + node.removeAttribute("t-component"); + } + const dynamicProps = node.getAttribute("t-props"); + node.removeAttribute("t-props"); + const defaultSlotScope = node.getAttribute("t-slot-scope"); + node.removeAttribute("t-slot-scope"); + let on = null; + let props = null; + let propsTranslationCtx = null; + for (let name of node.getAttributeNames()) { + const value = node.getAttribute(name); + if (name.startsWith("t-translation-context-")) { + const attrName = name.slice(22); + propsTranslationCtx = propsTranslationCtx || {}; + propsTranslationCtx[attrName] = value; + } + else if (name.startsWith("t-")) { + if (name.startsWith("t-on-")) { + on = on || {}; + on[name.slice(5)] = value; + } + else { + const message = directiveErrorMap.get(name.split("-").slice(0, 2).join("-")); + throw new OwlError(message || `unsupported directive on Component: ${name}`); + } + } + else { + props = props || {}; + props[name] = value; + } + } + let slots = null; + if (node.hasChildNodes()) { + const clone = node.cloneNode(true); + // named slots + const slotNodes = Array.from(clone.querySelectorAll("[t-set-slot]")); + for (let slotNode of slotNodes) { + if (slotNode.tagName !== "t") { + throw new OwlError(`Directive 't-set-slot' can only be used on <t> nodes (used on a <${slotNode.tagName}>)`); + } + const name = slotNode.getAttribute("t-set-slot"); + // check if this is defined in a sub component (in which case it should + // be ignored) + let el = slotNode.parentElement; + let isInSubComponent = false; + while (el && el !== clone) { + if (el.hasAttribute("t-component") || el.tagName[0] === el.tagName[0].toUpperCase()) { + isInSubComponent = true; + break; + } + el = el.parentElement; + } + if (isInSubComponent || !el) { + continue; + } + slotNode.removeAttribute("t-set-slot"); + slotNode.remove(); + const slotAst = parseNode(slotNode, ctx); + let on = null; + let attrs = null; + let attrsTranslationCtx = null; + let scope = null; + for (let attributeName of slotNode.getAttributeNames()) { + const value = slotNode.getAttribute(attributeName); + if (attributeName === "t-slot-scope") { + scope = value; + continue; + } + else if (attributeName.startsWith("t-translation-context-")) { + const attrName = attributeName.slice(22); + attrsTranslationCtx = attrsTranslationCtx || {}; + attrsTranslationCtx[attrName] = value; + } + else if (attributeName.startsWith("t-on-")) { + on = on || {}; + on[attributeName.slice(5)] = value; + } + else { + attrs = attrs || {}; + attrs[attributeName] = value; + } + } + slots = slots || {}; + slots[name] = { content: slotAst, on, attrs, attrsTranslationCtx, scope }; + } + // default slot + const defaultContent = parseChildNodes(clone, ctx); + slots = slots || {}; + // t-set-slot="default" has priority over content + if (defaultContent && !slots.default) { + slots.default = { + content: defaultContent, + on, + attrs: null, + attrsTranslationCtx: null, + scope: defaultSlotScope, + }; + } + } + return { + type: 11 /* TComponent */, + name, + isDynamic, + dynamicProps, + props, + propsTranslationCtx, + slots, + on, + }; + } + // ----------------------------------------------------------------------------- + // Slots + // ----------------------------------------------------------------------------- + function parseTSlot(node, ctx) { + if (!node.hasAttribute("t-slot")) { + return null; + } + const name = node.getAttribute("t-slot"); + node.removeAttribute("t-slot"); + let attrs = null; + let attrsTranslationCtx = null; + let on = null; + for (let attributeName of node.getAttributeNames()) { + const value = node.getAttribute(attributeName); + if (attributeName.startsWith("t-on-")) { + on = on || {}; + on[attributeName.slice(5)] = value; + } + else if (attributeName.startsWith("t-translation-context-")) { + const attrName = attributeName.slice(22); + attrsTranslationCtx = attrsTranslationCtx || {}; + attrsTranslationCtx[attrName] = value; + } + else { + attrs = attrs || {}; + attrs[attributeName] = value; + } + } + return { + type: 14 /* TSlot */, + name, + attrs, + attrsTranslationCtx, + on, + defaultContent: parseChildNodes(node, ctx), + }; + } + // ----------------------------------------------------------------------------- + // Translation + // ----------------------------------------------------------------------------- + function parseTTranslation(node, ctx) { + if (node.getAttribute("t-translation") !== "off") { + return null; + } + node.removeAttribute("t-translation"); + return { + type: 16 /* TTranslation */, + content: parseNode(node, ctx), + }; + } + // ----------------------------------------------------------------------------- + // Translation Context + // ----------------------------------------------------------------------------- + function parseTTranslationContext(node, ctx) { + const translationCtx = node.getAttribute("t-translation-context"); + if (!translationCtx) { + return null; + } + node.removeAttribute("t-translation-context"); + return { + type: 17 /* TTranslationContext */, + content: parseNode(node, ctx), + translationCtx, + }; + } + // ----------------------------------------------------------------------------- + // Portal + // ----------------------------------------------------------------------------- + function parseTPortal(node, ctx) { + if (!node.hasAttribute("t-portal")) { + return null; + } + const target = node.getAttribute("t-portal"); + node.removeAttribute("t-portal"); + const content = parseNode(node, ctx); + if (!content) { + return { + type: 0 /* Text */, + value: "", + }; + } + return { + type: 18 /* TPortal */, + target, + content, + }; + } + // ----------------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------------- + /** + * Parse all the child nodes of a given node and return a list of ast elements + */ + function parseChildren(node, ctx) { + const children = []; + for (let child of node.childNodes) { + const childAst = parseNode(child, ctx); + if (childAst) { + if (childAst.type === 3 /* Multi */) { + children.push(...childAst.content); + } + else { + children.push(childAst); + } + } + } + return children; + } + /** + * Parse all the child nodes of a given node and return an ast if possible. + * In the case there are multiple children, they are wrapped in a astmulti. + */ + function parseChildNodes(node, ctx) { + const children = parseChildren(node, ctx); + switch (children.length) { + case 0: + return null; + case 1: + return children[0]; + default: + return { type: 3 /* Multi */, content: children }; + } + } + /** + * Normalizes the content of an Element so that t-if/t-elif/t-else directives + * immediately follow one another (by removing empty text nodes or comments). + * Throws an error when a conditional branching statement is malformed. This + * function modifies the Element in place. + * + * @param el the element containing the tree that should be normalized + */ + function normalizeTIf(el) { + let tbranch = el.querySelectorAll("[t-elif], [t-else]"); + for (let i = 0, ilen = tbranch.length; i < ilen; i++) { + let node = tbranch[i]; + let prevElem = node.previousElementSibling; + let pattr = (name) => prevElem.getAttribute(name); + let nattr = (name) => +!!node.getAttribute(name); + if (prevElem && (pattr("t-if") || pattr("t-elif"))) { + if (pattr("t-foreach")) { + throw new OwlError("t-if cannot stay at the same level as t-foreach when using t-elif or t-else"); + } + if (["t-if", "t-elif", "t-else"].map(nattr).reduce(function (a, b) { + return a + b; + }) > 1) { + throw new OwlError("Only one conditional branching directive is allowed per node"); + } + // All text (with only spaces) and comment nodes (nodeType 8) between + // branch nodes are removed + let textNode; + while ((textNode = node.previousSibling) !== prevElem) { + if (textNode.nodeValue.trim().length && textNode.nodeType !== 8) { + throw new OwlError("text is not allowed between branching directives"); + } + textNode.remove(); + } + } + else { + throw new OwlError("t-elif and t-else directives must be preceded by a t-if or t-elif directive"); + } + } + } + /** + * Normalizes the content of an Element so that t-esc directives on components + * are removed and instead places a <t t-esc=""> as the default slot of the + * component. Also throws if the component already has content. This function + * modifies the Element in place. + * + * @param el the element containing the tree that should be normalized + */ + function normalizeTEscTOut(el) { + for (const d of ["t-esc", "t-out"]) { + const elements = [...el.querySelectorAll(`[${d}]`)].filter((el) => el.tagName[0] === el.tagName[0].toUpperCase() || el.hasAttribute("t-component")); + for (const el of elements) { + if (el.childNodes.length) { + throw new OwlError(`Cannot have ${d} on a component that already has content`); + } + const value = el.getAttribute(d); + el.removeAttribute(d); + const t = el.ownerDocument.createElement("t"); + if (value != null) { + t.setAttribute(d, value); + } + el.appendChild(t); + } + } + } + /** + * Normalizes the tree inside a given element and do some preliminary validation + * on it. This function modifies the Element in place. + * + * @param el the element containing the tree that should be normalized + */ + function normalizeXML(el) { + normalizeTIf(el); + normalizeTEscTOut(el); + } + + function compile(template, options = { + hasGlobalValues: false, + }) { + // parsing + const ast = parse(template, options.customDirectives); + // some work + const hasSafeContext = template instanceof Node + ? !(template instanceof Element) || template.querySelector("[t-set], [t-call]") === null + : !template.includes("t-set") && !template.includes("t-call"); + // code generation + const codeGenerator = new CodeGenerator(ast, { ...options, hasSafeContext }); + const code = codeGenerator.generateCode(); + // template function + try { + return new Function("app, bdom, helpers", code); + } + catch (originalError) { + const { name } = options; + const nameStr = name ? `template "${name}"` : "anonymous template"; + const err = new OwlError(`Failed to compile ${nameStr}: ${originalError.message}\n\ngenerated code:\nfunction(app, bdom, helpers) {\n${code}\n}`); + err.cause = originalError; + throw err; + } + } + + // do not modify manually. This file is generated by the release script. + const version = "2.6.1"; + + // ----------------------------------------------------------------------------- + // Scheduler + // ----------------------------------------------------------------------------- + class Scheduler { + constructor() { + this.tasks = new Set(); + this.frame = 0; + this.delayedRenders = []; + this.cancelledNodes = new Set(); + this.processing = false; + this.requestAnimationFrame = Scheduler.requestAnimationFrame; + } + addFiber(fiber) { + this.tasks.add(fiber.root); + } + scheduleDestroy(node) { + this.cancelledNodes.add(node); + if (this.frame === 0) { + this.frame = this.requestAnimationFrame(() => this.processTasks()); + } + } + /** + * Process all current tasks. This only applies to the fibers that are ready. + * Other tasks are left unchanged. + */ + flush() { + if (this.delayedRenders.length) { + let renders = this.delayedRenders; + this.delayedRenders = []; + for (let f of renders) { + if (f.root && f.node.status !== 3 /* DESTROYED */ && f.node.fiber === f) { + f.render(); + } + } + } + if (this.frame === 0) { + this.frame = this.requestAnimationFrame(() => this.processTasks()); + } + } + processTasks() { + if (this.processing) { + return; + } + this.processing = true; + this.frame = 0; + for (let node of this.cancelledNodes) { + node._destroy(); + } + this.cancelledNodes.clear(); + for (let task of this.tasks) { + this.processFiber(task); + } + for (let task of this.tasks) { + if (task.node.status === 3 /* DESTROYED */) { + this.tasks.delete(task); + } + } + this.processing = false; + } + processFiber(fiber) { + if (fiber.root !== fiber) { + this.tasks.delete(fiber); + return; + } + const hasError = fibersInError.has(fiber); + if (hasError && fiber.counter !== 0) { + this.tasks.delete(fiber); + return; + } + if (fiber.node.status === 3 /* DESTROYED */) { + this.tasks.delete(fiber); + return; + } + if (fiber.counter === 0) { + if (!hasError) { + fiber.complete(); + } + // at this point, the fiber should have been applied to the DOM, so we can + // remove it from the task list. If it is not the case, it means that there + // was an error and an error handler triggered a new rendering that recycled + // the fiber, so in that case, we actually want to keep the fiber around, + // otherwise it will just be ignored. + if (fiber.appliedToDom) { + this.tasks.delete(fiber); + } + } + } + } + // capture the value of requestAnimationFrame as soon as possible, to avoid + // interactions with other code, such as test frameworks that override them + Scheduler.requestAnimationFrame = window.requestAnimationFrame.bind(window); + + let hasBeenLogged = false; + const apps = new Set(); + window.__OWL_DEVTOOLS__ || (window.__OWL_DEVTOOLS__ = { apps, Fiber, RootFiber, toRaw, reactive }); + class App extends TemplateSet { + constructor(Root, config = {}) { + super(config); + this.scheduler = new Scheduler(); + this.subRoots = new Set(); + this.root = null; + this.name = config.name || ""; + this.Root = Root; + apps.add(this); + if (config.test) { + this.dev = true; + } + this.warnIfNoStaticProps = config.warnIfNoStaticProps || false; + if (this.dev && !config.test && !hasBeenLogged) { + console.info(`Owl is running in 'dev' mode.`); + hasBeenLogged = true; + } + const env = config.env || {}; + const descrs = Object.getOwnPropertyDescriptors(env); + this.env = Object.freeze(Object.create(Object.getPrototypeOf(env), descrs)); + this.props = config.props || {}; + } + mount(target, options) { + const root = this.createRoot(this.Root, { props: this.props }); + this.root = root.node; + this.subRoots.delete(root.node); + return root.mount(target, options); + } + createRoot(Root, config = {}) { + const props = config.props || {}; + // hack to make sure the sub root get the sub env if necessary. for owl 3, + // would be nice to rethink the initialization process to make sure that + // we can create a ComponentNode and give it explicitely the env, instead + // of looking it up in the app + const env = this.env; + if (config.env) { + this.env = config.env; + } + const restore = saveCurrent(); + const node = this.makeNode(Root, props); + restore(); + if (config.env) { + this.env = env; + } + this.subRoots.add(node); + return { + node, + mount: (target, options) => { + App.validateTarget(target); + if (this.dev) { + validateProps(Root, props, { __owl__: { app: this } }); + } + const prom = this.mountNode(node, target, options); + return prom; + }, + destroy: () => { + this.subRoots.delete(node); + node.destroy(); + this.scheduler.processTasks(); + }, + }; + } + makeNode(Component, props) { + return new ComponentNode(Component, props, this, null, null); + } + mountNode(node, target, options) { + const promise = new Promise((resolve, reject) => { + let isResolved = false; + // manually set a onMounted callback. + // that way, we are independant from the current node. + node.mounted.push(() => { + resolve(node.component); + isResolved = true; + }); + // Manually add the last resort error handler on the node + let handlers = nodeErrorHandlers.get(node); + if (!handlers) { + handlers = []; + nodeErrorHandlers.set(node, handlers); + } + handlers.unshift((e) => { + if (!isResolved) { + reject(e); + } + throw e; + }); + }); + node.mountComponent(target, options); + return promise; + } + destroy() { + if (this.root) { + for (let subroot of this.subRoots) { + subroot.destroy(); + } + this.root.destroy(); + this.scheduler.processTasks(); + } + apps.delete(this); + } + createComponent(name, isStatic, hasSlotsProp, hasDynamicPropList, propList) { + const isDynamic = !isStatic; + let arePropsDifferent; + const hasNoProp = propList.length === 0; + if (hasSlotsProp) { + arePropsDifferent = (_1, _2) => true; + } + else if (hasDynamicPropList) { + arePropsDifferent = function (props1, props2) { + for (let k in props1) { + if (props1[k] !== props2[k]) { + return true; + } + } + return Object.keys(props1).length !== Object.keys(props2).length; + }; + } + else if (hasNoProp) { + arePropsDifferent = (_1, _2) => false; + } + else { + arePropsDifferent = function (props1, props2) { + for (let p of propList) { + if (props1[p] !== props2[p]) { + return true; + } + } + return false; + }; + } + const updateAndRender = ComponentNode.prototype.updateAndRender; + const initiateRender = ComponentNode.prototype.initiateRender; + return (props, key, ctx, parent, C) => { + let children = ctx.children; + let node = children[key]; + if (isDynamic && node && node.component.constructor !== C) { + node = undefined; + } + const parentFiber = ctx.fiber; + if (node) { + if (arePropsDifferent(node.props, props) || parentFiber.deep || node.forceNextRender) { + node.forceNextRender = false; + updateAndRender.call(node, props, parentFiber); + } + } + else { + // new component + if (isStatic) { + const components = parent.constructor.components; + if (!components) { + throw new OwlError(`Cannot find the definition of component "${name}", missing static components key in parent`); + } + C = components[name]; + if (!C) { + throw new OwlError(`Cannot find the definition of component "${name}"`); + } + else if (!(C.prototype instanceof Component)) { + throw new OwlError(`"${name}" is not a Component. It must inherit from the Component class`); + } + } + node = new ComponentNode(C, props, this, ctx, key); + children[key] = node; + initiateRender.call(node, new Fiber(node, parentFiber)); + } + parentFiber.childrenMap[key] = node; + return node; + }; + } + handleError(...args) { + return handleError(...args); + } + } + App.validateTarget = validateTarget; + App.apps = apps; + App.version = version; + async function mount(C, target, config = {}) { + return new App(C, config).mount(target, config); + } + + const mainEventHandler = (data, ev, currentTarget) => { + const { data: _data, modifiers } = filterOutModifiersFromData(data); + data = _data; + let stopped = false; + if (modifiers.length) { + let selfMode = false; + const isSelf = ev.target === currentTarget; + for (const mod of modifiers) { + switch (mod) { + case "self": + selfMode = true; + if (isSelf) { + continue; + } + else { + return stopped; + } + case "prevent": + if ((selfMode && isSelf) || !selfMode) + ev.preventDefault(); + continue; + case "stop": + if ((selfMode && isSelf) || !selfMode) + ev.stopPropagation(); + stopped = true; + continue; + } + } + } + // If handler is empty, the array slot 0 will also be empty, and data will not have the property 0 + // We check this rather than data[0] being truthy (or typeof function) so that it crashes + // as expected when there is a handler expression that evaluates to a falsy value + if (Object.hasOwnProperty.call(data, 0)) { + const handler = data[0]; + if (typeof handler !== "function") { + throw new OwlError(`Invalid handler (expected a function, received: '${handler}')`); + } + let node = data[1] ? data[1].__owl__ : null; + if (node ? node.status === 1 /* MOUNTED */ : true) { + handler.call(node ? node.component : null, ev); + } + } + return stopped; + }; + + function status(component) { + switch (component.__owl__.status) { + case 0 /* NEW */: + return "new"; + case 2 /* CANCELLED */: + return "cancelled"; + case 1 /* MOUNTED */: + return "mounted"; + case 3 /* DESTROYED */: + return "destroyed"; + } + } + + // ----------------------------------------------------------------------------- + // useRef + // ----------------------------------------------------------------------------- + /** + * The purpose of this hook is to allow components to get a reference to a sub + * html node or component. + */ + function useRef(name) { + const node = getCurrent(); + const refs = node.refs; + return { + get el() { + const el = refs[name]; + return inOwnerDocument(el) ? el : null; + }, + }; + } + // ----------------------------------------------------------------------------- + // useEnv and useSubEnv + // ----------------------------------------------------------------------------- + /** + * This hook is useful as a building block for some customized hooks, that may + * need a reference to the env of the component calling them. + */ + function useEnv() { + return getCurrent().component.env; + } + function extendEnv(currentEnv, extension) { + const env = Object.create(currentEnv); + const descrs = Object.getOwnPropertyDescriptors(extension); + return Object.freeze(Object.defineProperties(env, descrs)); + } + /** + * This hook is a simple way to let components use a sub environment. Note that + * like for all hooks, it is important that this is only called in the + * constructor method. + */ + function useSubEnv(envExtension) { + const node = getCurrent(); + node.component.env = extendEnv(node.component.env, envExtension); + useChildSubEnv(envExtension); + } + function useChildSubEnv(envExtension) { + const node = getCurrent(); + node.childEnv = extendEnv(node.childEnv, envExtension); + } + /** + * This hook will run a callback when a component is mounted and patched, and + * will run a cleanup function before patching and before unmounting the + * the component. + * + * @template T + * @param {Effect<T>} effect the effect to run on component mount and/or patch + * @param {()=>[...T]} [computeDependencies=()=>[NaN]] a callback to compute + * dependencies that will decide if the effect needs to be cleaned up and + * run again. If the dependencies did not change, the effect will not run + * again. The default value returns an array containing only NaN because + * NaN !== NaN, which will cause the effect to rerun on every patch. + */ + function useEffect(effect, computeDependencies = () => [NaN]) { + let cleanup; + let dependencies; + onMounted(() => { + dependencies = computeDependencies(); + cleanup = effect(...dependencies); + }); + onPatched(() => { + const newDeps = computeDependencies(); + const shouldReapply = newDeps.some((val, i) => val !== dependencies[i]); + if (shouldReapply) { + dependencies = newDeps; + if (cleanup) { + cleanup(); + } + cleanup = effect(...dependencies); + } + }); + onWillUnmount(() => cleanup && cleanup()); + } + // ----------------------------------------------------------------------------- + // useExternalListener + // ----------------------------------------------------------------------------- + /** + * When a component needs to listen to DOM Events on element(s) that are not + * part of his hierarchy, we can use the `useExternalListener` hook. + * It will correctly add and remove the event listener, whenever the + * component is mounted and unmounted. + * + * Example: + * a menu needs to listen to the click on window to be closed automatically + * + * Usage: + * in the constructor of the OWL component that needs to be notified, + * `useExternalListener(window, 'click', this._doSomething);` + * */ + function useExternalListener(target, eventName, handler, eventParams) { + const node = getCurrent(); + const boundHandler = handler.bind(node.component); + onMounted(() => target.addEventListener(eventName, boundHandler, eventParams)); + onWillUnmount(() => target.removeEventListener(eventName, boundHandler, eventParams)); + } + + config.shouldNormalizeDom = false; + config.mainEventHandler = mainEventHandler; + const blockDom = { + config, + // bdom entry points + mount: mount$1, + patch, + remove, + // bdom block types + list, + multi, + text, + toggler, + createBlock, + html, + comment, + }; + const __info__ = { + version: App.version, + }; + + TemplateSet.prototype._compileTemplate = function _compileTemplate(name, template) { + return compile(template, { + name, + dev: this.dev, + translateFn: this.translateFn, + translatableAttributes: this.translatableAttributes, + customDirectives: this.customDirectives, + hasGlobalValues: this.hasGlobalValues, + }); + }; + + exports.App = App; + exports.Component = Component; + exports.EventBus = EventBus; + exports.OwlError = OwlError; + exports.__info__ = __info__; + exports.batched = batched; + exports.blockDom = blockDom; + exports.loadFile = loadFile; + exports.markRaw = markRaw; + exports.markup = markup; + exports.mount = mount; + exports.onError = onError; + exports.onMounted = onMounted; + exports.onPatched = onPatched; + exports.onRendered = onRendered; + exports.onWillDestroy = onWillDestroy; + exports.onWillPatch = onWillPatch; + exports.onWillRender = onWillRender; + exports.onWillStart = onWillStart; + exports.onWillUnmount = onWillUnmount; + exports.onWillUpdateProps = onWillUpdateProps; + exports.reactive = reactive; + exports.status = status; + exports.toRaw = toRaw; + exports.useChildSubEnv = useChildSubEnv; + exports.useComponent = useComponent; + exports.useEffect = useEffect; + exports.useEnv = useEnv; + exports.useExternalListener = useExternalListener; + exports.useRef = useRef; + exports.useState = useState; + exports.useSubEnv = useSubEnv; + exports.validate = validate; + exports.validateType = validateType; + exports.whenReady = whenReady; + exports.xml = xml; + + Object.defineProperty(exports, '__esModule', { value: true }); + + + __info__.date = '2025-03-05T08:37:58.580Z'; + __info__.hash = '2b5cea9'; + __info__.url = 'https://github.com/odoo/owl'; + + +})(this.owl = this.owl || {}); diff --git a/runbot/static/src/owl_module.js b/runbot/static/src/owl_module.js new file mode 100644 index 000000000..128889e44 --- /dev/null +++ b/runbot/static/src/owl_module.js @@ -0,0 +1,5 @@ +odoo.define('@runbot/owl', [], function () { + 'use strict'; + + return owl; +}); From 0db0bfcf5bea502e5d669ab5c7c332d90de59678 Mon Sep 17 00:00:00 2001 From: William Braeckman <wbr@odoo.com> Date: Mon, 10 Mar 2025 15:56:44 +0100 Subject: [PATCH 4/7] [IMP] runbot: add basic owl structure Adds the minimal requirements for owl and xml file based templating for the frontend. --- runbot/__manifest__.py | 10 + runbot/static/src/frontend/root.js | 64 ++++ runbot/static/src/vendored/registry.js | 212 ++++++++++++ runbot/static/src/vendored/registry_hook.js | 28 ++ .../src/vendored/template_inheritance.js | 320 ++++++++++++++++++ runbot/static/src/vendored/templates.js | 176 ++++++++++ runbot/templates/utils.xml | 2 + 7 files changed, 812 insertions(+) create mode 100644 runbot/static/src/frontend/root.js create mode 100644 runbot/static/src/vendored/registry.js create mode 100644 runbot/static/src/vendored/registry_hook.js create mode 100644 runbot/static/src/vendored/template_inheritance.js create mode 100644 runbot/static/src/vendored/templates.js diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 584a4170f..465231bcb 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -67,6 +67,16 @@ 'runbot/static/src/diff_match_patch_module.js', 'runbot/static/src/fields/*', ], + 'runbot.assets_frontend': [ + ('include', 'web.assets_frontend_minimal'), # Pray the gods this stays named correctly + + 'runbot/static/libs/owl.js', + 'runbot/static/src/owl_module.js', + + 'runbot/static/src/vendored/**/*', # Vendored files coming from odoo modules + + 'runbot/static/src/frontend/root.js', + ] }, 'post_load': 'runbot_post_load', } diff --git a/runbot/static/src/frontend/root.js b/runbot/static/src/frontend/root.js new file mode 100644 index 000000000..f4c24be13 --- /dev/null +++ b/runbot/static/src/frontend/root.js @@ -0,0 +1,64 @@ +import { whenReady, Component, xml, App, onError } from '@runbot/owl'; + +import { getTemplate } from '@web/core/templates'; +import { registry } from '@web/core/registry'; +import { useRegistry } from '@web/core/registry_hook'; + + +const mainComponents = registry.category('main.components'); + +class ErrorHandler extends Component { + static template = xml`<t t-slot="default" />`; + static props = ["onError", "slots"]; + setup() { + onError((error) => { + this.props.onError(error); + }); + } +} + +class ComponentContainer extends Component { + static components = { ErrorHandler }; + static props = {}; + static template = xml` + <div class="o-main-components-container"> + <t t-foreach="Components.entries" t-as="C" t-key="C[0]"> + <ErrorHandler onError="error => this.handleComponentError(error, C)"> + <t t-component="C[1].Component" t-props="C[1].props"/> + </ErrorHandler> + </t> + </div> + `; + + setup() { + this.Components = useRegistry(mainComponents); + } + + handleComponentError(error, C) { + console.error('Error while rendering', C[0], 'removing from app.'); + // remove the faulty component and rerender without it + this.Components.entries.splice(this.Components.entries.indexOf(C), 1); + this.render(); + /** + * we rethrow the error to notify the user something bad happened. + * We do it after a tick to make sure owl can properly finish its + * rendering + */ + Promise.resolve().then(() => { + throw error; + }); + } +} + +/** + * Bootstrap the frontend. + */ +(async function startApp() { + await whenReady(); + + const app = new App(ComponentContainer, { + getTemplate, + env: {}, + }); + await app.mount(document.body); +})(); diff --git a/runbot/static/src/vendored/registry.js b/runbot/static/src/vendored/registry.js new file mode 100644 index 000000000..9eef3aba8 --- /dev/null +++ b/runbot/static/src/vendored/registry.js @@ -0,0 +1,212 @@ +/** @odoo-module alias=@web/core/registry default=false **/ + + +import { EventBus, validate } from "@runbot/owl"; + +// ----------------------------------------------------------------------------- +// Errors +// ----------------------------------------------------------------------------- +export class KeyNotFoundError extends Error {} + +export class DuplicatedKeyError extends Error {} + +// ----------------------------------------------------------------------------- +// Validation +// ----------------------------------------------------------------------------- + +const validateSchema = (name, key, value, schema) => { + if (!odoo.debug) { + return; + } + try { + validate(value, schema); + } catch (error) { + throw new Error(`Validation error for key "${key}" in registry "${name}": ${error}`); + } +}; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +/** + * @template S + * @template C + * @typedef {import("registries").RegistryData<S, C>} RegistryData + */ + +/** + * @template T + * @typedef {T extends RegistryData<any, any> ? T : RegistryData<T, {}>} ToRegistryData + */ + +/** + * @template T + * @typedef {ToRegistryData<T>["__itemShape"]} GetRegistryItemShape + */ + +/** + * @template T + * @typedef {ToRegistryData<T>["__categories"]} GetRegistryCategories + */ + +/** + * Registry + * + * The Registry class is basically just a mapping from a string key to an object. + * It is really not much more than an object. It is however useful for the + * following reasons: + * + * 1. it let us react and execute code when someone add something to the registry + * (for example, the FunctionRegistry subclass this for this purpose) + * 2. it throws an error when the get operation fails + * 3. it provides a chained API to add items to the registry. + * + * @template T + */ +export class Registry extends EventBus { + /** + * @param {string} [name] + */ + constructor(name) { + super(); + /** @type {Record<string, [number, GetRegistryItemShape<T>]>}*/ + this.content = {}; + /** @type {{ [P in keyof GetRegistryCategories<T>]?: Registry<GetRegistryCategories<T>[P]> }} */ + this.subRegistries = {}; + /** @type {GetRegistryItemShape<T>[]}*/ + this.elements = null; + /** @type {[string, GetRegistryItemShape<T>][]}*/ + this.entries = null; + this.name = name; + this.validationSchema = null; + + this.addEventListener("UPDATE", () => { + this.elements = null; + this.entries = null; + }); + } + + /** + * Add an entry (key, value) to the registry if key is not already used. If + * the parameter force is set to true, an entry with same key (if any) is replaced. + * + * Note that this also returns the registry, so another add method call can + * be chained + * + * @param {string} key + * @param {GetRegistryItemShape<T>} value + * @param {{force?: boolean, sequence?: number}} [options] + * @returns {Registry<T>} + */ + add(key, value, { force, sequence } = {}) { + if (this.validationSchema) { + validateSchema(this.name, key, value, this.validationSchema); + } + if (!force && key in this.content) { + throw new DuplicatedKeyError( + `Cannot add key "${key}" in the "${this.name}" registry: it already exists` + ); + } + let previousSequence; + if (force) { + const elem = this.content[key]; + previousSequence = elem && elem[0]; + } + sequence = sequence === undefined ? previousSequence || 50 : sequence; + this.content[key] = [sequence, value]; + const payload = { operation: "add", key, value }; + this.trigger("UPDATE", payload); + return this; + } + + /** + * Get an item from the registry + * + * @param {string} key + * @returns {GetRegistryItemShape<T>} + */ + get(key, defaultValue) { + if (arguments.length < 2 && !(key in this.content)) { + throw new KeyNotFoundError(`Cannot find key "${key}" in the "${this.name}" registry`); + } + const info = this.content[key]; + return info ? info[1] : defaultValue; + } + + /** + * Check the presence of a key in the registry + * + * @param {string} key + * @returns {boolean} + */ + contains(key) { + return key in this.content; + } + + /** + * Get a list of all elements in the registry. Note that it is ordered + * according to the sequence numbers. + * + * @returns {GetRegistryItemShape<T>[]} + */ + getAll() { + if (!this.elements) { + const content = Object.values(this.content).sort((el1, el2) => el1[0] - el2[0]); + this.elements = content.map((elem) => elem[1]); + } + return this.elements.slice(); + } + + /** + * Return a list of all entries, ordered by sequence numbers. + * + * @returns {[string, GetRegistryItemShape<T>][]} + */ + getEntries() { + if (!this.entries) { + const entries = Object.entries(this.content).sort((el1, el2) => el1[1][0] - el2[1][0]); + this.entries = entries.map(([str, elem]) => [str, elem[1]]); + } + return this.entries.slice(); + } + + /** + * Remove an item from the registry + * + * @param {string} key + */ + remove(key) { + const value = this.content[key]; + delete this.content[key]; + const payload = { operation: "delete", key, value }; + this.trigger("UPDATE", payload); + } + + /** + * Open a sub registry (and create it if necessary) + * + * @template {keyof GetRegistryCategories<T> & string} K + * @param {K} subcategory + * @returns {Registry<GetRegistryCategories<T>[K]>} + */ + category(subcategory) { + if (!(subcategory in this.subRegistries)) { + this.subRegistries[subcategory] = new Registry(subcategory); + } + return this.subRegistries[subcategory]; + } + + addValidation(schema) { + if (this.validationSchema) { + throw new Error("Validation schema already set on this registry"); + } + this.validationSchema = schema; + for (const [key, value] of this.getEntries()) { + validateSchema(this.name, key, value, schema); + } + } +} + +/** @type {Registry<import("registries").GlobalRegistry>} */ +export const registry = new Registry(); diff --git a/runbot/static/src/vendored/registry_hook.js b/runbot/static/src/vendored/registry_hook.js new file mode 100644 index 000000000..43b83b4f9 --- /dev/null +++ b/runbot/static/src/vendored/registry_hook.js @@ -0,0 +1,28 @@ +/** @odoo-module alias=@web/core/registry_hook default=false **/ + + +import { useState, onWillStart, onWillDestroy } from "@runbot/owl"; + +export function useRegistry(registry) { + const state = useState({ entries: registry.getEntries() }); + const listener = ({ detail }) => { + const index = state.entries.findIndex(([k]) => k === detail.key); + if (detail.operation === "add" && index === -1) { + // push the new entry at the right place + const newEntries = registry.getEntries(); + const newEntry = newEntries.find(([k]) => k === detail.key); + const newIndex = newEntries.indexOf(newEntry); + if (newIndex === newEntries.length - 1) { + state.entries.push(newEntry); + } else { + state.entries.splice(newIndex, 0, newEntry); + } + } else if (detail.operation === "delete" && index >= 0) { + state.entries.splice(index, 1); + } + }; + + onWillStart(() => registry.addEventListener("UPDATE", listener)); + onWillDestroy(() => registry.removeEventListener("UPDATE", listener)); + return state; +} diff --git a/runbot/static/src/vendored/template_inheritance.js b/runbot/static/src/vendored/template_inheritance.js new file mode 100644 index 000000000..de498b2ce --- /dev/null +++ b/runbot/static/src/vendored/template_inheritance.js @@ -0,0 +1,320 @@ +/** @odoo-module alias=@web/core/template_inheritance default=false **/ + +const RSTRIP_REGEXP = /(?=\n[ \t]*$)/; +/** + * The child nodes of operation represent new content to create before target or + * or other elements to move before target from the target tree (tree from which target is part of). + * Some processing of text nodes has to be done in order to normalize the situation. + * Note: we assume that target has a parent element. + * @param {Element} target + * @param {Element} operation + */ +function addBefore(target, operation) { + const nodes = getNodes(target, operation); + if (nodes.length === 0) { + return; + } + const { previousSibling } = target; + target.before(...nodes); + if (previousSibling?.nodeType === Node.TEXT_NODE) { + const [text1, text2] = previousSibling.data.split(RSTRIP_REGEXP); + previousSibling.data = text1.trimEnd(); + if (nodes[0].nodeType === Node.TEXT_NODE) { + mergeTextNodes(previousSibling, nodes[0]); + } + if (text2 && nodes.some((n) => n.nodeType !== Node.TEXT_NODE)) { + const textNode = document.createTextNode(text2); + target.before(textNode); + if (textNode.previousSibling.nodeType === Node.TEXT_NODE) { + mergeTextNodes(textNode.previousSibling, textNode); + } + } + } +} + +/** + * element is part of a tree. Here we return the root element of that tree. + * Note: this root element is not necessarily the documentElement of the ownerDocument + * of element (hence the following code). + * @param {Element} element + * @returns {Element} + */ +function getRoot(element) { + while (element.parentElement) { + element = element.parentElement; + } + return element; +} + +const HASCLASS_REGEXP = /hasclass\(([^)]*)\)/g; +const CLASS_CONTAINS_REGEX = /contains\(@class.*\)/g; +/** + * @param {Element} operation + * @returns {string} + */ +function getXpath(operation) { + const xpath = operation.getAttribute("expr"); + if (odoo.debug) { + if (CLASS_CONTAINS_REGEX.test(xpath)) { + const parent = operation.closest("t[t-inherit]"); + const templateName = parent.getAttribute("t-name") || parent.getAttribute("t-inherit"); + console.warn( + `Error-prone use of @class in template "${templateName}" (or one of its inheritors).` + + " Use the hasclass(*classes) function to filter elements by their classes" + ); + } + } + // hasclass does not exist in XPath 1.0 but is a custom function defined server side (see _hasclass) usable in lxml. + // Here we have to replace it by a complex condition (which is not nice). + // Note: we assume that classes do not contain the 2 chars , and ) + return xpath.replaceAll(HASCLASS_REGEXP, (_, capturedGroup) => { + return capturedGroup + .split(",") + .map((c) => `contains(concat(' ', @class, ' '), ' ${c.trim().slice(1, -1)} ')`) + .join(" and "); + }); +} + +/** + * @param {Element} element + * @param {Element} operation + * @returns {Node|null} + */ +function getNode(element, operation) { + const root = getRoot(element); + const doc = new Document(); + doc.appendChild(root); // => root is the documentElement of its ownerDocument (we do that in case root is a clone) + if (operation.tagName === "xpath") { + const xpath = getXpath(operation); + const result = doc.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE); + return result.singleNodeValue; + } + for (const elem of root.querySelectorAll(operation.tagName)) { + if ( + [...operation.attributes].every( + ({ name, value }) => name === "position" || elem.getAttribute(name) === value + ) + ) { + return elem; + } + } + return null; +} + +/** + * @param {Element} element + * @param {Element} operation + * @returns {Element} + */ +function getElement(element, operation) { + const node = getNode(element, operation); + if (!node) { + throw new Error(`Element '${operation.outerHTML}' cannot be located in element tree`); + } + if (!(node instanceof Element)) { + throw new Error(`Found node ${node} instead of an element`); + } + return node; +} + +/** + * @param {Element} element + * @param {Element} operation + * @returns {Node[]} + */ +function getNodes(element, operation) { + const nodes = []; + for (const childNode of operation.childNodes) { + if (childNode.tagName === "xpath" && childNode.getAttribute?.("position") === "move") { + const node = getElement(element, childNode); + removeNode(node); + nodes.push(node); + } else { + nodes.push(childNode); + } + } + return nodes; +} + +/** + * @param {Text} first + * @param {Text} second + * @param {boolean} [trimEnd=true] + */ +function mergeTextNodes(first, second, trimEnd = true) { + first.data = (trimEnd ? first.data.trimEnd() : first.data) + second.data; + second.remove(); +} + +function splitAndTrim(str, separator) { + return str.split(separator).map((s) => s.trim()); +} + +/** + * @param {Element} target + * @param {Element} operation + */ +function modifyAttributes(target, operation) { + for (const child of operation.children) { + if (child.tagName !== "attribute") { + continue; + } + const attributeName = child.getAttribute("name"); + const firstNode = child.childNodes[0]; + let value = firstNode?.nodeType === Node.TEXT_NODE ? firstNode.data : ""; + + const add = child.getAttribute("add") || ""; + const remove = child.getAttribute("remove") || ""; + if (add || remove) { + if (firstNode?.nodeType === Node.TEXT_NODE) { + throw new Error(`Useless element content ${firstNode.outerHTML}`); + } + const separator = child.getAttribute("separator") || ","; + const toRemove = new Set(splitAndTrim(remove, separator)); + const values = splitAndTrim(target.getAttribute(attributeName) || "", separator).filter( + (s) => !toRemove.has(s) + ); + values.push(...splitAndTrim(add, separator).filter((s) => s)); + value = values.join(separator); + } + + if (value) { + target.setAttribute(attributeName, value); + } else { + target.removeAttribute(attributeName); + } + } +} + +/** + * Remove node and normalize surrounind text nodes (if any) + * Note: we assume that node has a parent element + * @param {Node} node + */ +function removeNode(node) { + const { nextSibling, previousSibling } = node; + node.remove(); + if (nextSibling?.nodeType === Node.TEXT_NODE && previousSibling?.nodeType === Node.TEXT_NODE) { + mergeTextNodes( + previousSibling, + nextSibling, + previousSibling.parentElement.firstChild === previousSibling + ); + } +} + +/** + * @param {Element} root + * @param {Element} target + * @param {Element} operation + */ +function replace(root, target, operation) { + const mode = operation.getAttribute("mode") || "outer"; + switch (mode) { + case "outer": { + const result = operation.ownerDocument.evaluate( + ".//*[text()='$0']", + operation, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE + ); + for (let i = 0; i < result.snapshotLength; i++) { + const loc = result.snapshotItem(i); + loc.firstChild.replaceWith(target.cloneNode(true)); + } + if (target.parentElement) { + const nodes = getNodes(target, operation); + target.replaceWith(...nodes); + } else { + let operationContent = null; + let comment = null; + for (const child of operation.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE) { + operationContent = child; + break; + } + if (child.nodeType === Node.COMMENT_NODE) { + comment = child; + } + } + root = operationContent.cloneNode(true); + if (target.hasAttribute("t-name")) { + root.setAttribute("t-name", target.getAttribute("t-name")); + } + if (comment) { + root.prepend(comment); + } + } + break; + } + case "inner": + while (target.firstChild) { + target.removeChild(target.lastChild); + } + target.append(...operation.childNodes); + break; + default: + throw new Error(`Invalid mode attribute: '${mode}'`); + } + return root; +} + +/** + * @param {Element} root + * @param {Element} operations is a single element whose children represent operations to perform on root + * @param {string} [url=""] + * @returns {Element} root modified (in place) by the operations + */ +export function applyInheritance(root, operations, url = "") { + for (const operation of operations.children) { + const target = getElement(root, operation); + const position = operation.getAttribute("position") || "inside"; + + if (odoo.debug && url) { + const attributes = [...operation.attributes].map( + ({ name, value }) => + `${name}=${JSON.stringify(name === "position" ? position : value)}` + ); + const comment = document.createComment( + ` From file: ${url} ; ${attributes.join(" ; ")} ` + ); + if (position === "attributes") { + target.before(comment); // comment won't be visible if target is root + } else { + operation.prepend(comment); + } + } + + switch (position) { + case "replace": { + root = replace(root, target, operation); // root can be replaced (see outer mode) + break; + } + case "attributes": { + modifyAttributes(target, operation); + break; + } + case "inside": { + const sentinel = document.createElement("sentinel"); + target.append(sentinel); + addBefore(sentinel, operation); + removeNode(sentinel); + break; + } + case "after": { + const sentinel = document.createElement("sentinel"); + target.after(sentinel); + addBefore(sentinel, operation); + removeNode(sentinel); + break; + } + case "before": { + addBefore(target, operation); + break; + } + default: + throw new Error(`Invalid position attribute: '${position}'`); + } + } + return root; +} diff --git a/runbot/static/src/vendored/templates.js b/runbot/static/src/vendored/templates.js new file mode 100644 index 000000000..ccef0169a --- /dev/null +++ b/runbot/static/src/vendored/templates.js @@ -0,0 +1,176 @@ +/** @odoo-module alias=@web/core/templates default=false */ + +import { applyInheritance } from "@web/core/template_inheritance"; + +const parser = new DOMParser(); +/** @type {((document: Document) => void)[]} */ +const templateProcessors = []; +/** @type {((url: string) => boolean)[]} */ +let urlFilters = []; +function getParsedTemplate(templateString) { + const doc = parser.parseFromString(templateString, "text/xml"); + for (const processor of templateProcessors) { + processor(doc); + } + return doc.firstChild; +} + +function getClone(template) { + const c = template.cloneNode(true); + new Document().append(c); // => c is the documentElement of its ownerDocument + return c; +} + +const registered = new Set(); +function isRegistered(...args) { + const key = JSON.stringify([...args]); + if (registered.has(key)) { + return true; + } + registered.add(key); + return false; +} + +let blockType = null; +let blockId = 0; + +const templates = {}; +const parsedTemplates = {}; +const info = {}; +export function registerTemplate(name, url, templateString) { + if (isRegistered(...arguments)) { + return; + } + if (blockType !== "templates") { + blockType = "templates"; + blockId++; + } + if (name in templates && (info[name].url !== url || templates[name] !== templateString)) { + throw new Error(`Template ${name} already exists`); + } + templates[name] = templateString; + info[name] = { blockId, url }; +} + +const templateExtensions = {}; +const parsedTemplateExtensions = {}; +export function registerTemplateExtension(inheritFrom, url, templateString) { + if (isRegistered(...arguments)) { + return; + } + if (blockType !== "extensions") { + blockType = "extensions"; + blockId++; + } + if (!templateExtensions[inheritFrom]) { + templateExtensions[inheritFrom] = []; + } + if (!templateExtensions[inheritFrom][blockId]) { + templateExtensions[inheritFrom][blockId] = []; + } + templateExtensions[inheritFrom][blockId].push({ + templateString, + url, + }); +} + +/** + * @param {(document: Document) => void} processor + */ +export function registerTemplateProcessor(processor) { + templateProcessors.push(processor); +} + +/** + * @param {typeof urlFilters} filters + */ +export function setUrlFilters(filters) { + urlFilters = filters; +} + +function _getTemplate(name, blockId = null) { + if (!(name in parsedTemplates)) { + if (!(name in templates)) { + return null; + } + const templateString = templates[name]; + parsedTemplates[name] = getParsedTemplate(templateString); + } + let processedTemplate = parsedTemplates[name]; + + const inheritFrom = processedTemplate.getAttribute("t-inherit"); + if (inheritFrom) { + const parentTemplate = _getTemplate(inheritFrom, blockId || info[name].blockId); + if (!parentTemplate) { + throw new Error( + `Constructing template ${name}: template parent ${inheritFrom} not found` + ); + } + const element = getClone(processedTemplate); + processedTemplate = applyInheritance(getClone(parentTemplate), element, info[name].url); + if (processedTemplate.tagName !== element.tagName) { + const temp = processedTemplate; + processedTemplate = new Document().createElement(element.tagName); + processedTemplate.append(...temp.childNodes); + } + for (const { name, value } of element.attributes) { + if (!["t-inherit", "t-inherit-mode"].includes(name)) { + processedTemplate.setAttribute(name, value); + } + } + } + + for (const otherBlockId in templateExtensions[name] || {}) { + if (blockId && otherBlockId > blockId) { + break; + } + if (!(name in parsedTemplateExtensions)) { + parsedTemplateExtensions[name] = {}; + } + if (!(otherBlockId in parsedTemplateExtensions[name])) { + parsedTemplateExtensions[name][otherBlockId] = []; + for (const { templateString, url } of templateExtensions[name][otherBlockId]) { + parsedTemplateExtensions[name][otherBlockId].push({ + template: getParsedTemplate(templateString), + url, + }); + } + } + for (const { template, url } of parsedTemplateExtensions[name][otherBlockId]) { + if (!urlFilters.every((filter) => filter(url))) { + continue; + } + processedTemplate = applyInheritance( + inheritFrom ? processedTemplate : getClone(processedTemplate), + getClone(template), + url + ); + } + } + + return processedTemplate; +} + +/** @type {Record<string, Element>} */ +let processedTemplates = {}; + +/** + * @param {string} name + */ +export function getTemplate(name) { + if (!processedTemplates[name]) { + processedTemplates[name] = _getTemplate(name); + } + return processedTemplates[name]; +} + +export function clearProcessedTemplates() { + processedTemplates = {}; +} + +export function checkPrimaryTemplateParents(namesToCheck) { + const missing = new Set(namesToCheck.filter((name) => !(name in templates))); + if (missing.size) { + console.error(`Missing (primary) parent templates: ${[...missing].join(", ")}`); + } +} diff --git a/runbot/templates/utils.xml b/runbot/templates/utils.xml index 10089f7e2..db3bddba0 100644 --- a/runbot/templates/utils.xml +++ b/runbot/templates/utils.xml @@ -15,6 +15,8 @@ <script type="text/javascript" src="/runbot/static/libs/bootstrap/js/bootstrap.bundle.js"/> <script type="text/javascript" src="/runbot/static/src/frontend/runbot.js"/> + <t t-call-assets="runbot.assets_frontend"/> + <t t-if="refresh"> <meta http-equiv="refresh" t-att-content="refresh"/> </t> From 6ba9aaf41157288c3f7deddccfab4562573e3dd3 Mon Sep 17 00:00:00 2001 From: William Braeckman <wbr@odoo.com> Date: Mon, 10 Mar 2025 16:12:14 +0100 Subject: [PATCH 5/7] [IMP] runbot: add interaction framework Copies the colibri + interaction framework from `web` module. We also bootstrap interactions to be used in the frontend. --- runbot/static/src/frontend/root.js | 16 +- runbot/static/src/vendored/colibri.js | 360 +++++++++++++++ runbot/static/src/vendored/interaction.js | 409 ++++++++++++++++++ .../src/vendored/interaction_service.js | 206 +++++++++ runbot/static/src/vendored/render.js | 35 ++ runbot/static/src/vendored/timing.js | 212 +++++++++ runbot/static/src/vendored/utils.js | 180 ++++++++ 7 files changed, 1416 insertions(+), 2 deletions(-) create mode 100644 runbot/static/src/vendored/colibri.js create mode 100644 runbot/static/src/vendored/interaction.js create mode 100644 runbot/static/src/vendored/interaction_service.js create mode 100644 runbot/static/src/vendored/render.js create mode 100644 runbot/static/src/vendored/timing.js create mode 100644 runbot/static/src/vendored/utils.js diff --git a/runbot/static/src/frontend/root.js b/runbot/static/src/frontend/root.js index f4c24be13..04c14406a 100644 --- a/runbot/static/src/frontend/root.js +++ b/runbot/static/src/frontend/root.js @@ -1,8 +1,9 @@ -import { whenReady, Component, xml, App, onError } from '@runbot/owl'; +import { whenReady, Component, xml, App, onError, EventBus } from '@runbot/owl'; import { getTemplate } from '@web/core/templates'; import { registry } from '@web/core/registry'; import { useRegistry } from '@web/core/registry_hook'; +import { InteractionService } from '@web/public/interaction_service'; const mainComponents = registry.category('main.components'); @@ -56,9 +57,20 @@ class ComponentContainer extends Component { (async function startApp() { await whenReady(); + const env = { + // These attributes are required by vendored data + bus: new EventBus(), + isReady: Promise.resolve(true), + services: {}, + debug: odoo.debug, + }; + const app = new App(ComponentContainer, { getTemplate, - env: {}, + env, }); await app.mount(document.body); + const Interactions = registry.category('public.interactions').getAll(); + const service = new InteractionService(document.body, env); + service.activate(Interactions); })(); diff --git a/runbot/static/src/vendored/colibri.js b/runbot/static/src/vendored/colibri.js new file mode 100644 index 000000000..3ee10b9ba --- /dev/null +++ b/runbot/static/src/vendored/colibri.js @@ -0,0 +1,360 @@ +/** @odoo-module alias=@web/public/colibri default=false **/ + +/** + * This is a mini framework designed to make it easy to describe the dynamic + * content of a "interaction". + */ + +let owl = null; +let Markup = null; + +// Return this from event handlers to skip updateContent. +export const SKIP_IMPLICIT_UPDATE = Symbol(); + +export class Colibri { + constructor(core, I, el) { + this.el = el; + this.isReady = false; + this.isUpdating = false; + this.isDestroyed = false; + this.dynamicAttrs = []; + this.tOuts = []; + this.cleanups = []; + this.listeners = new Map(); + this.dynamicNodes = new Map(); + this.core = core; + this.interaction = new I(el, core.env, this); + this.interaction.setup(); + } + async start() { + await this.interaction.willStart(); + if (this.isDestroyed) { + return; + } + this.isReady = true; + const content = this.interaction.dynamicContent; + if (content) { + this.processContent(content); + this.updateContent(); + } + this.interaction.start(); + } + + addListener(nodes, event, fn, options) { + if (typeof fn !== "function") { + throw new Error(`Invalid listener for event '${event}' (not a function)`); + } + if (!this.isReady) { + throw new Error( + "this.addListener can only be called after the interaction is started. Maybe move the call in the start method." + ); + } + const re = /^(?<event>.*)\.(?<suffix>prevent|stop|capture|once|noUpdate|withTarget)$/; + let groups = re.exec(event)?.groups; + while (groups) { + fn = { + prevent: + (f) => + (ev, ...args) => { + ev.preventDefault(); + return f.call(this.interaction, ev, ...args); + }, + stop: + (f) => + (ev, ...args) => { + ev.stopPropagation(); + return f.call(this.interaction, ev, ...args); + }, + capture: (f) => { + options ||= {}; + options.capture = true; + return f; + }, + once: (f) => { + options ||= {}; + options.once = true; + return f; + }, + noUpdate: + (f) => + (...args) => { + f.call(this.interaction, ...args); + return SKIP_IMPLICIT_UPDATE; + }, + withTarget: + (f) => + (ev, ...args) => { + const currentTarget = ev.currentTarget; + return f.call(this.interaction, ev, currentTarget, ...args); + }, + }[groups.suffix](fn); + event = groups.event; + groups = re.exec(event)?.groups; + } + const handler = fn.isHandler + ? fn + : async (...args) => { + if (SKIP_IMPLICIT_UPDATE !== (await fn.call(this.interaction, ...args))) { + if (!this.isDestroyed) { + this.updateContent(); + } + } + }; + handler.isHandler = true; + for (const node of nodes) { + node.addEventListener(event, handler, options); + this.cleanups.push(() => node.removeEventListener(event, handler, options)); + } + return [event, handler, options]; + } + + refreshListeners() { + for (const sel of this.listeners.keys()) { + const nodes = this.getNodes(sel); + const newNodes = new Set(nodes); + const oldNodes = this.dynamicNodes.get(sel); + const events = this.listeners.get(sel); + const toRemove = new Set(); + for (const node of oldNodes) { + if (newNodes.has(node)) { + newNodes.delete(node); + } else { + toRemove.add(node); + } + } + for (const event of Object.keys(events)) { + const [handler, options] = events[event]; + for (const node of toRemove) { + node.removeEventListener(event, handler, options); + } + if (newNodes.size) { + this.addListener(newNodes, event, handler, options); + } + } + this.dynamicNodes.set(sel, nodes); + } + } + + mapSelectorToListeners(sel, event, handler, options) { + if (this.listeners.has(sel)) { + this.listeners.get(sel)[event] = [handler, options]; + } else { + this.listeners.set(sel, { [event]: [handler, options] }); + } + } + + mountComponent(nodes, C, props) { + for (const node of nodes) { + const root = this.core.prepareRoot(node, C, props); + root.mount(); + this.cleanups.push(() => root.destroy()); + } + } + + applyTOut(el, value) { + if (!Markup) { + owl = odoo.loader.modules.get("@runbot/owl"); + if (owl) { + Markup = owl.markup("").constructor; + } + } + if (Markup && value instanceof Markup) { + el.innerHTML = value; + } else { + el.textContent = value; + } + } + + applyAttr(el, attr, value) { + if (attr === "class") { + if (typeof value !== "object") { + throw new Error("t-att-class directive expects an object"); + } + for (const cl in value) { + for (const c of cl.trim().split(" ")) { + el.classList.toggle(c, value[cl] || false); + } + } + } else if (attr === "style") { + if (typeof value !== "object") { + throw new Error("t-att-style directive expects an object"); + } + for (const prop in value) { + let style = value[prop]; + if (style === undefined) { + el.style.removeProperty(prop); + } else { + style = String(style); + if (style.endsWith(" !important")) { + el.style.setProperty( + prop, + style.substring(0, style.length - 11), + "important" + ); + } else { + el.style.setProperty(prop, style); + } + } + } + } else { + if ([false, undefined, null].includes(value)) { + el.removeAttribute(attr); + } else { + if (value === true) { + value = attr; + } + el.setAttribute(attr, value); + } + } + } + + getNodes(sel) { + const selectors = this.interaction.dynamicSelectors; + if (sel in selectors) { + const elem = selectors[sel](); + return elem ? [elem] : []; + } + return this.interaction.el.querySelectorAll(sel); + } + + processContent(content) { + for (const sel in content) { + if (sel.startsWith("t-")) { + throw new Error(`Selector missing for key ${sel} in dynamicContent (interaction '${this.interaction.constructor.name}').`); + } + let nodes; + if (this.dynamicNodes.has(sel)) { + nodes = this.dynamicNodes.get(sel); + } else { + nodes = this.getNodes(sel); + this.dynamicNodes.set(sel, nodes); + } + const descr = content[sel]; + for (const directive in descr) { + const value = descr[directive]; + if (directive.startsWith("t-on-")) { + const ev = directive.slice(5); + const [event, handler, options] = this.addListener(nodes, ev, value); + this.mapSelectorToListeners(sel, event, handler, options); + } else if (directive.startsWith("t-att-")) { + const attr = directive.slice(6); + this.dynamicAttrs.push({ nodes, attr, definition: value, initialValues: null }); + } else if (directive === "t-out") { + this.tOuts.push([nodes, value]); + } else if (directive === "t-component") { + const { Component } = odoo.loader.modules.get("@runbot/owl"); + if (Object.prototype.isPrototypeOf.call(Component, value)) { + this.mountComponent(nodes, value); + } else { + this.mountComponent(nodes, ...value()); + } + } else { + const suffix = directive.startsWith("t-") ? "" : " (should start with t-)"; + throw new Error(`Invalid directive: '${directive}'${suffix}`); + } + } + } + } + + updateContent() { + if (this.isDestroyed || !this.isReady) { + throw new Error( + "Cannot update content of an interaction that is not ready or is destroyed" + ); + } + if (this.isUpdating) { + throw new Error("Updatecontent should not be called while interaction is updating"); + } + this.isUpdating = true; + const errors = []; + const interaction = this.interaction; + for (const dynamicAttr of this.dynamicAttrs) { + const { nodes, attr, definition, initialValues } = dynamicAttr; + let valuePerNode; + if (!initialValues) { + valuePerNode = new Map(); + dynamicAttr.initialValues = valuePerNode; + } + for (const node of nodes) { + try { + const value = definition.call(interaction, node); + if (!initialValues) { + let attrValue; + switch (attr) { + case "class": + attrValue = []; + for (const classNames of Object.keys(value)) { + attrValue[classNames] = node.classList.contains(classNames); + } + break; + case "style": + attrValue = {}; + for (const property of Object.keys(value)) { + const propertyValue = node.style.getPropertyValue(property); + const priority = node.style.getPropertyPriority(property); + attrValue[property] = propertyValue + ? propertyValue + (priority ? ` !${priority}` : "") + : ""; + } + break; + default: + attrValue = node.getAttribute(attr); + } + valuePerNode.set(node, attrValue); + } + this.applyAttr(node, attr, value); + } catch (e) { + errors.push({ error: e, attribute: attr }); + } + } + } + for (const [nodes, definition] of this.tOuts) { + for (const node of nodes) { + this.applyTOut(node, definition.call(interaction, node)); + } + } + this.isUpdating = false; + if (errors.length) { + const { attribute, error } = errors[0]; + throw Error( + `An error occured while updating dynamic attribute '${attribute}' (in interaction '${this.interaction.constructor.name}')`, + { cause: error } + ); + } + } + + destroy() { + // restore t-att to their initial values + for (const dynAttrs of this.dynamicAttrs) { + const { nodes, attr, initialValues } = dynAttrs; + if (!initialValues) { + continue; + } + for (const node of nodes) { + const initialValue = initialValues.get(node); + this.applyAttr(node, attr, initialValue); + } + } + + for (const cleanup of this.cleanups.reverse()) { + cleanup(); + } + this.cleanups = []; + this.listeners.clear(); + this.dynamicNodes.clear(); + this.interaction.destroy(); + this.core = null; + this.isDestroyed = true; + this.isReady = false; + } + + /** + * Patchable mechanism to handle context-specific protection of a specific + * chunk of synchronous code after returning from an asynchronous one. + * This should typically be used around code that follows an + * await waitFor(...). + */ + protectSyncAfterAsync(interaction, name, fn) { + return fn.bind(interaction); + } +} diff --git a/runbot/static/src/vendored/interaction.js b/runbot/static/src/vendored/interaction.js new file mode 100644 index 000000000..35bd51747 --- /dev/null +++ b/runbot/static/src/vendored/interaction.js @@ -0,0 +1,409 @@ +/** @odoo-module alias=@web/public/interaction default=false **/ + +import { renderToFragment } from "@web/core/utils/render"; +import { debounce, throttleForAnimation } from "@web/core/utils/timing"; +import { SKIP_IMPLICIT_UPDATE } from "./colibri"; +import { makeAsyncHandler, makeButtonHandler } from "./utils"; + +/** + * This is the base class to describe interactions. The Interaction class + * provides a good integration with the web framework (env/services), a well + * specified lifecycle, some dynamic content, and a few helper functions + * designed to accomplish common tasks, such as adding dom listener or waiting for + * some task to complete. + * + * Note that even though interactions are not destroyed in the standard workflow + * (a user visiting the website), there are still some cases where it happens: + * for example, when someone switch the website in "edit" mode. This means that + * interactions should gracefully clean up after themselves. + */ + +export class Interaction { + /** + * This static property describes the set of html element targeted by this + * interaction. An instance will be created for each match when the website + * framework is initialized. + * + * @type {string} + */ + static selector = ""; + + /** + * The `selectorHas` attribute, if defined, allows to filter elements found + * through the `selector` attribute by only considering those which contain + * at least an element which matches this `selectorHas` selector. + * + * Note that this is the equivalent of setting up a `selector` using the + * `:has` pseudo-selector but that pseudo-selector is known to not be fully + * supported in all browsers. To prevent useless crashes, using this + * `selectorHas` attribute should be preferred. + * + * @type {string} + */ + static selectorHas = ""; + + /** + * Note that a dynamic selector is allowed to return a falsy value, for ex + * the result of a querySelector. In that case, the directive will simply be + * ignored. + * + * @type {Object.<string, Function>} + */ + dynamicSelectors = { + _root: () => this.el, + _body: () => this.el.ownerDocument.body, + _window: () => window, + _document: () => this.el.ownerDocument, + }; + + /** + * The dynamic content of an interaction is an object describing the set of + * "dynamic elements" managed by the framework: event handlers, dynamic + * attributes, dynamic content, sub components. + * + * Its syntax looks like the following: + * dynamicContent = { + * ".some-selector": { "t-on-click": (ev) => this.onClick(ev) }, + * ".some-other-selector": { + * "t-att-class": () => ({ "some-class": true }), + * "t-att-style": () => ({ property: value }), + * "t-att-other-attribute": () => value, + * "t-out": () => value, + * }, + * _root: { "t-component": () => [Component, { someProp: "value" }] }, + * } + * + * A selector is either a standard css selector, or a special keyword + * (see dynamicSelectors: _body, _root, _document, _window) + * + * Accepted directives include: t-on-, t-att-, t-out and t-component + * + * A falsy value on a class or style property will remove it. + * On others attributes: + * - `false`, `undefined` or `null` remove it + * - other falsy values (`""`, `0`) are applied as such (`required=""`) + * - boolean `true` is applied as the attribute's name + * (e.g. `{ "t-att-required": () => true }` applies `required="required"`) + * + * Note that this is not owl! It is similar, to make it easy to learn, but + * it is different, the syntax and semantics are somewhat different. + * + * @type {Object} + */ + dynamicContent = {}; + + /** + * The constructor is not supposed to be defined in a subclass. Use setup + * instead + * + * @param {HTMLElement} el + * @param {import("@web/env").OdooEnv} env + * @param {Object} metadata + */ + constructor(el, env, metadata) { + this.__colibri__ = metadata; + this.el = el; + this.env = env; + this.services = env.services; + } + + /** + * Returns true if the interaction has been started (so, just before the + * start method is called) + */ + get isReady() { + return this.__colibri__.isReady; + } + + get isDestroyed() { + return this.__colibri__.isDestroyed; + } + + // ------------------------------------------------------------------------- + // lifecycle methods + // ------------------------------------------------------------------------- + + /** + * This is the standard constructor method. This is the proper place to + * initialize everything needed by the interaction. The el element is + * available and can be used. Services are ready and available as well. + */ + setup() {} + + /** + * If the interaction needs some asynchronous work to be ready, it should + * be done here. The website framework will wait for this method to complete + * before applying the dynamic content (event handlers, ...) + */ + async willStart() {} + + /** + * The start function when we need to execute some code after the interaction + * is ready. It is the equivalent to the "mounted" owl lifecycle hook. At + * this point, event handlers have been attached. + */ + start() {} + + /** + * All side effects done should be cleaned up here. Note that like all + * other lifecycle methods, it is not necessary to call the super.destroy + * method (unless you inherit from a concrete subclass) + */ + destroy() {} + + // ------------------------------------------------------------------------- + // helpers + // ------------------------------------------------------------------------- + + /** + * This method applies the dynamic content description to the dom. So, if + * a dynamic attribute has been defined with a t-att-, it will be done + * synchronously by this method. Note that updateContent is already being + * called after each event handler, and by most other helpers, so in practice, + * it is not common to need to call it. + */ + updateContent() { + this.__colibri__.updateContent(); + } + + /** + * Wrap a promise into a promise that will only be resolved if the interaction + * has not been destroyed, and will also call updateContent after the calling + * code has acted. + */ + waitFor(promise) { + const prom = new Promise((resolve, reject) => { + promise + .then((result) => { + if (!this.isDestroyed) { + resolve(result); + prom.then(() => { + if (this.isReady) { + this.updateContent(); + } + }); + } + }) + .catch((e) => { + reject(e); + prom.catch(() => { + if (this.isReady && !this.isDestroyed) { + this.updateContent(); + } + }); + }); + }); + return prom; + } + + /** + * Mechanism to handle context-specific protection of a specific + * chunk of synchronous code after returning from an asynchronous one. + * This should typically be used around code that follows an + * await waitFor(...). + */ + protectSyncAfterAsync(fn) { + return this.__colibri__.protectSyncAfterAsync(this, "protectSyncAfterAsync", fn); + } + + /** + * Wait for a specific timeout, then execute the given function (unless the + * interaction has been destroyed). The dynamic content is then applied. + */ + waitForTimeout(fn, delay) { + fn = this.__colibri__.protectSyncAfterAsync(this, "waitForTimeout", fn); + return setTimeout(() => { + if (!this.isDestroyed) { + fn.call(this); + if (this.isReady) { + this.updateContent(); + } + } + }, parseInt(delay)); + } + + /** + * Wait for a animation frame, then execute the given function (unless the + * interaction has been destroyed). The dynamic content is then applied. + */ + waitForAnimationFrame(fn) { + fn = this.__colibri__.protectSyncAfterAsync(this, "waitForAnimationFrame", fn); + return window.requestAnimationFrame(() => { + if (!this.isDestroyed) { + fn.call(this); + if (this.isReady) { + this.updateContent(); + } + } + }); + } + + /** + * Debounces a function and makes sure it is cancelled upon destroy. + */ + debounced(fn, delay) { + fn = this.__colibri__.protectSyncAfterAsync(this, "debounced", fn); + const debouncedFn = debounce(async (...args) => { + await fn.apply(this, args); + if (this.isReady && !this.isDestroyed) { + this.updateContent(); + } + }, delay); + this.registerCleanup(() => { + debouncedFn.cancel(); + }); + return Object.assign( + { + [debouncedFn.name]: (...args) => { + debouncedFn(...args); + return SKIP_IMPLICIT_UPDATE; + }, + }[debouncedFn.name], + { + cancel: debouncedFn.cancel, + } + ); + } + + /** + * Throttles a function for animation and makes sure it is cancelled upon destroy. + */ + throttled(fn) { + fn = this.__colibri__.protectSyncAfterAsync(this, "throttled", fn); + const throttledFn = throttleForAnimation(async (...args) => { + await fn.apply(this, args); + if (this.isReady && !this.isDestroyed) { + this.updateContent(); + } + }); + this.registerCleanup(() => { + throttledFn.cancel(); + }); + return Object.assign( + { + [throttledFn.name]: (...args) => { + throttledFn(...args); + return SKIP_IMPLICIT_UPDATE; + }, + }[throttledFn.name], + { + cancel: throttledFn.cancel, + } + ); + } + + /** + * Make sure the function is not started again before it is completed. + * If required, add a loading animation on button if the execution takes + * more than 400ms. + */ + locked(fn, useLoadingAnimation = false) { + fn = this.__colibri__.protectSyncAfterAsync(this, "locked", fn); + if (useLoadingAnimation) { + return makeButtonHandler(fn); + } else { + return makeAsyncHandler(fn); + } + } + + /** + * Add a listener to the target. Whenever the listener is executed, the + * dynamic content will be applied. Also, the listener will automatically be + * cleaned up when the interaction is destroyed. + * Returns a function to remove the listener(s). + * + * @param {EventTarget|EventTarget[]|NodeList} target one or more element(s) / bus + * @param {string} event + * @param {Function} fn + * @param {Object} [options] + * @returns {Function} removes the listeners + */ + addListener(target, event, fn, options) { + const nodes = target[Symbol.iterator] ? target : [target]; + const [ev, handler, opts] = this.__colibri__.addListener(nodes, event, fn, options); + return () => nodes.forEach((node) => node.removeEventListener(ev, handler, opts)); + } + + /** + * Recomputes eventListeners registered through `dynamicContent`. + * Interaction listeners are static. If the DOM is updated at some point, + * you should call `refreshListeners` so that events are: + * - removed on nodes that don't match the selector anymore + * - added on new nodes or on nodes that didn't match it before but do now. + */ + refreshListeners() { + this.__colibri__.refreshListeners(); + } + + /** + * Insert and activate an element at a specific location (default position: + * "beforeend"). + * The inserted element will be removed when the interaction is destroyed. + * + * @param { HTMLElement } el + * @param { HTMLElement } [locationEl] the target + * @param { "afterbegin" | "afterend" | "beforebegin" | "beforeend" } [position] + * @param { boolean } [removeOnClean] + */ + insert(el, locationEl = this.el, position = "beforeend", removeOnClean = true) { + locationEl.insertAdjacentElement(position, el); + if (removeOnClean) { + this.registerCleanup(() => el.remove()); + } + this.services["public.interactions"].startInteractions(el); + this.refreshListeners(); + } + + /** + * Renders, insert and activate an element at a specific location. + * The inserted element will be removed when the interaction is destroyed. + * + * @param { string } template + * @param { Object } renderContext + * @param { HTMLElement } [locationEl] the target + * @param { "afterbegin" | "afterend" | "beforebegin" | "beforeend" } [position] + * @param { Function } callback called with rendered elements before insertion + * @param { boolean } [removeOnClean] + * @returns { HTMLElement[] } rendered elements + */ + renderAt( + template, + renderContext, + locationEl, + position = "beforeend", + callback, + removeOnClean = true + ) { + const fragment = renderToFragment(template, renderContext); + const result = [...fragment.children]; + const els = [...fragment.children]; + callback?.(els); + if (["afterend", "afterbegin"].includes(position)) { + els.reverse(); + } + for (const el of els) { + this.insert(el, locationEl, position, removeOnClean); + } + return result; + } + + /** + * Register a function that will be executed when the interaction is + * destroyed. It is sometimes useful, so we can explicitely add the cleanup + * at the location where the side effect is created. + * + * @param {Function} fn + */ + registerCleanup(fn) { + this.__colibri__.cleanups.push(fn.bind(this)); + } + + /** + * @param {HTMLElement} el + * @param {import("@odoo/owl").Component} C + * @param {Object|null} [props] + */ + mountComponent(el, C, props = null) { + this.__colibri__.mountComponent([el], C, props); + } +} diff --git a/runbot/static/src/vendored/interaction_service.js b/runbot/static/src/vendored/interaction_service.js new file mode 100644 index 000000000..1852e8cd5 --- /dev/null +++ b/runbot/static/src/vendored/interaction_service.js @@ -0,0 +1,206 @@ +/** @odoo-module alias=@web/public/interaction_service default=false **/ + +import { Interaction } from "./interaction"; +import { getTemplate } from "@web/core/templates"; +import { PairSet } from "./utils"; +import { Colibri } from "./colibri"; + +/** + * Website Core + * + * This service handles the core interactions for the website codebase. + * It will replace public root, publicroot instance, and all that stuff + * + * We have 2 kinds of interactions: + * - simple interactions (subclasses of Interaction) + * - components + * + * The Interaction class is designed to be a simple class that provides access + * to the framework (env and services), and a minimalist declarative framework + * that allows manipulating dom, attaching event handlers and updating it + * properly. It does not depend on owl. + * + * The Component kind of interaction is used for more complicated interface needs. + * It provides full access to Owl features, but is rendered browser side. + * + */ + +export class InteractionService { + /** + * + * @param {HTMLElement} el + * @param {Object} env + */ + constructor(el, env) { + this.Interactions = []; + this.el = el; + this.isActive = false; + // relation el <--> Interaction + this.activeInteractions = new PairSet(); + this.env = env; + this.interactions = []; + this.roots = []; + this.owlApp = null; + this.proms = []; + this.registry = null; + } + + /** + * + * @param {Interaction[]} Interactions + */ + activate(Interactions) { + this.Interactions = Interactions; + const startProm = this.env.isReady.then(() => this.startInteractions()); + this.proms.push(startProm); + } + + prepareRoot(el, C, props) { + if (!this.owlApp) { + const { App } = odoo.loader.modules.get("@runbot/owl"); + const appConfig = { + name: "Odoo Website", + getTemplate, + env: this.env, + dev: this.env.debug, + warnIfNoStaticProps: this.env.debug, + }; + this.owlApp = new App(null, appConfig); + } + const root = this.owlApp.createRoot(C, { props, env: this.env }); + const compElem = document.createElement("owl-component"); + compElem.setAttribute("contenteditable", "false"); + compElem.dataset.oeProtected = "true"; + el.appendChild(compElem); + return { + C, + root, + el: compElem, + mount: () => root.mount(compElem), + destroy: () => { + root.destroy(); + compElem.remove(); + }, + }; + } + + async _mountComponent(el, C) { + const root = this.prepareRoot(el, C); + this.roots.push(root); + return root.mount(); + } + + startInteractions(el = this.el) { + if (!el.isConnected) { + return Promise.resolve(); + } + const proms = []; + for (const I of this.Interactions) { + if (I.selector === "") { + throw new Error( + `The selector should be defined as a static property on the class ${I.name}, not on the instance` + ); + } + if (I.dynamicContent) { + throw new Error( + `The dynamic content object should be defined on the instance, not on the class (${I.name})` + ); + } + let targets; + try { + const isMatch = el.matches(I.selector); + targets = isMatch + ? [el, ...el.querySelectorAll(I.selector)] + : el.querySelectorAll(I.selector); + if (I.selectorHas) { + targets = [...targets].filter((el) => !!el.querySelector(I.selectorHas)); + } + } catch { + const selectorHasError = I.selectorHas ? ` or selectorHas: '${I.selectorHas}'` : ""; + const error = new Error( + `Could not start interaction ${I.name} (invalid selector: '${I.selector}'${selectorHasError})` + ); + proms.push(Promise.reject(error)); + continue; + } + for (const _el of targets) { + this._startInteraction(_el, I, proms); + } + } + if (el === this.el) { + this.isActive = true; + } + const prom = Promise.all(proms); + this.proms.push(prom); + return prom; + } + + _startInteraction(el, I, proms) { + if (this.activeInteractions.has(el, I)) { + return; + } + this.activeInteractions.add(el, I); + if (I.prototype instanceof Interaction) { + try { + const interaction = new Colibri(this, I, el); + this.interactions.push(interaction); + proms.push(interaction.start()); + } catch (e) { + this.proms.push(Promise.reject(e)); + } + } else { + proms.push(this._mountComponent(el, I)); + } + } + + stopInteractions(el = this.el) { + const interactions = []; + for (const interaction of this.interactions.slice().reverse()) { + if (el === interaction.el || el.contains(interaction.el)) { + interaction.destroy(); + this.activeInteractions.delete(interaction.el, interaction.interaction.constructor); + } else { + interactions.push(interaction); + } + } + this.interactions = interactions; + const roots = []; + for (const root of this.roots.slice().reverse()) { + if (el === root.el || el.contains(root.el)) { + root.destroy(); + this.activeInteractions.delete(root.el, root.C); + } else { + roots.push(root); + } + } + this.roots = roots; + if (el === this.el) { + this.isActive = false; + } + } + + /** + * @returns { Promise } returns a promise that is resolved when all current + * interactions are started. Note that it does not take into account possible + * future interactions. + */ + get isReady() { + const proms = this.proms.slice(); + return Promise.all(proms); + } +} + +// registry.category("services").add("public.interactions", { +// dependencies: ["localization"], +// async start(env) { +// const el = document.querySelector("#wrapwrap"); +// if (!el) { +// // if this is an issue, maybe we should make the wrapwrap configurable +// return null; +// } +// const Interactions = registry.category("public.interactions").getAll(); +// const service = new InteractionService(el, env); +// service.activate(Interactions); +// return service; +// }, +// }); diff --git a/runbot/static/src/vendored/render.js b/runbot/static/src/vendored/render.js new file mode 100644 index 000000000..3d1750bc0 --- /dev/null +++ b/runbot/static/src/vendored/render.js @@ -0,0 +1,35 @@ +/** @odoo-module alias=@web/core/utils/render default=false **/ + +import { App, blockDom, Component } from "@runbot/owl"; +import { getTemplate } from "@web/core/templates"; + + +export function renderToFragment(template, context = {}) { + const frag = document.createDocumentFragment(); + for (const el of [...render(template, context).children]) { + frag.appendChild(el); + } + return frag; +} + +let app; +Object.defineProperty(renderToFragment, "app", { + get: () => { + if (!app) { + app = new App(Component, { + name: "renderToFragment", + getTemplate, + }); + } + return app; + }, +}); + +function render(template, context = {}) { + const app = renderToFragment.app; + const templateFn = app.getTemplate(template); + const bdom = templateFn(context, {}); + const div = document.createElement("div"); + blockDom.mount(bdom, div); + return div; +} diff --git a/runbot/static/src/vendored/timing.js b/runbot/static/src/vendored/timing.js new file mode 100644 index 000000000..fda95438d --- /dev/null +++ b/runbot/static/src/vendored/timing.js @@ -0,0 +1,212 @@ +/** @odoo-module alias=@web/core/utils/timing default=false **/ + + +import { onWillUnmount, useComponent } from "@runbot/owl"; + +/** + * Creates a batched version of a callback so that all calls to it in the same + * time frame will only call the original callback once. + * @param callback the callback to batch + * @param synchronize this function decides the granularity of the batch (a microtick by default) + * @returns a batched version of the original callback + */ +export function batched(callback, synchronize = () => Promise.resolve()) { + let scheduled = false; + return async (...args) => { + if (!scheduled) { + scheduled = true; + await synchronize(); + scheduled = false; + callback(...args); + } + }; +} + +/** + * Creates and returns a new debounced version of the passed function (func) + * which will postpone its execution until after 'delay' milliseconds + * have elapsed since the last time it was invoked. The debounced function + * will return a Promise that will be resolved when the function (func) + * has been fully executed. + * + * If both `options.trailing` and `options.leading` are true, the function + * will only be invoked at the trailing edge if the debounced function was + * called at least once more during the wait time. + * + * @template {Function} T the return type of the original function + * @param {T} func the function to debounce + * @param {number | "animationFrame"} delay how long should elapse before the function + * is called. If 'animationFrame' is given instead of a number, 'requestAnimationFrame' + * will be used instead of 'setTimeout'. + * @param {boolean} [options] if true, equivalent to exclusive leading. If false, equivalent to exclusive trailing. + * @param {object} [options] + * @param {boolean} [options.leading=false] whether the function should be invoked at the leading edge of the timeout + * @param {boolean} [options.trailing=true] whether the function should be invoked at the trailing edge of the timeout + * @returns {T & { cancel: () => void }} the debounced function + */ +export function debounce(func, delay, options) { + let handle; + const funcName = func.name ? func.name + " (debounce)" : "debounce"; + const useAnimationFrame = delay === "animationFrame"; + const setFnName = useAnimationFrame ? "requestAnimationFrame" : "setTimeout"; + const clearFnName = useAnimationFrame ? "cancelAnimationFrame" : "clearTimeout"; + let lastArgs; + let leading = false; + let trailing = true; + if (typeof options === "boolean") { + leading = options; + trailing = !options; + } else if (options) { + leading = options.leading ?? leading; + trailing = options.trailing ?? trailing; + } + + return Object.assign( + { + /** @type {any} */ + [funcName](...args) { + return new Promise((resolve) => { + if (leading && !handle) { + Promise.resolve(func.apply(this, args)).then(resolve); + } else { + lastArgs = args; + } + window[clearFnName](handle); + handle = window[setFnName](() => { + handle = null; + if (trailing && lastArgs) { + Promise.resolve(func.apply(this, lastArgs)).then(resolve); + lastArgs = null; + } + }, delay); + }); + }, + }[funcName], + { + cancel(execNow = false) { + window[clearFnName](handle); + if (execNow && lastArgs) { + func.apply(this, lastArgs); + } + }, + } + ); +} + +/** + * Function that calls recursively a request to an animation frame. + * Useful to call a function repetitively, until asked to stop, that needs constant rerendering. + * The provided callback gets as argument the time the last frame took. + * @param {(deltaTime: number) => void} callback + * @returns {() => void} stop function + */ +export function setRecurringAnimationFrame(callback) { + const handler = (timestamp) => { + callback(timestamp - lastTimestamp); + lastTimestamp = timestamp; + handle = window.requestAnimationFrame(handler); + }; + + const stop = () => { + window.cancelAnimationFrame(handle); + }; + + let lastTimestamp = window.performance.now(); + let handle = window.requestAnimationFrame(handler); + + return stop; +} + +/** + * Creates a version of the function where only the last call between two + * animation frames is executed before the browser's next repaint. This + * effectively throttles the function to the display's refresh rate. + * Note that the throttled function can be any callback. It is not + * specifically an event handler, no assumption is made about its + * signature. + * NB: The first call is always called immediately (leading edge). + * + * @template {Function} T + * @param {T} func the function to throttle + * @returns {T & { cancel: () => void }} the throttled function + */ +export function throttleForAnimation(func) { + let handle = null; + const calls = new Set(); + const funcName = func.name ? `${func.name} (throttleForAnimation)` : "throttleForAnimation"; + const pending = () => { + if (calls.size) { + handle = window.requestAnimationFrame(pending); + const { args, resolve } = [...calls].pop(); + calls.clear(); + Promise.resolve(func.apply(this, args)).then(resolve); + } else { + handle = null; + } + }; + return Object.assign( + { + /** @type {any} */ + [funcName](...args) { + return new Promise((resolve) => { + const isNew = handle === null; + if (isNew) { + handle = window.requestAnimationFrame(pending); + Promise.resolve(func.apply(this, args)).then(resolve); + } else { + calls.add({ args, resolve }); + } + }); + }, + }[funcName], + { + cancel() { + window.cancelAnimationFrame(handle); + calls.clear(); + handle = null; + }, + } + ); +} + +// ----------------------------------- HOOKS ----------------------------------- + +/** + * Hook that returns a debounced version of the given function, and cancels + * the potential pending execution on willUnmount. + * @see debounce + * @template {Function} T + * @param {T} callback + * @param {number | "animationFrame"} delay + * @param {Object} [options] + * @param {string} [options.execBeforeUnmount=false] executes the callback if the debounced function + * has been called and not resolved before destroying the component. + * @param {boolean} [options.immediate=false] whether the function should be called on + * the leading edge instead of the trailing edge. + * @returns {T & { cancel: () => void }} + */ +export function useDebounced( + callback, + delay, + { execBeforeUnmount = false, immediate = false } = {} +) { + const component = useComponent(); + const debounced = debounce(callback.bind(component), delay, immediate); + onWillUnmount(() => debounced.cancel(execBeforeUnmount)); + return debounced; +} + +/** + * Hook that returns a throttled for animation version of the given function, + * and cancels the potential pending execution on willUnmount. + * @see throttleForAnimation + * @template {Function} T + * @param {T} func the function to throttle + * @returns {T & { cancel: () => void }} the throttled function + */ +export function useThrottleForAnimation(func) { + const component = useComponent(); + const throttledForAnimation = throttleForAnimation(func.bind(component)); + onWillUnmount(() => throttledForAnimation.cancel()); + return throttledForAnimation; +} diff --git a/runbot/static/src/vendored/utils.js b/runbot/static/src/vendored/utils.js new file mode 100644 index 000000000..62362f530 --- /dev/null +++ b/runbot/static/src/vendored/utils.js @@ -0,0 +1,180 @@ +/** @odoo-module alias=@web/public/utils default=false **/ + +export class PairSet { + constructor() { + this.map = new Map(); // map of [1] => Set<[2]> + } + add(elem1, elem2) { + if (!this.map.has(elem1)) { + this.map.set(elem1, new Set()); + } + this.map.get(elem1).add(elem2); + } + has(elem1, elem2) { + if (!this.map.has(elem1)) { + return false; + } + return this.map.get(elem1).has(elem2); + } + delete(elem1, elem2) { + if (!this.map.has(elem1)) { + return; + } + const s = this.map.get(elem1); + s.delete(elem2); + if (!s.size) { + this.map.delete(elem1); + } + } +} + +import { addLoadingEffect } from "@web/core/utils/ui"; + +export const DEBOUNCE = 400; +export const BUTTON_HANDLER_SELECTOR = + 'a, button, input[type="submit"], input[type="button"], .btn'; + +/** + * Protects a function which is to be used as a handler by preventing its + * execution for the duration of a previous call to it (including async + * parts of that call). + * + * @param {function} fct + * The function which is to be used as a handler. If a promise + * is returned, it is used to determine when the handler's action is + * finished. Otherwise, the return is used as jQuery uses it. + */ +export function makeAsyncHandler(fct) { + let pending = false; + function _isLocked() { + return pending; + } + function _lock() { + pending = true; + } + function _unlock() { + pending = false; + } + return function () { + if (_isLocked()) { + // If a previous call to this handler is still pending, ignore + // the new call. + return; + } + + _lock(); + const result = fct.apply(this, arguments); + Promise.resolve(result).finally(_unlock); + return result; + }; +} + +/** + * Creates a debounced version of a function to be used as a button click + * handler. Also improves the handler to disable the button for the time of + * the debounce and/or the time of the async actions it performs. + * + * Limitation: if two handlers are put on the same button, the button will + * become enabled again once any handler's action finishes (multiple click + * handlers should however not be bound to the same button). + * + * @param {function} fct + * The function which is to be used as a button click handler. If a + * promise is returned, it is used to determine when the button can be + * re-enabled. Otherwise, the return is used as jQuery uses it. + */ +export function makeButtonHandler(fct) { + // Fallback: if the final handler is not bound to a button, at least + // make it an async handler (also handles the case where some events + // might ignore the disabled state of the button). + fct = makeAsyncHandler(fct); + + return function (ev) { + const result = fct.apply(this, arguments); + + const buttonEl = ev.target.closest(BUTTON_HANDLER_SELECTOR); + if (!(buttonEl instanceof HTMLElement)) { + return result; + } + + // Disable the button for the duration of the handler's action + // or at least for the duration of the click debounce. This makes + // a 'real' debounce creation useless. Also, during the debouncing + // part, the button is disabled without any visual effect. + buttonEl.classList.add("pe-none"); + new Promise((resolve) => setTimeout(resolve, DEBOUNCE)).then(() => { + buttonEl.classList.remove("pe-none"); + const restore = addLoadingEffect(buttonEl); + return Promise.resolve(result).then(restore, restore); + }); + + return result; + }; +} + +/** + * Patches a "t-" entry of a dynamic content. + * + * @param {Object} dynamicContent + * @param {string} selector + * @param {string} t + * @param {any|function} replacement, if a function, takes the element and the + * replaced's function output as parameters + */ +export function patchDynamicContentEntry(dynamicContent, selector, t, replacement) { + dynamicContent[selector] = dynamicContent[selector] || {}; + const forSelector = dynamicContent[selector]; + if (replacement === undefined) { + delete forSelector[t]; + } else if (typeof replacement === "function" && t !== "t-component") { + if (!forSelector[t]) { + forSelector[t] = () => {}; + } + const oldFn = forSelector[t]; + if (["t-att-class", "t-att-style"].includes(t)) { + forSelector[t] = (el, oldResult) => { + const result = oldResult || {}; + Object.assign(result, oldFn(el, result)); + Object.assign(result, replacement(el, result)); + return result; + }; + } else if (t.startsWith("t-on-")) { + forSelector[t] = (el, ...args) => replacement(el, oldFn, ...args); + } else { + forSelector[t] = (el, oldResult) => { + let result = oldResult; + result = oldFn(el, result); + result = replacement(el, result); + return result; + }; + } + } else { + forSelector[t] = replacement; + } +} + +/** + * Patches several entries in a dynamicContent. + * Example usage: + * patchDynamicContent(this.dynamicContent, { + * _root: { + * "t-att-class": (el, old) => ({ + * "test": this.condition && old.test, + * }), + * "t-on-click": (el, oldFn) => { + * oldFn(el); + * this.doMoreStuff(); + * }, + * }, + * }) + * + * @param {Object} dynamicContent + * @param {Object} replacement + */ +export function patchDynamicContent(dynamicContent, replacement) { + for (const [selector, forSelector] of Object.entries(replacement)) { + for (const [t, forT] of Object.entries(forSelector)) { + patchDynamicContentEntry(dynamicContent, selector, t, forT); + } + } +} From 723f9c49ef10caa23f57293cf253c22766142a22 Mon Sep 17 00:00:00 2001 From: William Braeckman <wbr@odoo.com> Date: Mon, 10 Mar 2025 16:34:39 +0100 Subject: [PATCH 6/7] [REF] runbot: rewrite runbot.js Rewrites runbot.js in modern js with the new Interaction framework. --- runbot/__manifest__.py | 1 + runbot/static/src/frontend/runbot.js | 82 ++++++++++++++++------------ runbot/templates/utils.xml | 3 +- 3 files changed, 49 insertions(+), 37 deletions(-) diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 465231bcb..23dcaca97 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -76,6 +76,7 @@ 'runbot/static/src/vendored/**/*', # Vendored files coming from odoo modules 'runbot/static/src/frontend/root.js', + 'runbot/static/src/frontend/runbot.js', ] }, 'post_load': 'runbot_post_load', diff --git a/runbot/static/src/frontend/runbot.js b/runbot/static/src/frontend/runbot.js index 4775c9e75..73e96b132 100644 --- a/runbot/static/src/frontend/runbot.js +++ b/runbot/static/src/frontend/runbot.js @@ -1,39 +1,51 @@ -(function($) { - "use strict"; - $(function () { - $(document).on('click', '[data-runbot]', function (e) { - e.preventDefault(); - var data = $(this).data(); - var operation = data.runbot; - if (!operation) { - return; - } - var xhr = new XMLHttpRequest(); - var url = e.target.href - if (data.runbotBuild) { - url = '/runbot/build/' + data.runbotBuild + '/' + operation - } - var elem = e.target - xhr.addEventListener('load', function () { - if (operation == 'rebuild' && window.location.href.split('?')[0].endsWith('/build/' + data.runbotBuild)){ - window.location.href = window.location.href.replace('/build/' + data.runbotBuild, '/build/' + xhr.responseText); - } else if (operation == 'action') { - elem.parentElement.innerText = this.responseText - } else { - window.location.reload(); - } - }); - xhr.open('POST', url); - xhr.send(); - }); - }); -})(jQuery); +import { registry } from '@web/core/registry'; +import { Interaction } from '@web/public/interaction'; + + +class Runbot extends Interaction { + static selector = '.frontend'; + dynamicContent = { + '[data-runbot]': { + 't-on-click.prevent': this.onClickDataRunbot, + }, + '[data-clipboard-copy]': { + 't-on-click.prevent': this.onClickClipboardCopy + } + }; + /** + * @param {Event} ev + */ + async onClickDataRunbot({currentTarget: target}) { + const {runbot: operation, runbotBuild} = target.dataset; + if (!operation) { + return; + } + let url = target.href; + if (runbotBuild) { + url = `/runbot/build/${runbotBuild}/${operation}`; + } + const response = await fetch(url, { + method: 'POST', + }); + if (operation == 'rebuild' && window.location.href.split('?')[0].endsWith(`/build/${runbotBuild}`)) { + window.location.href = window.location.href.replace('/build/' + runbotBuild, '/build/' + await response.text()); + } else if (operation == 'action') { + target.parentElement.innerText = await response.text(); + } else { + window.location.reload(); + } + } -function copyToClipboard(text) { - if (!navigator.clipboard) { - console.error('Clipboard not supported'); - return; + /** + * @param {Event} ev + */ + async onClickClipboardCopy({ currentTarget: target }) { + if (!navigator.clipboard) { + return; + } + navigator.clipboard.writeText(target.dataset.clipboardCopy); } - navigator.clipboard.writeText(text); } + +registry.category('public.interactions').add('runbot', Runbot); diff --git a/runbot/templates/utils.xml b/runbot/templates/utils.xml index db3bddba0..f9c35c219 100644 --- a/runbot/templates/utils.xml +++ b/runbot/templates/utils.xml @@ -13,7 +13,6 @@ <script src="/runbot/static/libs/jquery/jquery.js" type="text/javascript"/> <script type="text/javascript" src="/runbot/static/libs/popper/popper.js"/> <script type="text/javascript" src="/runbot/static/libs/bootstrap/js/bootstrap.bundle.js"/> - <script type="text/javascript" src="/runbot/static/src/frontend/runbot.js"/> <t t-call-assets="runbot.assets_frontend"/> @@ -392,7 +391,7 @@ </template> <template id="runbot.branch_copy_button"> - <button t-attf-class="btn btn-default {{btn_size or 'btn-ssm'}}" title="Copy Bundle name" aria-label="Copy Bundle name" t-attf-onclick="copyToClipboard('{{ bundle.name.split(':')[-1] }}')"> + <button t-attf-class="btn btn-default {{btn_size or 'btn-ssm'}}" title="Copy Bundle name" aria-label="Copy Bundle name" t-attf-data-clipboard-copy="{{ bundle.name.split(':')[-1] }}"> <i t-attf-class="fa fa-clipboard"/> </button> </template> From cc2e3e0d660711b6bcb29410f5dff7760cec6a0f Mon Sep 17 00:00:00 2001 From: William Braeckman <wbr@odoo.com> Date: Mon, 10 Mar 2025 16:39:26 +0100 Subject: [PATCH 7/7] [REF] runbot: move all assets to the bundle Instead of hardcoding rels, move assets to the bundle. --- runbot/__manifest__.py | 7 +++++++ runbot/templates/utils.xml | 7 ------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 23dcaca97..0ff95fdc5 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -70,6 +70,13 @@ 'runbot.assets_frontend': [ ('include', 'web.assets_frontend_minimal'), # Pray the gods this stays named correctly + 'runbot/static/libs/bootstrap/css/bootstrap.css', + 'runbot/static/libs/fontawesome/css/font-awesome.css', + 'runbot/static/src/css/runbot.css', + 'runbot/static/libs/jquery/jquery.js', + 'runbot/static/libs/popper/popper.js', + 'runbot/static/libs/bootstrap/js/bootstrap.bundle.js', + 'runbot/static/libs/owl.js', 'runbot/static/src/owl_module.js', diff --git a/runbot/templates/utils.xml b/runbot/templates/utils.xml index f9c35c219..e0f194506 100644 --- a/runbot/templates/utils.xml +++ b/runbot/templates/utils.xml @@ -6,13 +6,6 @@ <html t-att-data-bs-theme="theme"> <head> <title t-out="title or 'Runbot'"/> - <link rel="stylesheet" type="text/css" href="/runbot/static/libs/bootstrap/css/bootstrap.css"/> - <link rel="stylesheet" type="text/css" href="/runbot/static/libs/fontawesome/css/font-awesome.css"/> - <link rel="stylesheet" type="text/css" href="/runbot/static/src/css/runbot.css"/> - - <script src="/runbot/static/libs/jquery/jquery.js" type="text/javascript"/> - <script type="text/javascript" src="/runbot/static/libs/popper/popper.js"/> - <script type="text/javascript" src="/runbot/static/libs/bootstrap/js/bootstrap.bundle.js"/> <t t-call-assets="runbot.assets_frontend"/>