This commit is contained in:
parent
7c8012b7af
commit
01b9498193
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import models
|
||||
from . import version
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
|
||||
{
|
||||
'name': 'Odex30 Web',
|
||||
'category': 'Odex30-base',
|
||||
'author': 'Expert Co. Ltd.',
|
||||
'version': '1.0',
|
||||
'description': """
|
||||
Odex Web Client.
|
||||
===========================
|
||||
|
||||
This module modifies the web addon to provide Odex design and responsiveness.
|
||||
""",
|
||||
'depends': ['web', 'base_setup'],
|
||||
'auto_install': ['web'],
|
||||
'data': [
|
||||
'views/webclient_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web._assets_primary_variables': [
|
||||
('after', 'web/static/src/scss/primary_variables.scss', 'odex30_web/static/src/**/*.variables.scss'),
|
||||
('before', 'web/static/src/scss/primary_variables.scss', 'odex30_web/static/src/scss/primary_variables.scss'),
|
||||
],
|
||||
'web._assets_secondary_variables': [
|
||||
('before', 'web/static/src/scss/secondary_variables.scss', 'odex30_web/static/src/scss/secondary_variables.scss'),
|
||||
],
|
||||
'web._assets_backend_helpers': [
|
||||
('before', 'web/static/src/scss/bootstrap_overridden.scss', 'odex30_web/static/src/scss/bootstrap_overridden.scss'),
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
'odex30_web/static/src/webclient/home_menu/home_menu_background.scss',
|
||||
'odex30_web/static/src/webclient/navbar/navbar.scss',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'odex30_web/static/src/webclient/**/*.scss',
|
||||
'odex30_web/static/src/views/**/*.scss',
|
||||
|
||||
'odex30_web/static/src/core/**/*',
|
||||
'odex30_web/static/src/webclient/**/*.js',
|
||||
('after', 'web/static/src/views/list/list_renderer.xml', 'odex30_web/static/src/views/list/list_renderer_desktop.xml'),
|
||||
'odex30_web/static/src/webclient/**/*.xml',
|
||||
'odex30_web/static/src/views/**/*.js',
|
||||
'odex30_web/static/src/views/**/*.xml',
|
||||
('remove', 'odex30_web/static/src/views/pivot/**'),
|
||||
|
||||
('remove', 'odex30_web/static/src/**/*.dark.scss'),
|
||||
],
|
||||
'web.assets_backend_lazy': [
|
||||
'odex30_web/static/src/views/pivot/**',
|
||||
],
|
||||
'web.assets_backend_lazy_dark': [
|
||||
('include', 'web.dark_mode_variables'),
|
||||
('before', 'odex30_web/static/src/scss/bootstrap_overridden.scss', 'odex30_web/static/src/scss/bootstrap_overridden.dark.scss'),
|
||||
('after', 'web/static/lib/bootstrap/scss/_functions.scss', 'odex30_web/static/src/scss/bs_functions_overridden.dark.scss'),
|
||||
],
|
||||
'web.assets_web': [
|
||||
('replace', 'web/static/src/main.js', 'odex30_web/static/src/main.js'),
|
||||
],
|
||||
"web.dark_mode_variables": [
|
||||
('before', 'odex30_web/static/src/scss/primary_variables.scss', 'odex30_web/static/src/scss/primary_variables.dark.scss'),
|
||||
('before', 'odex30_web/static/src/**/*.variables.scss', 'odex30_web/static/src/**/*.variables.dark.scss'),
|
||||
('before', 'odex30_web/static/src/scss/secondary_variables.scss', 'odex30_web/static/src/scss/secondary_variables.dark.scss'),
|
||||
],
|
||||
"web.assets_web_dark": [
|
||||
('include', 'web.dark_mode_variables'),
|
||||
('before', 'odex30_web/static/src/scss/bootstrap_overridden.scss', 'odex30_web/static/src/scss/bootstrap_overridden.dark.scss'),
|
||||
('after', 'web/static/lib/bootstrap/scss/_functions.scss', 'odex30_web/static/src/scss/bs_functions_overridden.dark.scss'),
|
||||
'odex30_web/static/src/**/*.dark.scss',
|
||||
],
|
||||
'web.tests_assets': [
|
||||
'odex30_web/static/tests/*.js',
|
||||
],
|
||||
"web.assets_tests": [
|
||||
"odex30_web/static/tests/tours/**/*.js",
|
||||
],
|
||||
'web.assets_unit_tests': [
|
||||
'odex30_web/static/tests/**/*.test.js',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'odex30_web/static/tests/views/**/*.js',
|
||||
'odex30_web/static/tests/webclient/**/*.js',
|
||||
('remove', 'odex30_web/static/tests/**/*.test.js'),
|
||||
],
|
||||
},
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
|
|
@ -0,0 +1,457 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * odex30_web
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-01 22:17+0000\n"
|
||||
"PO-Revision-Date: 2026-01-01 22:17+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.js:0
|
||||
msgid "%s days"
|
||||
msgstr "%s أيام"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/settings_form_view/res_config_edition.xml:0
|
||||
msgid "(Enterprise Edition)"
|
||||
msgstr "(النسخة الشركاتية)"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.js:0
|
||||
msgid "1 month"
|
||||
msgstr "شهر واحد"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/views/list/list_renderer_desktop.xml:0
|
||||
msgid "Add Custom Field"
|
||||
msgstr "إضافة حقل مخصص"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/views/kanban/kanban_header_patch.js:0
|
||||
msgid "Automations"
|
||||
msgstr "الأتمتة"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Build new apps from scratch"
|
||||
msgstr "بناء تطبيقات جديدة من الصفر"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Build new reports"
|
||||
msgstr "بناء تقارير جديدة"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/share_url/burger_menu.xml:0
|
||||
msgid "Close menu"
|
||||
msgstr "إغلاق القائمة"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid ""
|
||||
"Contact your sales representative to help you to unlink your previous "
|
||||
"database"
|
||||
msgstr ""
|
||||
"تواصل مع مندوب المبيعات لمساعدتك في فصل قاعدة البيانات السابقة"
|
||||
""
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Create automation rules"
|
||||
msgstr "إنشاء قواعد الأتمتة"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Customize Reports"
|
||||
msgstr "تخصيص التقارير"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Customize any screen"
|
||||
msgstr "تخصيص أي شاشة"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/color_scheme/color_scheme_menu_items.js:0
|
||||
msgid "Dark Mode"
|
||||
msgstr "الوضع الداكن"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/settings_form_view/res_config_edition.xml:0
|
||||
msgid "Database expiration:"
|
||||
msgstr "انتهاء صلاحية قاعدة البيانات:"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Define webhooks"
|
||||
msgstr "تعريف Webhooks"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Discard"
|
||||
msgstr "إلغاء"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Dismiss"
|
||||
msgstr "إغلاق"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Error reason:"
|
||||
msgstr "سبب الخطأ:"
|
||||
|
||||
#. module: odex30_web
|
||||
#: model:ir.model,name:odex30_web.model_ir_http
|
||||
msgid "HTTP Routing"
|
||||
msgstr "مسار HTTP"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/home_menu_service.js:0
|
||||
msgid "Home"
|
||||
msgstr "الرئيسية"
|
||||
|
||||
#. module: odex30_web
|
||||
#: model:ir.model.fields,field_description:odex30_web.field_res_users_settings__homemenu_config
|
||||
msgid "Home Menu Configuration"
|
||||
msgstr "إعدادات قائمة الرئيسية"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/navbar/navbar.js:0
|
||||
msgid "Home menu"
|
||||
msgstr "قائمة الرئيسية"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "I paid, please recheck!"
|
||||
msgstr "لقد دفعت، يرجى إعادة التحقق!"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Install Odoo Studio and its dependencies"
|
||||
msgstr "تثبيت Odoo Studio وتبعياته"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Learn More"
|
||||
msgstr "تعرف على المزيد"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Log in as an administrator to correct the issue."
|
||||
msgstr "سجل الدخول كمدير لإصلاح المشكلة."
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/home_menu.xml:0
|
||||
msgid "No result"
|
||||
msgstr "لا توجد نتائج"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/settings_form_view/res_config_edition.xml:0
|
||||
msgid "Odoo"
|
||||
msgstr "أودو"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/settings_form_view/res_config_edition.xml:0
|
||||
msgid "Odoo Enterprise Edition License V1.0"
|
||||
msgstr "رخصة Odoo Enterprise Edition V1.0"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/views/list/list_renderer_desktop.js:0
|
||||
msgid "Odoo Studio - Add new fields to any view"
|
||||
msgstr "Odoo Studio - إضافة حقول جديدة إلى أي عرض"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/views/kanban/kanban_header_patch.js:0
|
||||
msgid "Odoo Studio - Customize workflows in minutes"
|
||||
msgstr "Odoo Studio - تخصيص سير العمل في دقائق"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Odoo Support"
|
||||
msgstr "دعم أودو"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Paste code here"
|
||||
msgstr "الصق الكود هنا"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/navbar/navbar.js:0
|
||||
msgid "Previous view"
|
||||
msgstr "العرض السابق"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.js:0
|
||||
msgid "Register"
|
||||
msgstr "تسجيل"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Register your subscription"
|
||||
msgstr "تسجيل اشتراكك"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Renew now"
|
||||
msgstr "تجديد الآن"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.js:0
|
||||
msgid "Retry"
|
||||
msgstr "إعادة المحاولة"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Send an email"
|
||||
msgstr "إرسال بريد إلكتروني"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Sending the instructions by email ..."
|
||||
msgstr "إرسال التعليمات بالبريد الإلكتروني..."
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/share_url/share_url.js:0
|
||||
msgid "Share"
|
||||
msgstr "مشاركة"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/share_url/burger_menu.xml:0
|
||||
msgid "Share URL"
|
||||
msgstr "مشاركة الرابط"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid ""
|
||||
"Something went wrong while registering your database. You can try again or "
|
||||
"contact"
|
||||
msgstr ""
|
||||
"حدث خطأ أثناء تسجيل قاعدة البيانات. يمكنك المحاولة مرة أخرى أو التواصل"
|
||||
" مع"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Start using Odoo Studio"
|
||||
msgstr "ابدأ استخدام Odoo Studio"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Subscription Code:"
|
||||
msgstr "كود الاشتراك:"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/home_menu.xml:0
|
||||
msgid "TIP"
|
||||
msgstr "نصيحة"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid ""
|
||||
"Thank you, your registration was successful! Your database is valid until"
|
||||
msgstr ""
|
||||
"شكراً، تم تسجيلك بنجاح! قاعدة بياناتك صالحة حتى"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/enterprise_subscription_service.js:0
|
||||
msgid ""
|
||||
"Thank you, your registration was successful! Your database is valid until "
|
||||
"%s."
|
||||
msgstr ""
|
||||
"شكراً، تم تسجيلك بنجاح! قاعدة بياناتك صالحة حتى %s."
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid ""
|
||||
"The instructions to unlink your subscription from the previous database(s) "
|
||||
"have been sent"
|
||||
msgstr ""
|
||||
"تم إرسال التعليمات لفصل اشتراكك من قواعد البيانات السابقة"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.js:0
|
||||
msgid "This database has expired. "
|
||||
msgstr "انتهت صلاحية هذه قاعدة البيانات. "
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.js:0
|
||||
msgid "This database will expire in %s. "
|
||||
msgstr "ستنتهي صلاحية قاعدة البيانات هذه في %s. "
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.js:0
|
||||
msgid "This demo database will expire in %s. "
|
||||
msgstr "ستنتهي صلاحية قاعدة بيانات العرض التجريبي في %s. "
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Unable to send the instructions by email, please contact the"
|
||||
msgstr "تعذر إرسال التعليمات بالبريد الإلكتروني، يرجى التواصل مع"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Unleash the power of Odoo Studio:"
|
||||
msgstr "استخدم قوة Odoo Studio:"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Upgrade your subscription"
|
||||
msgstr "ترقية اشتراكك"
|
||||
|
||||
#. module: odex30_web
|
||||
#: model:ir.model,name:odex30_web.model_res_users_settings
|
||||
msgid "User Settings"
|
||||
msgstr "إعدادات المستخدم"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "Want to tailor-make your Odoo?"
|
||||
msgstr "هل تريد تخصيص أودو حسب احتياجاتك؟"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid ""
|
||||
"You have more users or more apps installed than your subscription allows."
|
||||
msgstr ""
|
||||
"لديك مستخدمون أو تطبيقات مثبتة أكثر مما يسمح به اشتراكك."
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid ""
|
||||
"You will be able to register your database once you have installed your "
|
||||
"first app."
|
||||
msgstr ""
|
||||
"يمكنك تسجيل قاعدة البيانات بعد تثبيت أول تطبيق."
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Your subscription code"
|
||||
msgstr "كود اشتراكك"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.js:0
|
||||
msgid ""
|
||||
"Your subscription expired %s days ago. This database will be blocked soon. "
|
||||
msgstr ""
|
||||
"انتهى اشتراكك منذ %s أيام. سيتم حظر قاعدة البيانات قريباً. "
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.js:0
|
||||
msgid "Your subscription expires in %s days. "
|
||||
msgstr "ينتهي اشتراكك بعد %s أيام. "
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Your subscription is already linked to a database."
|
||||
msgstr "اشتراكك مرتبط بقاعدة بيانات بالفعل."
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "Your subscription was updated and is valid until"
|
||||
msgstr "تم تحديث اشتراكك وهو صالح حتى"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/promote_studio_dialog/promote_studio_dialog.xml:0
|
||||
msgid "and more!"
|
||||
msgstr "وأكثر!"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "buy a subscription"
|
||||
msgstr "شراء اشتراك"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "buy a subscription."
|
||||
msgstr "شراء اشتراك."
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "or"
|
||||
msgstr "أو"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/expiration_panel.xml:0
|
||||
msgid "to the subscription owner to confirm the change, enter a new code or"
|
||||
msgstr ""
|
||||
"لصاحب الاشتراك لتأكيد التغيير، أدخل كود جديد أو"
|
||||
|
||||
#. module: odex30_web
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web/static/src/webclient/home_menu/home_menu.xml:0
|
||||
msgid "— open me anywhere with"
|
||||
msgstr ""
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import ir_http
|
||||
from . import res_users_settings
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
|
||||
import json
|
||||
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class Http(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@classmethod
|
||||
def _post_logout(cls):
|
||||
super()._post_logout()
|
||||
request.future_response.set_cookie('color_scheme', max_age=0)
|
||||
|
||||
def webclient_rendering_context(self):
|
||||
return {
|
||||
'session_info': self.session_info(),
|
||||
}
|
||||
|
||||
def session_info(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
if self.env.user.has_group('base.group_system'):
|
||||
warn_enterprise = 'admin'
|
||||
elif self.env.user._is_internal():
|
||||
warn_enterprise = 'user'
|
||||
else:
|
||||
warn_enterprise = False
|
||||
|
||||
result = super(Http, self).session_info()
|
||||
result['support_url'] = "https://www.odoo.com/help"
|
||||
if warn_enterprise:
|
||||
result['warning'] = warn_enterprise
|
||||
result['expiration_date'] = ICP.get_param('database.expiration_date')
|
||||
result['expiration_reason'] = ICP.get_param('database.expiration_reason')
|
||||
return result
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResUsersSettings(models.Model):
|
||||
_inherit = 'res.users.settings'
|
||||
|
||||
homemenu_config = fields.Json(string="Home Menu Configuration", readonly=True)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1,35 @@
|
|||
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.51001 1080H76.35L1153.55 0H3.51001V1080Z" fill="url(#o_app_switcher_gradient_01)"/>
|
||||
<path d="M76.35 1080H842.98L1920 0.18V0H1153.55L76.35 1080Z" fill="url(#o_app_switcher_gradient_02)"/>
|
||||
<path d="M1920 0.180176L842.98 1080H1063.11L1920 220.88V0.180176Z" fill="url(#o_app_switcher_gradient_03)"/>
|
||||
<path d="M1920 1080V220.88L1063.11 1080H1920Z" fill="url(#o_app_switcher_gradient_04)"/>
|
||||
<rect width="1920" height="1080" fill="url(#o_app_switcher_gradient_05)" fill-opacity="0.25"/>
|
||||
<rect width="1920" height="1080" fill="#E9E6F9" fill-opacity="0.25"/>
|
||||
<defs>
|
||||
<linearGradient id="o_app_switcher_gradient_01" x1="-222.43" y1="727.19" x2="904.26" y2="-76.67" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.1" stop-color="white"/>
|
||||
<stop offset="0.36" stop-color="#FEFEFE"/>
|
||||
<stop offset="0.68" stop-color="#EAE7F9"/>
|
||||
<stop offset="1" stop-color="#E4E9F7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="o_app_switcher_gradient_02" x1="407.23" y1="1021.82" x2="1848.47" y2="-153.08" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.32" stop-color="#FEFEFE"/>
|
||||
<stop offset="0.66" stop-color="#EAE7F9"/>
|
||||
<stop offset="1" stop-color="#E5E2F6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="o_app_switcher_gradient_03" x1="1142.33" y1="846.57" x2="1951.83" y2="136.16" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.15" stop-color="white"/>
|
||||
<stop offset="0.51" stop-color="#F7F0FD"/>
|
||||
<stop offset="0.85" stop-color="#F0E7F9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="o_app_switcher_gradient_04" x1="1409.74" y1="1071" x2="2070.98" y2="526.01" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.45" stop-color="white"/>
|
||||
<stop offset="0.88" stop-color="#F7F0FD"/>
|
||||
<stop offset="1" stop-color="#ECE5F8"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="o_app_switcher_gradient_05" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(960 540) rotate(90) scale(540 960)">
|
||||
<stop stop-color="#9996A9" stop-opacity="0.53"/>
|
||||
<stop offset="1" stop-color="#7A768F"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
declare module "@odoo/owl" {
|
||||
export * from "@odoo/owl/dist/types/owl"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Overrides the existing classes to fit the text-color of
|
||||
// tag_list.dark.scss
|
||||
@for $size from 2 through length($o-colors) {
|
||||
.o_colorlist_item_color_#{$size - 1} {
|
||||
--background-color: #{adjust-color(nth($o-colors, $size), $lightness: -5%, $saturation: -15%)};
|
||||
--color: #{mix(nth($o-colors, $size), $o-view-background-color, 15%)};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// = Dropdowns
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o-dropdown {
|
||||
--border-color: #{$dropdown-border-color};
|
||||
--o-input-border-color: #{$dropdown-border-color};
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
.o_notebook {
|
||||
--notebook-link-border-color: #{$border-color};
|
||||
--notebook-link-border-color-hover: #{$border-color};
|
||||
--notebook-link-border-color-active-accent: #{$o-brand-odoo};
|
||||
|
||||
.modal & {
|
||||
--notebook-padding-x: #{$modal-inner-padding};
|
||||
--notebook-margin-x: -#{$modal-inner-padding};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// = Popovers
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_popover {
|
||||
--border-color: #{$popover-border-color};
|
||||
|
||||
.table {
|
||||
--table-bg: #{$popover-bg};
|
||||
}
|
||||
|
||||
.o_input {
|
||||
--o-input-border-color: #{$popover-border-color};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// = Search Panel
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_search_panel_section {
|
||||
.o_popover > & .list-group {
|
||||
--#{$prefix}list-group-active-bg: #{$o-gray-400};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.o_tag {
|
||||
@for $size from 1 through length($o-colors) {
|
||||
&.o_tag_color_#{$size - 1} {
|
||||
--background-color: #{mix(nth($o-colors, $size), $o-view-background-color, 15%)};
|
||||
--color: #{adjust-color(nth($o-colors, $size), $lightness: 5%, $saturation: -15%)};
|
||||
|
||||
&::after {
|
||||
--background-color: var(--background-color);
|
||||
--color: var(--color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { startWebClient } from "@web/start";
|
||||
import { WebClientOdex } from "./webclient/webclient";
|
||||
|
||||
/**
|
||||
* This file starts the webclient. In the manifest, it replaces
|
||||
* the community main.js to load a different webclient class
|
||||
* (WebClientOdex instead of WebClient)
|
||||
*/
|
||||
startWebClient(WebClientOdex);
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
///
|
||||
/// This file is a copy of the bootstrap _variables.scss file where all the
|
||||
/// left-untouched variables definition have been removed.
|
||||
///
|
||||
|
||||
|
||||
// == Color system
|
||||
$danger: $o-danger !default;
|
||||
|
||||
$min-contrast-ratio: 4.5 !default;
|
||||
|
||||
$info-text-emphasis: shift-color($o-info, 90%) !default;
|
||||
$info-bg-subtle: shift-color($o-info, -65%) !default;
|
||||
$info-border-subtle: shift-color($o-info, 0%) !default;
|
||||
|
||||
// == Characters which are escaped by the escape-svg function
|
||||
|
||||
|
||||
// == Options
|
||||
|
||||
|
||||
// == Prefix for :root CSS variables
|
||||
|
||||
|
||||
// == Gradient
|
||||
|
||||
|
||||
// == Spacing
|
||||
|
||||
|
||||
// == Position
|
||||
|
||||
|
||||
// == Body
|
||||
|
||||
// == Links
|
||||
|
||||
$link-shade-percentage: 15% !default;
|
||||
$link-hover-color: shift-color($o-action, 30%) !default;
|
||||
|
||||
// == Paragraphs
|
||||
|
||||
|
||||
// == Grid breakpoints
|
||||
|
||||
|
||||
// == Grid containers
|
||||
|
||||
|
||||
// == Grid columns
|
||||
|
||||
|
||||
// == Components
|
||||
$box-shadow: 0 .5rem 1rem rgba($o-white, .3) !default;
|
||||
$box-shadow-sm: 0 .125rem .25rem rgba($o-white, .15) !default;
|
||||
$box-shadow-lg: 0 1rem 3rem rgba($o-white, .3) !default;
|
||||
$box-shadow-inset: inset 0 1px 2px rgba($o-white, .15) !default;
|
||||
|
||||
$component-active-bg: $o-gray-300 !default;
|
||||
|
||||
|
||||
// == Typography
|
||||
$mark-bg: #ffdebc !default;
|
||||
$mark-color: shift-color($mark-bg, -75%) !default;
|
||||
|
||||
// == Tables
|
||||
$table-bg: $o-view-background-color !default;
|
||||
$table-border-color: $o-gray-300 !default;
|
||||
$table-group-separator-color: $o-gray-300 !default;
|
||||
$table-bg-scale: -70% !default;
|
||||
$table-striped-bg-factor: .02 !default;
|
||||
$table-hover-bg-factor: .1 !default;
|
||||
$table-active-bg-factor: .1 !default;
|
||||
|
||||
// == Buttons + Forms
|
||||
|
||||
|
||||
// == Buttons
|
||||
|
||||
|
||||
// == Forms
|
||||
$input-border-color: $o-gray-300 !default;
|
||||
$input-placeholder-color: mix($o-gray-500, $o-gray-600) !default;
|
||||
$input-focus-bg: inherit !default;
|
||||
$form-range-thumb-active-bg: lighten($o-brand-primary, 10%);
|
||||
$form-range-track-bg: $o-gray-300 !default;
|
||||
|
||||
$form-switch-color: rgba($o-black, .5) !default;
|
||||
$form-switch-focus-color: $o-black !default;
|
||||
$form-switch-checked-color: $o-view-background-color !default;
|
||||
|
||||
// == Form validation
|
||||
|
||||
|
||||
// == Z-index master list
|
||||
|
||||
|
||||
// == Navs
|
||||
|
||||
|
||||
// == Navbar
|
||||
|
||||
|
||||
// == Dropdowns
|
||||
$dropdown-bg: $o-gray-300 !default;
|
||||
$dropdown-border-color: $o-gray-400 !default;
|
||||
$dropdown-header-color: $o-gray-700 !default;
|
||||
|
||||
|
||||
// == Pagination
|
||||
|
||||
|
||||
// == Placeholders
|
||||
|
||||
|
||||
// == Cards
|
||||
$card-cap-bg: $o-view-background-color !default;
|
||||
|
||||
// == Accordion
|
||||
|
||||
|
||||
// == Tooltips
|
||||
$tooltip-color: $o-gray-800 !default;
|
||||
$tooltip-bg: $o-gray-300 !default;
|
||||
|
||||
|
||||
// == Form tooltips must come after regular tooltips
|
||||
|
||||
|
||||
// == Popovers
|
||||
$popover-bg: $o-gray-300 !default;
|
||||
$popover-border-color: $o-gray-400 !default;
|
||||
|
||||
// == Toasts
|
||||
|
||||
|
||||
// == Badges
|
||||
|
||||
|
||||
// == Modals
|
||||
|
||||
|
||||
// == Alerts
|
||||
$alert-bg-scale: -65% !default;
|
||||
$alert-border-scale: 0% !default;
|
||||
$alert-color-scale: 90% !default;
|
||||
|
||||
// == Progress bars
|
||||
|
||||
|
||||
// == List group
|
||||
$list-group-bg: $o-view-background-color !default;
|
||||
|
||||
|
||||
// == Image thumbnails
|
||||
|
||||
|
||||
// == Figures
|
||||
|
||||
|
||||
// == Breadcrumbs
|
||||
|
||||
|
||||
// == Carousel
|
||||
|
||||
|
||||
// == Spinners
|
||||
|
||||
|
||||
// == Close
|
||||
|
||||
|
||||
// == Offcanvas
|
||||
|
||||
|
||||
// == Code
|
||||
|
||||
// == Keyboard Input
|
||||
$kbd-color: $o-gray-200 !default;
|
||||
$kbd-bg: $o-gray-900 !default;
|
||||
$kbd-box-shadow: 0px 1px 1px rgba($o-white, 0.2), inset 0px -1px 1px 1px rgba($o-gray-800, 0.8), inset 0px 2px 0px 0px rgba($o-black, 0.8) !default;
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
///
|
||||
/// This file is a copy of the bootstrap _variables.scss file where all the
|
||||
/// left-untouched variables definition have been removed.
|
||||
///
|
||||
|
||||
//
|
||||
// Color system
|
||||
//
|
||||
|
||||
$light: $o-white !default;
|
||||
$dark: $o-gray-900 !default;
|
||||
$warning: #e99d00 !default;
|
||||
$danger: #d44c59 !default;
|
||||
|
||||
|
||||
// Options
|
||||
|
||||
// Enable predefined decorative box-shadow styles on various components.
|
||||
// Does not affect box-shadows used for focus states.
|
||||
|
||||
$enable-shadows: true !default;
|
||||
|
||||
// Components
|
||||
//
|
||||
// Define common padding and border radius sizes and more.
|
||||
|
||||
$component-active-color: unset !default;
|
||||
$component-active-bg: mix($o-enterprise-action-color, $o-white, 10%) !default;
|
||||
|
||||
$nav-tabs-border-radius: 0 !default;
|
||||
$nav-pills-border-radius: 0 !default;
|
||||
$card-border-radius: 0 !default;
|
||||
$accordion-border-radius: 0 !default;
|
||||
$toast-border-radius: 0 !default;
|
||||
$badge-border-radius: 0 !default;
|
||||
$progress-border-radius: 0 !default;
|
||||
$list-group-border-radius: 0 !default;
|
||||
$thumbnail-border-radius: 0 !default;
|
||||
$form-check-input-border-radius: 0 !default;
|
||||
|
||||
// Typography
|
||||
//
|
||||
// Font, line-height, and color for body text, headings, and more.
|
||||
|
||||
$h1-font-size: $o-font-size-base * 2.4 !default;
|
||||
$h2-font-size: $o-font-size-base * 1.5 !default;
|
||||
$h3-font-size: $o-font-size-base * 1.3 !default;
|
||||
$h4-font-size: $o-font-size-base * 1.2 !default;
|
||||
$h5-font-size: $o-font-size-base * 1.1 !default;
|
||||
|
||||
// Buttons
|
||||
//
|
||||
// For each of Bootstrap's buttons, define text, background, and border color.
|
||||
|
||||
$btn-transition: none !default;
|
||||
|
||||
$btn-box-shadow: 0 !default;
|
||||
$btn-active-box-shadow: 0 !default;
|
||||
$btn-focus-box-shadow: 0 !default;
|
||||
|
||||
// Dropdowns
|
||||
//
|
||||
// Dropdown menu container and contents.
|
||||
|
||||
$dropdown-box-shadow: 0 .3rem 1rem rgba(#000, .1) !default;
|
||||
|
||||
// Forms
|
||||
//
|
||||
|
||||
$input-border-color: $o-gray-200 !default;
|
||||
$input-box-shadow: 0 !default;
|
||||
|
||||
$input-focus-bg: $o-white !default;
|
||||
$input-focus-box-shadow: 0 !default;
|
||||
$input-focus-border-color: mix($o-enterprise-action-color, $o-gray-200) !default;
|
||||
|
||||
$form-check-input-checked-color: $o-white !default;
|
||||
$form-check-input-checked-border-color: $o-enterprise-action-color !default;
|
||||
$form-check-input-checked-bg-color: $o-enterprise-action-color !default;
|
||||
|
||||
$form-select-focus-box-shadow: 0 !default;
|
||||
|
||||
$form-range-track-box-shadow: 0 !default;
|
||||
|
||||
// Z-index master list
|
||||
//
|
||||
// Change the z-index of the modal-backdrop elements to be equal to the
|
||||
// modal elements' ones. Bootstrap does not support multi-modals, and without
|
||||
// this rule all the modal-backdrops are below all the opened modals.
|
||||
// Indeed, bootstrap forces them to a lower z-index as the modal-backdrop
|
||||
// element (unique in their supported cases) might be put after the modal
|
||||
// element (if the modal is already in the DOM, hidden, then opened). This
|
||||
// cannot happen in odoo though as modals are not hidden but removed from
|
||||
// the DOM and are always put at the end of the body when opened.
|
||||
//
|
||||
// TODO the following code was disabled because it is saas-incompatible
|
||||
//
|
||||
// $zindex-modal-backdrop: $zindex-modal;
|
||||
|
||||
// Navs
|
||||
$nav-link-color: $o-main-text-color !default;
|
||||
$nav-tabs-link-active-color: $o-main-headings-color !default;
|
||||
$nav-tabs-link-active-bg: transparent !default;
|
||||
|
||||
|
||||
// Badges
|
||||
|
||||
$badge-border-radius: $o-border-radius !default;
|
||||
$badge-font-weight: normal !default;
|
||||
|
||||
// Alerts
|
||||
//
|
||||
// Define alert colors, border radius, and padding.
|
||||
|
||||
$alert-border-width: 0 !default;
|
||||
|
||||
// Progress bars
|
||||
|
||||
$progress-box-shadow: 0 !default;
|
||||
|
||||
// List group
|
||||
|
||||
$list-group-active-color: $o-enterprise-action-color !default;
|
||||
$list-group-active-bg: $component-active-bg !default;
|
||||
$list-group-active-border-color: $o-enterprise-action-color !default;
|
||||
|
||||
|
||||
// Image thumbnails
|
||||
|
||||
$thumbnail-box-shadow: 0 !default;
|
||||
|
||||
|
||||
// Breadcrumbs
|
||||
|
||||
$breadcrumb-active-color: $o-main-text-color !default;
|
||||
$breadcrumb-divider-color: $o-main-color-muted !default;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
///
|
||||
/// This file is a copy of the bootstrap _functions.scss file where all the
|
||||
/// left-untouched function definition have been removed.
|
||||
///
|
||||
|
||||
// Tint a color: mix a color with black
|
||||
@function tint-color($color, $weight) {
|
||||
@return mix(#000, $color, $weight);
|
||||
}
|
||||
|
||||
// Shade a color: mix a color with white
|
||||
@function shade-color($color, $weight) {
|
||||
@return mix(#FFF, $color, $weight);
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
///
|
||||
/// Odoo Dark-Mode
|
||||
///
|
||||
///
|
||||
|
||||
// = Colors
|
||||
// ============================================================================
|
||||
|
||||
$o-white: #000 !default;
|
||||
$o-black: #FFF !default;
|
||||
|
||||
$o-gray-100: #1B1D26 !default;
|
||||
$o-gray-200: #262A36 !default;
|
||||
$o-gray-300: #3C3E4B !default;
|
||||
$o-gray-400: #5A5E6B !default;
|
||||
$o-gray-500: #6B707F !default;
|
||||
$o-gray-600: #7E8392 !default;
|
||||
$o-gray-700: #B1B3BC !default;
|
||||
$o-gray-800: #D1D1D1 !default;
|
||||
$o-gray-900: #E4E4E4 !default;
|
||||
|
||||
$o-enterprise-color: #6b3e66 !default;
|
||||
$o-brand-primary: $o-enterprise-color !default;
|
||||
$o-enterprise-action-color: #02c7b5 !default;
|
||||
|
||||
$o-success: #1dc959 !default;
|
||||
$o-info: #6AB5FB !default;
|
||||
$o-warning: #FBB56A !default;
|
||||
$o-danger: #b83232 !default;
|
||||
$o-action: $o-enterprise-action-color !default;
|
||||
$light: $o-gray-300 !default;
|
||||
$dark: $o-gray-700 !default;
|
||||
|
||||
|
||||
// = Text
|
||||
// ============================================================================
|
||||
|
||||
$o-main-text-color: $o-gray-800 !default;
|
||||
$o-main-link-color: $o-action !default;
|
||||
$o-enterprise-color: $o-brand-odoo !default;
|
||||
$o-main-favorite-color: #ffd532 !default;
|
||||
$o-main-code-color: #c58bc8 !default;
|
||||
|
||||
// = Fine-tune contextual text colors.
|
||||
$o-theme-text-colors: (
|
||||
"primary": #b972a6,
|
||||
"success": #1dc959,
|
||||
"info": #6AB5FB,
|
||||
"warning": #FBB56A,
|
||||
"danger": #ff5757,
|
||||
) !default;
|
||||
|
||||
|
||||
// = Webclient
|
||||
// ============================================================================
|
||||
$o-webclient-color-scheme: dark !default;
|
||||
$o-webclient-background-color: $o-gray-100 !default;
|
||||
$o-view-background-color: $o-gray-200 !default;
|
||||
|
||||
// = Inputs
|
||||
$o-input-border-required: $o-black !default;
|
||||
|
||||
// = Components
|
||||
// ============================================================================
|
||||
$o-component-active-bg: mix($o-action, $o-gray-300, 10%) !default;
|
||||
$o-form-lightsecondary: $o-gray-300 !default;
|
||||
|
||||
// = List-group
|
||||
$o-list-group-active-color: $o-gray-900 !default;
|
||||
$o-list-group-active-bg: rgba(saturate(adjust-hue($o-info, 15), 1.8), .5) !default;
|
||||
|
||||
// = Modal
|
||||
$modal-backdrop-bg: $o-white !default;
|
||||
|
||||
// = Buttons
|
||||
$o-btns-bs-override: () !default;
|
||||
$o-btns-bs-override: map-merge((
|
||||
"primary": (
|
||||
background: $o-brand-primary,
|
||||
border: $o-brand-primary,
|
||||
color: $o-black,
|
||||
|
||||
hover-background: lighten($o-brand-primary, 5%),
|
||||
hover-border: lighten($o-brand-primary, 5%),
|
||||
hover-color: $o-black,
|
||||
|
||||
active-background: lighten($o-brand-primary, 10%),
|
||||
active-border: lighten($o-brand-primary, 10%),
|
||||
active-color: $o-black,
|
||||
),
|
||||
|
||||
"secondary": (
|
||||
background: $o-gray-300,
|
||||
border: $o-gray-300,
|
||||
color: $o-gray-900,
|
||||
|
||||
hover-background: $o-gray-400,
|
||||
hover-border: $o-gray-400,
|
||||
hover-color: $o-gray-900,
|
||||
|
||||
active-background: mix($o-action, $o-gray-100, 15%),
|
||||
active-border: lighten($o-action, 10%),
|
||||
active-color: $o-black,
|
||||
),
|
||||
|
||||
"light": (
|
||||
background: $o-gray-200,
|
||||
border: $o-gray-200,
|
||||
color: $o-gray-800,
|
||||
|
||||
hover-background: $o-gray-300,
|
||||
hover-border: $o-gray-300,
|
||||
hover-color: $o-gray-900,
|
||||
|
||||
active-background: mix($o-action, $o-gray-100, 15%),
|
||||
active-border: darken($o-action, 10%),
|
||||
active-color: $o-black,
|
||||
),
|
||||
|
||||
"danger": (
|
||||
background: $o-danger,
|
||||
border: $o-danger,
|
||||
color: $o-black,
|
||||
|
||||
hover-background: lighten($o-danger, 5%),
|
||||
hover-border: lighten($o-danger, 5%),
|
||||
hover-color: $o-black,
|
||||
|
||||
active-background: lighten($o-danger, 10%),
|
||||
active-border: lighten($o-danger, 10%),
|
||||
active-color: $o-black,
|
||||
),
|
||||
), $o-btns-bs-override);
|
||||
|
||||
|
||||
$o-btns-bs-outline-override: () !default;
|
||||
$o-btns-bs-outline-override: map-merge((
|
||||
|
||||
"primary": (
|
||||
background: transparent,
|
||||
border: map-get($o-theme-text-colors, 'primary'),
|
||||
color: map-get($o-theme-text-colors, 'primary'),
|
||||
|
||||
hover-background: lighten($o-brand-primary, 5%),
|
||||
hover-border: lighten($o-brand-primary, 5%),
|
||||
hover-color: $o-black,
|
||||
|
||||
active-background: lighten($o-brand-primary, 10%),
|
||||
active-border: lighten($o-brand-primary, 10%),
|
||||
active-color: $o-black,
|
||||
),
|
||||
"secondary": (
|
||||
background: transparent,
|
||||
border: $o-gray-300,
|
||||
color: $o-gray-700,
|
||||
|
||||
hover-background: $o-gray-300,
|
||||
hover-border: $o-gray-300,
|
||||
hover-color: $o-black,
|
||||
|
||||
active-background: mix($o-action, $o-gray-100, 15%),
|
||||
active-border: lighten($o-action, 10%),
|
||||
active-color: $o-black,
|
||||
),
|
||||
|
||||
"light": (
|
||||
background: transparent,
|
||||
border: $o-gray-300,
|
||||
color: $o-black,
|
||||
|
||||
hover-background: $o-gray-300,
|
||||
hover-border: $o-gray-300,
|
||||
hover-color: $o-gray-900,
|
||||
|
||||
active-background: mix($o-action, $o-gray-100, 15%),
|
||||
active-border: lighten($o-action, 10%),
|
||||
active-color: $o-black,
|
||||
),
|
||||
|
||||
"danger": (
|
||||
background: transparent,
|
||||
border: $o-danger,
|
||||
color: $o-danger,
|
||||
|
||||
hover-background: lighten($o-danger, 5%),
|
||||
hover-border: lighten($o-danger, 5%),
|
||||
hover-color: $o-black,
|
||||
|
||||
active-background: lighten($o-danger, 10%),
|
||||
active-border: lighten($o-danger, 10%),
|
||||
active-color: $o-black,
|
||||
),
|
||||
), $o-btns-bs-outline-override);
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
///
|
||||
/// This file regroups the variables that style odoo components.
|
||||
/// They are available in every asset bundle.
|
||||
///
|
||||
|
||||
// Colors
|
||||
$o-white: #FFF !default;
|
||||
$o-black: #000 !default;
|
||||
|
||||
$o-gray-100: #F9FAFB !default;
|
||||
$o-gray-200: #e7e9ed !default;
|
||||
$o-gray-300: #d8dadd !default;
|
||||
$o-gray-400: #9a9ca5 !default;
|
||||
$o-gray-500: #7c7f89 !default;
|
||||
$o-gray-600: #5f636f !default;
|
||||
$o-gray-700: #374151 !default;
|
||||
$o-gray-800: #1F2937 !default;
|
||||
$o-gray-900: #111827 !default;
|
||||
|
||||
$o-enterprise-color: #714B67 !default;
|
||||
$o-enterprise-action-color: #017e84 !default;
|
||||
|
||||
$o-opacity-disabled: .5 !default;
|
||||
$o-opacity-muted: .76 !default;
|
||||
|
||||
$o-brand-odoo: $o-enterprise-color !default;
|
||||
$o-brand-primary: $o-brand-odoo !default;
|
||||
$o-brand-secondary: #8f8f8f !default;
|
||||
$o-brand-lightsecondary: $o-gray-100 !default;
|
||||
|
||||
$o-action: $o-enterprise-action-color !default;
|
||||
$o-main-text-color: $o-gray-700 !default;
|
||||
$o-main-link-color: $o-enterprise-action-color !default;
|
||||
$o-main-color-muted: rgba($o-main-text-color, $o-opacity-muted) !default;
|
||||
|
||||
// Components
|
||||
$o-component-active-color: $o-gray-900 !default;
|
||||
$o-component-active-bg: mix($o-action, $o-white, 10%) !default;
|
||||
$o-component-active-border: $o-action !default;
|
||||
|
||||
$o-list-group-header-color: $o-gray-900 !default;
|
||||
$o-list-footer-color: $o-gray-900 !default;
|
||||
$o-list-footer-bg-color: transparent !default;
|
||||
$o-list-footer-font-weight: 500 !default;
|
||||
|
||||
$o-form-lightsecondary: $o-gray-200 !default;
|
||||
|
||||
// o-inputs
|
||||
$o-input-padding-y: 1px !default;
|
||||
$o-input-padding-x: 0 !default;
|
||||
|
||||
$o-input-border-required: $o-gray-900 !default;
|
||||
|
||||
// Badges
|
||||
$o-badge-min-width: 2.7ch !default !default;
|
||||
|
||||
// Buttons
|
||||
// Map of customized values for each button. If a button's design is defined
|
||||
// here, the relative values will take priority over default BS ones.
|
||||
// Notice: each map's entry is passed directly to the Bootstrap mixin, meaning
|
||||
// that all states must be defined, there can't be omissions.
|
||||
$o-btns-bs-override: () !default;
|
||||
$o-btns-bs-override: map-merge((
|
||||
"primary": (
|
||||
background: $o-brand-primary,
|
||||
border: $o-brand-primary,
|
||||
color: $o-white,
|
||||
|
||||
hover-background: darken($o-brand-primary, 10%),
|
||||
hover-border: darken($o-brand-primary, 10%),
|
||||
hover-color: $o-white,
|
||||
|
||||
active-background: mix($o-brand-primary, $o-white, 10%),
|
||||
active-border: $o-brand-primary,
|
||||
active-color:$o-brand-primary,
|
||||
),
|
||||
"secondary": (
|
||||
background: $o-gray-200,
|
||||
border: $o-gray-200,
|
||||
color: $o-gray-700,
|
||||
|
||||
hover-background: $o-gray-300,
|
||||
hover-border: $o-gray-300,
|
||||
hover-color: $o-gray-800,
|
||||
|
||||
active-background: $o-component-active-bg,
|
||||
active-border: $o-component-active-border,
|
||||
active-color: $o-component-active-color,
|
||||
),
|
||||
), $o-btns-bs-override);
|
||||
|
||||
$o-btns-bs-outline-override: () !default;
|
||||
$o-btns-bs-outline-override: map-merge((
|
||||
"secondary": (
|
||||
background: transparent,
|
||||
border: $o-gray-300,
|
||||
color: $o-gray-700,
|
||||
|
||||
hover-background: $o-gray-200,
|
||||
hover-border: $o-gray-300,
|
||||
hover-color: $o-gray-800,
|
||||
|
||||
active-background: mix($o-enterprise-action-color, $o-white, 10%),
|
||||
active-border: $o-enterprise-action-color,
|
||||
active-color: $o-gray-900,
|
||||
),
|
||||
), $o-btns-bs-outline-override);
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
$o-colors-original: lighten(#000, 46.7%), #e74e4e, #f4b660, #F7CD1F, #6cedeb, #8d5482,
|
||||
#f07b50, #2C8397, #475577, #dc0457, #30C381, #9365B8 !default;
|
||||
|
||||
$o-colors-secondary-original: #aa4b6b, #30C381, #97743a, #F7CD1F, #4285F4, #8E24AA,
|
||||
#D6145F, #173e43, #348F50, #AA3A38, #795548, #5e0231,
|
||||
#6be585, #999966, #e9d362, #b56969, #bdc3c7, #649173 !default;
|
||||
|
||||
$o-colors: ()!default;
|
||||
$o-colors-secondary: ()!default;
|
||||
|
||||
@each $-color in $o-colors-original {
|
||||
$-adjusted: saturate(mix($-color, $o-black, 50%), 60%);
|
||||
$o-colors: append($o-colors, $-adjusted);
|
||||
}
|
||||
|
||||
@each $-color in $o-colors-secondary-original {
|
||||
$-adjusted: saturate(mix($-color, $o-black, 50%), 80%);
|
||||
$o-colors-secondary: append($o-colors-secondary, $-adjusted);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// Needed for having no spacing between sheet and mail body in mass_mailing:
|
||||
// Different required cancel paddings between web and odex30_web
|
||||
$o-sheet-cancel-tpadding: $o-horizontal-padding !default;
|
||||
$o-sheet-cancel-bpadding: $o-horizontal-padding + $o-sheet-vpadding !default;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// = Search Bar
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_searchview_facet {
|
||||
--SearchBar-facet-background: #{$o-black};
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// = Mobile Search
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_mobile_search {
|
||||
--mobileSearch-bg: #{$o-gray-200};
|
||||
--mobileSearch__header-bg: #{$o-gray-100};
|
||||
}
|
||||
|
||||
.o_searchview {
|
||||
--SearchBar-background-color: #{$o-gray-100};
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// = Dashboard View
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_dashboard_view {
|
||||
--DashboardView-background-color: #{$o-gray-100};
|
||||
--DashboardView__controlPanel-background-color: transparent;
|
||||
--DashboardView__pieChart-background-color: transparent;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// = Image Field
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_field_image {
|
||||
--ImageField-background-color: #{$o-gray-900};
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.o_field_property_definition_type, .o_field_property_definition_type_menu {
|
||||
img {
|
||||
-webkit-filter: invert(100%);
|
||||
filter: invert(100%);
|
||||
}
|
||||
}
|
||||
.o_property_field_value {
|
||||
select {
|
||||
option {
|
||||
background-color: $border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.o-form-buttonbox {
|
||||
--o-stat-button-color: currentColor;
|
||||
--o-stat-text-color: #{o-text-color('primary')};
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// = Gantt View Variables
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
$gantt-highlight-today-border: rgba($o-warning, 0.5) !default;
|
||||
$gantt-highlight-today-bg: rgba($o-warning, 0.15)!default;
|
||||
$gantt-highlight-hover-row: rgba($o-brand-primary, .1) !default;
|
||||
$gantt-row-open-bg: $o-gray-100 !default;
|
||||
$gantt-unavailability-bg: $o-gray-200 !default;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// = Kanban Rendered
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_kanban_renderer {
|
||||
--KanbanGroup-grouped-bg: #{$o-view-background-color};
|
||||
--KanbanRecord__image-bg-color: #{$o-gray-900};
|
||||
--KanbanColumn__highlight-background: #{mix($o-action, $o-gray-100, 15%)};
|
||||
--KanbanColumn__highlight-border: #{$o-component-active-border};
|
||||
--Kanban-background: #{$gray-100};
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/* @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { KanbanHeader } from "@web/views/kanban/kanban_header";
|
||||
import { PromoteStudioAutomationDialog } from "@odex30_web/webclient/promote_studio_dialog/promote_studio_dialog";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
patch(KanbanHeader.prototype, {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get permissions() {
|
||||
const permissions = super.permissions;
|
||||
Object.defineProperty(permissions, "canEditAutomations", {
|
||||
get: () => user.isAdmin,
|
||||
configurable: true,
|
||||
});
|
||||
return permissions;
|
||||
},
|
||||
|
||||
async openAutomations() {
|
||||
if (typeof this._openAutomations === "function") {
|
||||
// this is the case if base_automation is installed
|
||||
return this._openAutomations();
|
||||
} else {
|
||||
this.env.services.dialog.add(PromoteStudioAutomationDialog, {
|
||||
title: _t("Odoo Studio - Customize workflows in minutes"),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
registry.category("kanban_header_config_items").add(
|
||||
"open_automations",
|
||||
{
|
||||
label: _t("Automations"),
|
||||
method: "openAutomations",
|
||||
isVisible: ({ permissions }) => permissions.canEditAutomations,
|
||||
class: "o_column_automations",
|
||||
},
|
||||
{ sequence: 25, force: true }
|
||||
);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
.o_kanban_view {
|
||||
|
||||
.o_column_quick_create .o_kanban_quick_create {
|
||||
input {
|
||||
&, &:focus, &:hover {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid map-get($grays, '600');
|
||||
}
|
||||
}
|
||||
.input-group-append, .input-group-prepend {
|
||||
border-left: 10px solid map-get($grays, '200');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// = ListRenderer
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_list_renderer {
|
||||
--ListRenderer-thead-border-end-color: transparent;
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { isMobileOS } from "@web/core/browser/feature_detection";
|
||||
import { user } from "@web/core/user";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { PromoteStudioDialog } from "@odex30_web/webclient/promote_studio_dialog/promote_studio_dialog";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { onWillDestroy, useState } from "@odoo/owl";
|
||||
|
||||
export const patchListRendererDesktop = () => ({
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.actionService = useService("action");
|
||||
const list = this.props.list;
|
||||
|
||||
const { actionId, actionType } = this.env.config || {};
|
||||
|
||||
// Start by determining if the current ListRenderer is in a context that would
|
||||
// allow the edition of the arch by studio.
|
||||
// It needs to be a full list view, in an action
|
||||
// (not a X2Many list, and not an "embedded" list in another component)
|
||||
// Also, there is not enough information when an action is in target new,
|
||||
// and this use case is fairly outside of the feature's scope
|
||||
const isPotentiallyEditable =
|
||||
!isMobileOS() &&
|
||||
!this.env.inDialog &&
|
||||
user.isSystem &&
|
||||
list === list.model.root &&
|
||||
actionId &&
|
||||
actionType === "ir.actions.act_window";
|
||||
this.studioEditable = useState({ value: isPotentiallyEditable });
|
||||
|
||||
if (isPotentiallyEditable) {
|
||||
const computeStudioEditable = (action) => {
|
||||
// Finalize the computation when the actionService is ready.
|
||||
// The following code is copied from studioService.
|
||||
if (!action.xml_id) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
action.res_model.indexOf("settings") > -1 &&
|
||||
action.res_model.indexOf("x_") !== 0
|
||||
) {
|
||||
return false; // settings views aren't editable; but x_settings is
|
||||
}
|
||||
if (action.res_model === "board.board") {
|
||||
return false; // dashboard isn't editable
|
||||
}
|
||||
if (action.view_mode === "qweb") {
|
||||
// Apparently there is a QWebView that allows to
|
||||
// implement ActWindow actions that are completely custom
|
||||
// but not editable by studio
|
||||
return false;
|
||||
}
|
||||
if (action.res_model === "knowledge.article") {
|
||||
// The knowledge form view is very specific and custom, it doesn't make sense
|
||||
// to edit it. Editing the list and kanban is more debatable, but for simplicity's sake
|
||||
// we set them to not editable too.
|
||||
return false;
|
||||
}
|
||||
if (action.res_model === "account.bank.statement.line") {
|
||||
return false; // bank reconciliation isn't editable
|
||||
}
|
||||
return Boolean(action.res_model);
|
||||
};
|
||||
const onUiUpdated = () => {
|
||||
const action = this.actionService.currentController.action;
|
||||
if (action.id === actionId) {
|
||||
this.studioEditable.value = computeStudioEditable(action);
|
||||
}
|
||||
stopListening();
|
||||
};
|
||||
const stopListening = () =>
|
||||
this.env.bus.removeEventListener("ACTION_MANAGER:UI-UPDATED", onUiUpdated);
|
||||
this.env.bus.addEventListener("ACTION_MANAGER:UI-UPDATED", onUiUpdated);
|
||||
|
||||
onWillDestroy(stopListening);
|
||||
}
|
||||
},
|
||||
|
||||
isStudioEditable() {
|
||||
return this.studioEditable.value;
|
||||
},
|
||||
|
||||
get displayOptionalFields() {
|
||||
return this.isStudioEditable() || super.displayOptionalFields;
|
||||
},
|
||||
|
||||
/**
|
||||
* This function opens promote studio dialog
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
onSelectedAddCustomField() {
|
||||
this.env.services.dialog.add(PromoteStudioDialog, {
|
||||
title: _t("Odoo Studio - Add new fields to any view"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const unpatchListRendererDesktop = patch(ListRenderer.prototype, patchListRendererDesktop());
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-inherit="web.ListRenderer" t-inherit-mode="extension">
|
||||
<xpath expr="//Dropdown/t[@t-set-slot='content']" position="inside">
|
||||
<t t-if="this.isStudioEditable ? this.isStudioEditable() : false">
|
||||
<div t-if="hasOptionalFields" class="dropdown-divider"/>
|
||||
<DropdownItem closingMode="'none'" onSelected="() => this.onSelectedAddCustomField()" class="'dropdown-item-studio'">
|
||||
<i class="fa fa-plus fa-fw me-2"/>
|
||||
<span>Add Custom Field</span>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { PivotRenderer } from "@web/views/pivot/pivot_renderer";
|
||||
|
||||
import { useEffect, useRef } from "@odoo/owl";
|
||||
|
||||
patch(PivotRenderer.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.root = useRef("root");
|
||||
if (this.env.isSmall) {
|
||||
useEffect(() => {
|
||||
if (this.root.el) {
|
||||
const tooltipElems = this.root.el.querySelectorAll("*[data-tooltip]");
|
||||
for (const el of tooltipElems) {
|
||||
el.removeAttribute("data-tooltip");
|
||||
el.removeAttribute("data-tooltip-position");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getPadding(cell) {
|
||||
if (this.env.isSmall) {
|
||||
return 5 + cell.indent * 5;
|
||||
}
|
||||
return super.getPadding(...arguments);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
@include media-breakpoint-down(md) {
|
||||
.o_pivot {
|
||||
height: 100%;
|
||||
|
||||
.dropdown.show {
|
||||
> .dropdown-toggle::after {
|
||||
@include o-caret-down;
|
||||
}
|
||||
}
|
||||
|
||||
th > .o_group_by_menu > .dropdown-menu {
|
||||
.dropdown-item {
|
||||
// caret centered vertically
|
||||
.dropdown-toggle::after{
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
// nested dropdown should be *under* the parent, not on its side
|
||||
.dropdown-menu {
|
||||
top: initial !important;
|
||||
left: 5% !important;
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-inherit="web.PivotRenderer" t-inherit-mode="extension">
|
||||
<xpath expr="//div[hasclass('o_pivot')]" position="attributes">
|
||||
<attribute name="t-ref">root</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/** @odoo-module **/
|
||||
import { BurgerMenu } from "@web/webclient/burger_menu/burger_menu";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class EnterpriseBurgerMenu extends BurgerMenu {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.hm = useService("home_menu");
|
||||
}
|
||||
|
||||
get currentApp() {
|
||||
return !this.hm.hasHomeMenu && super.currentApp;
|
||||
}
|
||||
}
|
||||
|
||||
const systrayItem = {
|
||||
Component: EnterpriseBurgerMenu,
|
||||
};
|
||||
|
||||
registry.category("systray").add("burger_menu", systrayItem, { sequence: 0, force: true });
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// = Burger Menu Variables
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
$o-burger-topbar-bg: $o-gray-100 !default;
|
||||
$o-burger-topbar-color: $o-gray-900 !default;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { cookie as cookieManager } from "@web/core/browser/cookie";
|
||||
|
||||
export function switchColorSchemeItem(env) {
|
||||
return {
|
||||
type: "switch",
|
||||
id: "color_scheme.switch_theme",
|
||||
description: _t("Dark Mode"),
|
||||
callback: () => {
|
||||
const cookie = cookieManager.get("color_scheme");
|
||||
const scheme = cookie === "dark" ? "light" : "dark";
|
||||
env.services.color_scheme.switchToColorScheme(scheme);
|
||||
},
|
||||
isChecked: cookieManager.get("color_scheme") === "dark",
|
||||
sequence: 30,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
|
||||
import { switchColorSchemeItem } from "./color_scheme_menu_items";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
const userMenuRegistry = registry.category("user_menuitems");
|
||||
|
||||
export const colorSchemeService = {
|
||||
dependencies: ["ui"],
|
||||
|
||||
start(env, { ui }) {
|
||||
userMenuRegistry.add("color_scheme.switch", switchColorSchemeItem);
|
||||
return {
|
||||
switchToColorScheme: (scheme) => {
|
||||
cookie.set("color_scheme", scheme);
|
||||
ui.block();
|
||||
this.reload();
|
||||
},
|
||||
};
|
||||
},
|
||||
reload() {
|
||||
browser.location.reload();
|
||||
},
|
||||
};
|
||||
serviceRegistry.add("color_scheme", colorSchemeService);
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { session } from "@web/session";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { deserializeDateTime, serializeDate, formatDate } from "@web/core/l10n/dates";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { ExpirationPanel } from "./expiration_panel";
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
import { Component, xml, useState } from "@odoo/owl";
|
||||
|
||||
function daysUntil(datetime) {
|
||||
const duration = datetime.diff(DateTime.utc(), "days");
|
||||
return Math.round(duration.values.days);
|
||||
}
|
||||
|
||||
export class SubscriptionManager {
|
||||
constructor(env, { orm, notification }) {
|
||||
this.env = env;
|
||||
this.orm = orm;
|
||||
this.notification = notification;
|
||||
if (session.expiration_date) {
|
||||
this.expirationDate = deserializeDateTime(session.expiration_date);
|
||||
} else {
|
||||
// If no date found, assume 1 month and hope for the best
|
||||
this.expirationDate = DateTime.utc().plus({ days: 30 });
|
||||
}
|
||||
this.expirationReason = session.expiration_reason;
|
||||
// Hack: we need to know if there is at least one app installed (except from App and
|
||||
// Settings). We use mail to do that, as it is a dependency of almost every addon. To
|
||||
// determine whether mail is installed or not, we check for the presence of the key
|
||||
// "storeData" in session_info, as it is added in mail.
|
||||
this.hasInstalledApps = "storeData" in session;
|
||||
// "user" or "admin"
|
||||
this.warningType = session.warning;
|
||||
this.lastRequestStatus = null;
|
||||
this.isWarningHidden = cookie.get("oe_instance_hide_panel");
|
||||
}
|
||||
|
||||
get formattedExpirationDate() {
|
||||
return formatDate(this.expirationDate, { format: "DDD" });
|
||||
}
|
||||
|
||||
get daysLeft() {
|
||||
return daysUntil(this.expirationDate);
|
||||
}
|
||||
|
||||
get unregistered() {
|
||||
return ["trial", "demo", false].includes(this.expirationReason);
|
||||
}
|
||||
|
||||
hideWarning() {
|
||||
// Hide warning for 24 hours.
|
||||
cookie.set("oe_instance_hide_panel", true, 24 * 60 * 60);
|
||||
this.isWarningHidden = true;
|
||||
}
|
||||
|
||||
async buy() {
|
||||
const limitDate = serializeDate(DateTime.utc().minus({ days: 15 }));
|
||||
const args = [
|
||||
[
|
||||
["share", "=", false],
|
||||
["login_date", ">=", limitDate],
|
||||
],
|
||||
];
|
||||
const nbUsers = await this.orm.call("res.users", "search_count", args);
|
||||
browser.location = `https://www.odoo.com/odoo-enterprise/upgrade?num_users=${nbUsers}`;
|
||||
}
|
||||
|
||||
async submitCode(OdexCode) {
|
||||
const [oldDate, ] = await Promise.all([
|
||||
this.orm.call("ir.config_parameter", "get_param", ["database.expiration_date"]),
|
||||
this.orm.call("ir.config_parameter", "set_param", [
|
||||
"database.enterprise_code",
|
||||
OdexCode,
|
||||
])
|
||||
]);
|
||||
|
||||
await this.orm.call("publisher_warranty.contract", "update_notification", [[]]);
|
||||
|
||||
const [linkedSubscriptionUrl, linkedEmail, expirationDate] = await Promise.all([
|
||||
this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.already_linked_subscription_url",
|
||||
]),
|
||||
this.orm.call("ir.config_parameter", "get_param", ["database.already_linked_email"]),
|
||||
this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.expiration_date",
|
||||
])
|
||||
]);
|
||||
|
||||
if (linkedSubscriptionUrl) {
|
||||
this.lastRequestStatus = "link";
|
||||
this.linkedSubscriptionUrl = linkedSubscriptionUrl;
|
||||
this.mailDeliveryStatus = null;
|
||||
this.linkedEmail = linkedEmail;
|
||||
} else if (expirationDate !== oldDate) {
|
||||
this.lastRequestStatus = "success";
|
||||
this.expirationDate = deserializeDateTime(expirationDate);
|
||||
if (this.daysLeft > 30) {
|
||||
this.notification.add(
|
||||
_t(
|
||||
"Thank you, your registration was successful! Your database is valid until %s.",
|
||||
this.formattedExpirationDate
|
||||
),
|
||||
{ type: "success" }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.lastRequestStatus = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async checkStatus() {
|
||||
await this.orm.call("publisher_warranty.contract", "update_notification", [[]]);
|
||||
|
||||
const expirationDateStr = await this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.expiration_date",
|
||||
]);
|
||||
this.lastRequestStatus = "update";
|
||||
this.expirationDate = deserializeDateTime(expirationDateStr);
|
||||
}
|
||||
|
||||
async sendUnlinkEmail() {
|
||||
const sendUnlinkInstructionsUrl = await this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.already_linked_send_mail_url",
|
||||
]);
|
||||
this.mailDeliveryStatus = "ongoing";
|
||||
const { result, reason } = await rpc(sendUnlinkInstructionsUrl);
|
||||
if (result) {
|
||||
this.mailDeliveryStatus = "success";
|
||||
} else {
|
||||
this.mailDeliveryStatus = "fail";
|
||||
this.mailDeliveryStatusError = reason;
|
||||
}
|
||||
}
|
||||
|
||||
async renew() {
|
||||
const OdexCode = await this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.enterprise_code",
|
||||
]);
|
||||
|
||||
const url = "https://www.odoo.com/odoo-enterprise/renew";
|
||||
const contractQueryString = OdexCode ? `?contract=${OdexCode}` : "";
|
||||
browser.location = `${url}${contractQueryString}`;
|
||||
}
|
||||
|
||||
async upsell() {
|
||||
const limitDate = serializeDate(DateTime.utc().minus({ days: 15 }));
|
||||
const [OdexCode, nbUsers] = await Promise.all([
|
||||
this.orm.call("ir.config_parameter", "get_param", ["database.enterprise_code"]),
|
||||
this.orm.call("res.users", "search_count", [
|
||||
[
|
||||
["share", "=", false],
|
||||
["login_date", ">=", limitDate],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
const url = "https://www.odoo.com/odoo-enterprise/upsell";
|
||||
const contractQueryString = OdexCode ? `&contract=${OdexCode}` : "";
|
||||
browser.location = `${url}?num_users=${nbUsers}${contractQueryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
class ExpiredSubscriptionBlockUI extends Component {
|
||||
static props = {};
|
||||
// TODO the "o_blockUI" div in there seems useless (it has 0 height and thus displays and does nothing)
|
||||
static template = xml`
|
||||
<t t-if="subscription.daysLeft <= 0">
|
||||
<div class="o_blockUI"/>
|
||||
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1100" class="d-flex align-items-center justify-content-center">
|
||||
<ExpirationPanel/>
|
||||
</div>
|
||||
</t>`;
|
||||
static components = { ExpirationPanel };
|
||||
setup() {
|
||||
this.subscription = useState(useService("enterprise_subscription"));
|
||||
}
|
||||
}
|
||||
|
||||
export const enterpriseSubscriptionService = {
|
||||
name: "enterprise_subscription",
|
||||
dependencies: ["orm", "notification"],
|
||||
start(env, { orm, notification }) {
|
||||
registry
|
||||
.category("main_components")
|
||||
.add("expired_subscription_block_ui", { Component: ExpiredSubscriptionBlockUI });
|
||||
return new SubscriptionManager(env, { orm, notification });
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("enterprise_subscription", enterpriseSubscriptionService);
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Transition } from "@web/core/transition";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Component, useState, useRef } from "@odoo/owl";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
/**
|
||||
* Expiration panel
|
||||
*
|
||||
* Component representing the banner located on top of the home menu. Its purpose
|
||||
* is to display the expiration state of the current database and to help the
|
||||
* user to buy/renew its subscription.
|
||||
* @extends Component
|
||||
*/
|
||||
export class ExpirationPanel extends Component {
|
||||
static template = "DatabaseExpirationPanel";
|
||||
static props = {};
|
||||
static components = { Transition };
|
||||
|
||||
setup() {
|
||||
this.subscription = useState(useService("enterprise_subscription"));
|
||||
|
||||
this.state = useState({
|
||||
displayRegisterForm: false,
|
||||
});
|
||||
|
||||
this.inputRef = useRef("input");
|
||||
}
|
||||
|
||||
get buttonText() {
|
||||
return this.subscription.lastRequestStatus === "error" ? _t("Retry") : _t("Register");
|
||||
}
|
||||
|
||||
get alertType() {
|
||||
if (this.subscription.lastRequestStatus === "success") {
|
||||
return "success";
|
||||
}
|
||||
const { daysLeft } = this.subscription;
|
||||
if (daysLeft <= 6) {
|
||||
return "danger";
|
||||
} else if (daysLeft <= 16) {
|
||||
return "warning";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
get expirationMessage() {
|
||||
const { daysLeft } = this.subscription;
|
||||
if (daysLeft <= 0) {
|
||||
return _t("This database has expired. ");
|
||||
}
|
||||
const delay = daysLeft === 30 ? _t("1 month") : _t("%s days", daysLeft);
|
||||
if (this.subscription.expirationReason === "demo") {
|
||||
return _t("This demo database will expire in %s. ", delay);
|
||||
}
|
||||
|
||||
const expirationDate = this.subscription.expirationDate;
|
||||
const today = DateTime.now();
|
||||
const diff = expirationDate.diff(today);
|
||||
|
||||
if (this.subscription.expirationReason !== 'renewal') {
|
||||
return _t("This database will expire in %s. ", delay);
|
||||
} else {
|
||||
if (daysLeft > 15) {
|
||||
return _t(
|
||||
"Your subscription expires in %s days. ",
|
||||
daysLeft - 15
|
||||
);
|
||||
} else {
|
||||
return _t(
|
||||
"Your subscription expired %s days ago. This database will be blocked soon. ",
|
||||
(diff.as("days") | 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showRegistrationForm() {
|
||||
this.state.displayRegisterForm = !this.state.displayRegisterForm;
|
||||
}
|
||||
|
||||
async onCodeSubmit() {
|
||||
const OdexCode = this.inputRef.el.value;
|
||||
if (!OdexCode) {
|
||||
return;
|
||||
}
|
||||
await this.subscription.submitCode(OdexCode);
|
||||
if (this.subscription.lastRequestStatus === "success") {
|
||||
this.state.displayRegisterForm = false;
|
||||
} else {
|
||||
this.state.buttonText = _t("Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
.database_expiration_panel .oe_instance_register_form {
|
||||
max-height: 0;
|
||||
transition: max-height 0.4s;
|
||||
|
||||
&.o-vertical-slide-enter-active {
|
||||
max-height: 10rem; // fixed value is required to properly trigger transition
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="DatabaseExpirationPanel">
|
||||
<div role="alert"
|
||||
class="database_expiration_panel alert o-hidden-ios text-center mt-4"
|
||||
t-attf-class="alert-{{alertType}}"
|
||||
>
|
||||
<!-- t-translation="off" should be on next a element below -->
|
||||
<a t-if="subscription.daysLeft > 0" href="#" class="oe_instance_hide_panel float-end alert-link" t-on-click.prevent="() => subscription.hideWarning()" aria-label="Dismiss">×</a>
|
||||
<span t-if="!subscription.lastRequestStatus" class="oe_instance_register">
|
||||
<t t-if="!subscription.hasInstalledApps">You will be able to register your database once you have installed your first app.</t>
|
||||
<t t-else="">
|
||||
<t t-esc="expirationMessage"/>
|
||||
<t t-if="subscription.warningType === 'admin'">
|
||||
<t t-if="subscription.unregistered">
|
||||
<a class="oe_instance_register_show alert-link text-decoration-underline" href="#" t-on-click.prevent="showRegistrationForm">Register your subscription</a>
|
||||
or
|
||||
<a class="oe_instance_buy alert-link text-decoration-underline" href="#" t-on-click.prevent="() => subscription.buy()">buy a subscription</a>.
|
||||
</t>
|
||||
<t t-if="subscription.expirationReason === 'renewal'">
|
||||
<div class="d-flex flex-wrap justify-content-center mt-2">
|
||||
<a class="oe_instance_renew btn btn-link" href="#" t-on-click.prevent="() => subscription.renew()">Renew now </a>
|
||||
<a class="check_enterprise_status btn btn-link" href="#" t-on-click.prevent="() => subscription.checkStatus()">I paid, please recheck!</a>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="subscription.expirationReason === 'upsell'">You have more users or more apps installed than your subscription allows.<br/>
|
||||
<div class="d-flex flex-wrap justify-content-center mt-2">
|
||||
<a class="oe_instance_upsell btn btn-link" href="#" t-on-click.prevent="() => subscription.upsell()">Upgrade your subscription </a>
|
||||
<a class="check_enterprise_status btn btn-link" href="#" t-on-click.prevent="() => subscription.checkStatus()">I paid, please recheck!</a>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-elif="subscription.warningType === 'user'">Log in as an administrator to correct the issue.</t>
|
||||
</t>
|
||||
</span>
|
||||
<span t-if="subscription.lastRequestStatus === 'success'" class="oe_instance_register oe_instance_success">Thank you, your registration was successful! Your database is valid until <span><t t-esc="subscription.formattedExpirationDate"/></span>.</span>
|
||||
<span t-elif="subscription.lastRequestStatus === 'update'" class="oe_instance_register oe_subscription_updated">Your subscription was updated and is valid until <span><t t-esc="subscription.formattedExpirationDate"/></span>.</span>
|
||||
<span t-elif="subscription.lastRequestStatus === 'error'" class="oe_instance_register oe_instance_error">Something went wrong while registering your database. You can try again or contact <a class="alert-link text-decoration-underline" href="https://www.odoo.com/help" target="_blank">Odoo Support</a>.</span>
|
||||
<span t-elif="subscription.lastRequestStatus === 'link'" class="oe_instance_register oe_database_already_linked">
|
||||
Your subscription is already linked to a database.<br/>
|
||||
<span t-if="subscription.linkedEmail" class="oe_contract_email_block">
|
||||
<a href="#" class="oe_contract_send_mail alert-link text-decoration-underline" t-on-click.prevent="() => subscription.sendUnlinkEmail()">Send an email</a> to the subscription owner to confirm the change, enter a new code or <a class="oe_instance_buy" href="#" t-on-click.prevent="() => subscription.buy()">buy a subscription.</a>
|
||||
<p t-if="subscription.mailDeliveryStatus === 'ongoing'">Sending the instructions by email ...</p>
|
||||
<p t-elif="subscription.mailDeliveryStatus === 'success'">The instructions to unlink your subscription from the previous database(s) have been sent</p>
|
||||
<p t-elif="subscription.mailDeliveryStatus === 'fail'">Unable to send the instructions by email, please contact the <a href="https://www.odoo.com/help" target="_blank">Odoo Support</a><br/>
|
||||
Error reason: <t t-esc="subscription.mailDeliveryStatusError"/>
|
||||
</p>
|
||||
</span>
|
||||
<span t-else="">Contact your sales representative to help you to unlink your previous database</span>
|
||||
</span>
|
||||
<Transition visible="state.displayRegisterForm and subscription.lastRequestStatus !== 'success'" t-slot-scope="transition" leaveDuration="400" name="'o-vertical-slide'">
|
||||
<form class="oe_instance_register oe_instance_register_form d-flex flex-wrap align-items-center overflow-hidden justify-content-center mt-4" t-att-class="transition.className">
|
||||
<label for="enterprise_code">Subscription Code: </label>
|
||||
<input type="text" class="o_input w-auto mx-2 mb-2 mb-sm-0" t-ref="input"
|
||||
placeholder="Paste code here"
|
||||
title="Your subscription code"
|
||||
/>
|
||||
<button class="btn btn-primary" t-on-click.prevent="onCodeSubmit"><t t-esc="buttonText"/></button>
|
||||
</form>
|
||||
</Transition>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// = Home Menu
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_home_menu_background {
|
||||
|
||||
.o_app_icon {
|
||||
--AppSwitcherIcon-background: #{rgba(#fff, .05)};
|
||||
--AppSwitcherIcon-inset-shadow: #{inset 0 0 0 1px rgba(#fff, .1)};
|
||||
--AppSwitcherIcon-border-color: transparent;
|
||||
}
|
||||
|
||||
.o_app:hover .o_app_icon {
|
||||
--AppSwitcherIcon-inset-shadow: #{inset 0 0 0 1px rgba(#fff, .2)};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { hasTouch, isIosApp, isMacOS } from "@web/core/browser/feature_detection";
|
||||
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
||||
import { user } from "@web/core/user";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { ExpirationPanel } from "./expiration_panel";
|
||||
import { useSortable } from "@web/core/utils/sortable_owl";
|
||||
|
||||
import {
|
||||
Component,
|
||||
useExternalListener,
|
||||
onMounted,
|
||||
onPatched,
|
||||
onWillUpdateProps,
|
||||
useState,
|
||||
useRef,
|
||||
} from "@odoo/owl";
|
||||
|
||||
class FooterComponent extends Component {
|
||||
static template = "odex30_web.HomeMenu.CommandPalette.Footer";
|
||||
static props = {
|
||||
//prop added by the command palette
|
||||
switchNamespace: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.controlKey = isMacOS() ? "COMMAND" : "CONTROL";
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Home menu
|
||||
*
|
||||
* This component handles the display and navigation between the different
|
||||
* available applications and menus.
|
||||
* @extends Component
|
||||
*/
|
||||
export class HomeMenu extends Component {
|
||||
static template = "odex30_web.HomeMenu";
|
||||
static components = { ExpirationPanel };
|
||||
static props = {
|
||||
apps: {
|
||||
type: Array,
|
||||
element: {
|
||||
type: Object,
|
||||
shape: {
|
||||
actionID: Number,
|
||||
href: String,
|
||||
appID: Number,
|
||||
id: Number,
|
||||
label: String,
|
||||
parents: String,
|
||||
webIcon: {
|
||||
type: [
|
||||
Boolean,
|
||||
String,
|
||||
{
|
||||
type: Object,
|
||||
optional: 1,
|
||||
shape: {
|
||||
iconClass: String,
|
||||
color: String,
|
||||
backgroundColor: String,
|
||||
},
|
||||
},
|
||||
],
|
||||
optional: true,
|
||||
},
|
||||
webIconData: { type: String, optional: 1 },
|
||||
xmlid: String,
|
||||
},
|
||||
},
|
||||
},
|
||||
reorderApps: { type: Function },
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object[]} props.apps application icons
|
||||
* @param {number} props.apps[].actionID
|
||||
* @param {number} props.apps[].id
|
||||
* @param {string} props.apps[].label
|
||||
* @param {string} props.apps[].parents
|
||||
* @param {(boolean|string|Object)} props.apps[].webIcon either:
|
||||
* - boolean: false (no webIcon)
|
||||
* - string: path to Odoo icon file
|
||||
* - Object: customized icon (background, class and color)
|
||||
* @param {string} [props.apps[].webIconData]
|
||||
* @param {string} props.apps[].xmlid
|
||||
* @param {function} props.reorderApps
|
||||
*/
|
||||
setup() {
|
||||
this.command = useService("command");
|
||||
this.menus = useService("menu");
|
||||
this.homeMenuService = useService("home_menu");
|
||||
this.subscription = useState(useService("enterprise_subscription"));
|
||||
this.ui = useService("ui");
|
||||
this.state = useState({
|
||||
focusedIndex: null,
|
||||
isIosApp: isIosApp(),
|
||||
});
|
||||
this.inputRef = useRef("input");
|
||||
this.rootRef = useRef("root");
|
||||
this.pressTimer;
|
||||
|
||||
if (!this.env.isSmall) {
|
||||
this._registerHotkeys();
|
||||
}
|
||||
|
||||
useSortable({
|
||||
enable: this._enableAppsSorting,
|
||||
// Params
|
||||
ref: this.rootRef,
|
||||
elements: ".o_draggable",
|
||||
cursor: "move",
|
||||
delay: 500,
|
||||
tolerance: 10,
|
||||
// Hooks
|
||||
onWillStartDrag: (params) => this._sortStart(params),
|
||||
onDrop: (params) => this._sortAppDrop(params),
|
||||
});
|
||||
|
||||
onWillUpdateProps(() => {
|
||||
// State is reset on each remount
|
||||
this.state.focusedIndex = null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!hasTouch()) {
|
||||
this._focusInput();
|
||||
}
|
||||
});
|
||||
|
||||
onPatched(() => {
|
||||
if (this.state.focusedIndex !== null && !this.env.isSmall) {
|
||||
const selectedItem = document.querySelector(".o_home_menu .o_menuitem.o_focused");
|
||||
// When TAB is managed externally the class o_focused disappears.
|
||||
if (selectedItem) {
|
||||
// Center window on the focused item
|
||||
selectedItem.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Getters
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
get displayedApps() {
|
||||
return this.props.apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get maxIconNumber() {
|
||||
const w = window.innerWidth;
|
||||
if (w < 576) {
|
||||
return 3;
|
||||
} else if (w < 768) {
|
||||
return 4;
|
||||
} else {
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} menu
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_openMenu(menu) {
|
||||
return this.menus.selectMenu(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this.state.focusedIndex if not null.
|
||||
* @private
|
||||
* @param {string} cmd
|
||||
*/
|
||||
_updateFocusedIndex(cmd) {
|
||||
const nbrApps = this.displayedApps.length;
|
||||
const lastIndex = nbrApps - 1;
|
||||
const focusedIndex = this.state.focusedIndex;
|
||||
if (lastIndex < 0) {
|
||||
return;
|
||||
}
|
||||
if (focusedIndex === null) {
|
||||
this.state.focusedIndex = 0;
|
||||
return;
|
||||
}
|
||||
const lineNumber = Math.ceil(nbrApps / this.maxIconNumber);
|
||||
const currentLine = Math.ceil((focusedIndex + 1) / this.maxIconNumber);
|
||||
let newIndex;
|
||||
switch (cmd) {
|
||||
case "previousElem":
|
||||
newIndex = focusedIndex - 1;
|
||||
break;
|
||||
case "nextElem":
|
||||
newIndex = focusedIndex + 1;
|
||||
break;
|
||||
case "previousColumn":
|
||||
if (focusedIndex % this.maxIconNumber) {
|
||||
// app is not the first one on its line
|
||||
newIndex = focusedIndex - 1;
|
||||
} else {
|
||||
newIndex =
|
||||
focusedIndex + Math.min(lastIndex - focusedIndex, this.maxIconNumber - 1);
|
||||
}
|
||||
break;
|
||||
case "nextColumn":
|
||||
if (focusedIndex === lastIndex || (focusedIndex + 1) % this.maxIconNumber === 0) {
|
||||
// app is the last one on its line
|
||||
newIndex = (currentLine - 1) * this.maxIconNumber;
|
||||
} else {
|
||||
newIndex = focusedIndex + 1;
|
||||
}
|
||||
break;
|
||||
case "previousLine":
|
||||
if (currentLine === 1) {
|
||||
newIndex = focusedIndex + (lineNumber - 1) * this.maxIconNumber;
|
||||
if (newIndex > lastIndex) {
|
||||
newIndex = lastIndex;
|
||||
}
|
||||
} else {
|
||||
// we go to the previous line on same column
|
||||
newIndex = focusedIndex - this.maxIconNumber;
|
||||
}
|
||||
break;
|
||||
case "nextLine":
|
||||
if (currentLine === lineNumber) {
|
||||
newIndex = focusedIndex % this.maxIconNumber;
|
||||
} else {
|
||||
// we go to the next line on the closest column
|
||||
newIndex =
|
||||
focusedIndex + Math.min(this.maxIconNumber, lastIndex - focusedIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// if newIndex is out of bounds -> normalize it
|
||||
if (newIndex < 0) {
|
||||
newIndex = lastIndex;
|
||||
} else if (newIndex > lastIndex) {
|
||||
newIndex = 0;
|
||||
}
|
||||
this.state.focusedIndex = newIndex;
|
||||
}
|
||||
|
||||
_focusInput() {
|
||||
if (!this.env.isSmall && this.inputRef.el) {
|
||||
this.inputRef.el.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
_enableAppsSorting() {
|
||||
return true;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {HTMLElement} params.element
|
||||
* @param {HTMLElement} params.previous
|
||||
*/
|
||||
_sortAppDrop({ element, previous }) {
|
||||
const order = this.props.apps.map((app) => app.xmlid);
|
||||
const elementId = element.children[0].dataset.menuXmlid;
|
||||
const elementIndex = order.indexOf(elementId);
|
||||
// first remove dragged element
|
||||
order.splice(elementIndex, 1);
|
||||
if (previous) {
|
||||
const prevIndex = order.indexOf(previous.children[0].dataset.menuXmlid);
|
||||
// insert dragged element after previous element
|
||||
order.splice(prevIndex + 1, 0, elementId);
|
||||
} else {
|
||||
// insert dragged element at beginning if no previous element
|
||||
order.splice(0, 0, elementId);
|
||||
}
|
||||
// apply new order
|
||||
this.props.reorderApps(order);
|
||||
user.setUserSettings("homemenu_config", JSON.stringify(order));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {HTMLElement} params.element
|
||||
*/
|
||||
_sortStart({ element, addClass }) {
|
||||
addClass(element.children[0], "o_dragged_app");
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} app
|
||||
*/
|
||||
_onAppClick(app) {
|
||||
this._openMenu(app);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_registerHotkeys() {
|
||||
const hotkeys = [
|
||||
["ArrowDown", () => this._updateFocusedIndex("nextLine")],
|
||||
["ArrowRight", () => this._updateFocusedIndex("nextColumn")],
|
||||
["ArrowUp", () => this._updateFocusedIndex("previousLine")],
|
||||
["ArrowLeft", () => this._updateFocusedIndex("previousColumn")],
|
||||
["Tab", () => this._updateFocusedIndex("nextElem")],
|
||||
["shift+Tab", () => this._updateFocusedIndex("previousElem")],
|
||||
[
|
||||
"Enter",
|
||||
() => {
|
||||
const menu = this.displayedApps[this.state.focusedIndex];
|
||||
if (menu) {
|
||||
this._openMenu(menu);
|
||||
}
|
||||
},
|
||||
],
|
||||
["Escape", () => this.homeMenuService.toggle(false)],
|
||||
];
|
||||
hotkeys.forEach((hotkey) => {
|
||||
useHotkey(...hotkey, {
|
||||
allowRepeat: true,
|
||||
});
|
||||
});
|
||||
useExternalListener(window, "keydown", this._onKeydownFocusInput);
|
||||
}
|
||||
|
||||
_onKeydownFocusInput() {
|
||||
if (
|
||||
document.activeElement !== this.inputRef.el &&
|
||||
this.ui.activeElement === document &&
|
||||
!["TEXTAREA", "INPUT"].includes(document.activeElement.tagName)
|
||||
) {
|
||||
this._focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
_onInputSearch() {
|
||||
const onClose = () => {
|
||||
this._focusInput();
|
||||
if (this.inputRef.el) {
|
||||
this.inputRef.el.value = "";
|
||||
}
|
||||
};
|
||||
const searchValue = this.compositionStart ? "/" : `/${this.inputRef.el.value.trim()}`;
|
||||
this.compositionStart = false;
|
||||
this.command.openMainPalette({ searchValue, FooterComponent }, onClose);
|
||||
}
|
||||
|
||||
_onInputBlur() {
|
||||
if (hasTouch()) {
|
||||
return;
|
||||
}
|
||||
// if we blur search input to focus on body (eg. click on any
|
||||
// non-interactive element) restore focus to avoid IME input issue
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === document.body && this.ui.activeElement === document) {
|
||||
this._focusInput();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
_onCompositionStart() {
|
||||
this.compositionStart = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
.o_home_menu_background {
|
||||
|
||||
&:not(.o_home_menu_background_custom):not(.o_in_studio) .o_main_navbar {
|
||||
background: transparent;
|
||||
border-bottom-color: transparent;
|
||||
|
||||
.o_dropdown_active,
|
||||
> ul > li.show > a {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_home_menu_background_custom .o_home_menu {
|
||||
background: {
|
||||
size: cover;
|
||||
repeat: no-repeat;
|
||||
position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.o_menu_systray {
|
||||
@include print-variable(o-navbar-badge-bg, $o-navbar-badge-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.o_home_menu {
|
||||
font-size: $font-size-base;
|
||||
|
||||
.container {
|
||||
@include media-breakpoint-up(md) {
|
||||
max-width: $o-home-menu-container-size !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o_app {
|
||||
.o_app_icon {
|
||||
width: $o-home-menu-app-icon-max-width;
|
||||
aspect-ratio: 1;
|
||||
padding: $o-home-menu-app-icon-padding;
|
||||
background-color: var(--AppSwitcherIcon-background, #{$o-home-menu-app-icon-background-color});
|
||||
object-fit: cover;
|
||||
transform-origin: center bottom;
|
||||
transition: box-shadow ease-in 0.1s, transform ease-in 0.1s;
|
||||
box-shadow: var(--AppSwitcherIcon-inset-shadow, inset 0 0 0 1px rgba(0,0,0, .2)),
|
||||
0 1px 1px rgba(#000, .02),
|
||||
0 2px 2px rgba(#000, .02),
|
||||
0 4px 4px rgba(#000, .02),
|
||||
0 8px 8px rgba(#000, .02),
|
||||
0 16px 16px rgba(#000, .02);
|
||||
|
||||
.fa {
|
||||
font-size: $o-home-menu-app-icon-max-width * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .o_app_icon {
|
||||
box-shadow: var(--AppSwitcherIcon-inset-shadow, inset 0 0 0 1px rgba(0,0,0, .2)),
|
||||
0 2px 2px rgba(#000, .03),
|
||||
0 4px 4px rgba(#000, .03),
|
||||
0 8px 8px rgba(#000, .03),
|
||||
0 12px 12px rgba(#000, .03),
|
||||
0 24px 24px rgba(#000, .03);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active .o_app_icon {
|
||||
transform: translateY(-2px) scale(.98);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.o_caption {
|
||||
color: var(--homeMenuCaption-color, #{$o-home-menu-caption-color});
|
||||
text-shadow: $o-home-menu-caption-shadow;
|
||||
}
|
||||
|
||||
&.o_focused {
|
||||
background: $component-active-bg;
|
||||
outline: 1px solid $o-action;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
}
|
||||
|
||||
.o_dragged_app {
|
||||
transition: transform 0.5s;
|
||||
transform: rotate(6deg);
|
||||
.o_app_icon {
|
||||
box-shadow: 0 8px 15px -10px black;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
// iOS iPhone list layout due to Apple AppStore review
|
||||
@include media-breakpoint-down(md) {
|
||||
&.o_ios_app {
|
||||
.o_apps {
|
||||
flex-direction: column;
|
||||
font-size: $o-home-menu-font-size-base * 1.25;
|
||||
margin-top: map-get($spacers, 1);
|
||||
padding: 0 map-get($spacers, 2);
|
||||
|
||||
> *, .o_app {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_app {
|
||||
flex-direction: row !important;
|
||||
justify-content: initial !important;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
padding: map-get($spacers, 3) map-get($spacers, 4) !important;
|
||||
}
|
||||
|
||||
.o_app_icon {
|
||||
width: $o-home-menu-app-icon-max-width * 0.75;
|
||||
height: $o-home-menu-app-icon-max-width * 0.75;
|
||||
margin-right: map-get($spacers, 4);
|
||||
}
|
||||
|
||||
.o_caption {
|
||||
text-align: start !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.o_ios_app) .o_caption {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_home_menu_background_custom {
|
||||
.o_home_menu .o_app .o_caption {
|
||||
color: $o-home-menu-custom-caption-color;
|
||||
text-shadow: $o-home-menu-custom-caption-shadow;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// = Home Menu Variables
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
$o-home-menu-caption-color: $o-black !default;
|
||||
$o-home-menu-caption-shadow: 0 1px 2px rgba(0, 0, 0, .75), 0 2px 5px rgba(0, 0, 0, .05), 0 0 5px rgba(0, 0, 0, .05) !default;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
$o-home-menu-font-size-base: 1rem;
|
||||
$o-home-menu-container-size: 850px;
|
||||
$o-home-menu-app-icon-max-width: 70px;
|
||||
$o-home-menu-app-icon-padding: 10px;
|
||||
$o-home-menu-app-icon-background-color: rgba(#fff, 1);
|
||||
|
||||
$o-home-menu-caption-color: $o-gray-700 !default;
|
||||
$o-home-menu-caption-shadow: none !default;
|
||||
|
||||
$o-home-menu-custom-caption-color: #fff !default;
|
||||
$o-home-menu-custom-caption-shadow: 0 1px 2px rgba(0, 0, 0, .75), 0 2px 5px rgba(0, 0, 0, .05), 0 0 5px rgba(0, 0, 0, .05) !default;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="odex30_web.HomeMenu">
|
||||
<div t-ref="root" class="o_home_menu h-100 overflow-auto" t-att-class="{ o_ios_app: state.isIosApp }">
|
||||
<div class="container">
|
||||
<input t-ref="input" type="text" class="o_search_hidden visually-hidden w-auto" data-allow-hotkeys="true" t-on-input="_onInputSearch" t-on-blur="_onInputBlur" t-on-compositionstart="_onCompositionStart"
|
||||
role="combobox"
|
||||
t-att-aria-activedescendant="'result_app_' + state.focusedIndex"
|
||||
t-att-aria-expanded="displayedApps.length ? 'true' : 'false'"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- When the subscription has expired, the expiration panel is show over the whole UI instead of here -->
|
||||
<ExpirationPanel t-if="subscription.warningType and !subscription.isWarningHidden and subscription.daysLeft <= 30 and subscription.daysLeft > 0"/>
|
||||
<div t-if="displayedApps.length" role="listbox" class="o_apps row user-select-none mt-5 mx-0">
|
||||
<div t-foreach="displayedApps" t-as="app" t-key="app.id" class="col-3 col-md-2 o_draggable mb-3 px-0">
|
||||
<a t-att-id="'result_app_' + app_index"
|
||||
role="option"
|
||||
t-att-aria-selected="state.focusedIndex === app_index ? 'true' : 'false'"
|
||||
class="o_app o_menuitem d-flex flex-column rounded-3 justify-content-start align-items-center w-100 p-1 p-md-2"
|
||||
t-att-class="{o_focused: state.focusedIndex === app_index}"
|
||||
t-att-data-menu-xmlid="app.xmlid"
|
||||
t-att-href="app.href"
|
||||
t-on-click.prevent="() => this._onAppClick(app)"
|
||||
>
|
||||
<img t-if="app.webIconData" class="o_app_icon rounded-3"
|
||||
t-attf-src="{{app.webIconData}}"
|
||||
/>
|
||||
<div t-else="" class="o_app_icon position-relative d-flex justify-content-center align-items-center p-2 rounded-3 ratio ratio-1x1"
|
||||
t-attf-style="background-color: {{app.webIcon.backgroundColor}};"
|
||||
>
|
||||
<i t-attf-class="{{app.webIcon.iconClass}} position-relative w-auto h-auto" t-attf-style="color: {{app.webIcon.color}};"/>
|
||||
</div>
|
||||
<div class="o_caption w-100 text-center text-truncate mt-2" t-esc="app.label or app.name"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div t-elif="!displayedApps.length" id="result_menu_0" role="option" aria-selected="true" class="o_no_result">
|
||||
No result
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="odex30_web.HomeMenu.CommandPalette.Footer">
|
||||
<span>
|
||||
<span class='fw-bolder text-primary'>TIP</span> — open me anywhere with <span t-esc="controlKey" class='fw-bolder text-primary'/> + <span class='fw-bolder text-primary'>K</span>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// = Home Menu Background
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_home_menu_background {
|
||||
--homeMenu-bg-color: #000511;
|
||||
--homeMenu-bg-image: url("/odex30_web/static/img/background-dark.jpg");
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// Shared with web client and login screen
|
||||
.o_home_menu_background, .o_web_client.o_home_menu_background {
|
||||
background: {
|
||||
size: cover;
|
||||
attachment: fixed;
|
||||
color: var(--homeMenu-bg-color, #{$o-gray-200});
|
||||
image: var(--homeMenu-bg-image, url("/odex30_web/static/img/background-light.svg"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { user } from "@web/core/user";
|
||||
import { Mutex } from "@web/core/utils/concurrency";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { computeAppsAndMenuItems, reorderApps } from "@web/webclient/menus/menu_helpers";
|
||||
import {
|
||||
ControllerNotFoundError,
|
||||
standardActionServiceProps,
|
||||
} from "@web/webclient/actions/action_service";
|
||||
import { HomeMenu } from "./home_menu";
|
||||
|
||||
import { Component, onMounted, onWillUnmount, useState, reactive, xml } from "@odoo/owl";
|
||||
|
||||
export const homeMenuService = {
|
||||
dependencies: ["action"],
|
||||
start(env) {
|
||||
const state = reactive({
|
||||
hasHomeMenu: false, // true iff the HomeMenu is currently displayed
|
||||
hasBackgroundAction: false, // true iff there is an action behind the HomeMenu
|
||||
toggle,
|
||||
});
|
||||
const mutex = new Mutex(); // used to protect against concurrent toggling requests
|
||||
class HomeMenuAction extends Component {
|
||||
static components = { HomeMenu };
|
||||
static target = "current";
|
||||
static props = { ...standardActionServiceProps };
|
||||
static template = xml`<HomeMenu t-props="homeMenuProps"/>`;
|
||||
static displayName = _t("Home");
|
||||
|
||||
setup() {
|
||||
this.menus = useService("menu");
|
||||
const homemenuConfig = JSON.parse(user.settings?.homemenu_config || "null");
|
||||
const apps = useState(
|
||||
computeAppsAndMenuItems(this.menus.getMenuAsTree("root")).apps
|
||||
);
|
||||
if (homemenuConfig) {
|
||||
reorderApps(apps, homemenuConfig);
|
||||
}
|
||||
this.homeMenuProps = {
|
||||
apps: apps,
|
||||
reorderApps: (order) => {
|
||||
reorderApps(apps, order);
|
||||
},
|
||||
};
|
||||
onMounted(() => this.onMounted());
|
||||
onWillUnmount(this.onWillUnmount);
|
||||
}
|
||||
async onMounted() {
|
||||
const { breadcrumbs } = this.env.config;
|
||||
state.hasHomeMenu = true;
|
||||
state.hasBackgroundAction = breadcrumbs.length > 0;
|
||||
this.env.bus.trigger("HOME-MENU:TOGGLED");
|
||||
}
|
||||
onWillUnmount() {
|
||||
state.hasHomeMenu = false;
|
||||
state.hasBackgroundAction = false;
|
||||
this.env.bus.trigger("HOME-MENU:TOGGLED");
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("menu", HomeMenuAction);
|
||||
|
||||
env.bus.addEventListener("HOME-MENU:TOGGLED", () => {
|
||||
document.body.classList.toggle("o_home_menu_background", state.hasHomeMenu);
|
||||
});
|
||||
|
||||
async function toggle(show) {
|
||||
return mutex.exec(async () => {
|
||||
show = show === undefined ? !state.hasHomeMenu : Boolean(show);
|
||||
if (show !== state.hasHomeMenu) {
|
||||
if (show) {
|
||||
await env.services.action.doAction("menu");
|
||||
} else {
|
||||
try {
|
||||
await env.services.action.restore();
|
||||
} catch (err) {
|
||||
if (!(err instanceof ControllerNotFoundError)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// hack: wait for a tick to ensure that the url has been updated before
|
||||
// switching again
|
||||
return new Promise((r) => setTimeout(r));
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("home_menu", homeMenuService);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// = Navbar
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_main_navbar {
|
||||
--o-navbar-badge-color: #{$black};
|
||||
--o-navbar-badge-text-shadow: none;
|
||||
--NavBar-menuToggle-color: #{$o-black};
|
||||
--NavBar-brand-color: #{$o-gray-800};
|
||||
--NavBar-entry-borderColor-active: #{darken($o-action, 10%)};
|
||||
--NavBar-entry-backgroundColor--active: #{mix($o-action, $o-gray-100, 15%)};
|
||||
--NavBar-entry-backgroundColor--hover: #{$o-gray-300};
|
||||
--NavBar-entry-backgroundColor--focus: #{$o-gray-300};
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { NavBar } from "@web/webclient/navbar/navbar";
|
||||
import { useService, useBus } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useState, useEffect, useRef } from "@odoo/owl";
|
||||
|
||||
export class OdexNavBar extends NavBar {
|
||||
static template = "odex30_web.OdexNavBar";
|
||||
setup() {
|
||||
super.setup();
|
||||
this.hm = useState(useService("home_menu"));
|
||||
this.pwa = useService("pwa");
|
||||
this.menuAppsRef = useRef("menuApps");
|
||||
this.navRef = useRef("nav");
|
||||
this._busToggledCallback = () => this._updateMenuAppsIcon();
|
||||
useBus(this.env.bus, "HOME-MENU:TOGGLED", this._busToggledCallback);
|
||||
useEffect(() => this._updateMenuAppsIcon());
|
||||
}
|
||||
get hasBackgroundAction() {
|
||||
return this.hm.hasBackgroundAction;
|
||||
}
|
||||
get isInApp() {
|
||||
return !this.hm.hasHomeMenu;
|
||||
}
|
||||
|
||||
_openAppMenuSidebar() {
|
||||
if (this.hm.hasHomeMenu) {
|
||||
this.hm.toggle(false);
|
||||
} else {
|
||||
this.state.isAppMenuSidebarOpened = true;
|
||||
}
|
||||
}
|
||||
_updateMenuAppsIcon() {
|
||||
const menuAppsEl = this.menuAppsRef.el;
|
||||
menuAppsEl.classList.toggle("o_hidden", !this.isInApp && !this.hasBackgroundAction);
|
||||
menuAppsEl.classList.toggle(
|
||||
"o_menu_toggle_back",
|
||||
!this.isInApp && this.hasBackgroundAction
|
||||
);
|
||||
if (!this.isScopedApp) {
|
||||
const title =
|
||||
!this.isInApp && this.hasBackgroundAction ? _t("Previous view") : _t("Home menu");
|
||||
menuAppsEl.title = title;
|
||||
menuAppsEl.ariaLabel = title;
|
||||
}
|
||||
|
||||
const menuBrand = this.navRef.el.querySelector(".o_menu_brand");
|
||||
if (menuBrand) {
|
||||
menuBrand.classList.toggle("o_hidden", !this.isInApp);
|
||||
}
|
||||
|
||||
const menuBrandIcon = this.navRef.el.querySelector(".o_menu_brand_icon");
|
||||
if (menuBrandIcon) {
|
||||
menuBrandIcon.classList.toggle("o_hidden", !this.isInApp);
|
||||
}
|
||||
|
||||
const appSubMenus = this.appSubMenus.el;
|
||||
if (appSubMenus) {
|
||||
appSubMenus.classList.toggle("o_hidden", !this.isInApp);
|
||||
}
|
||||
|
||||
const breadcrumb = this.navRef.el.querySelector(".o_breadcrumb");
|
||||
if (breadcrumb) {
|
||||
breadcrumb.classList.toggle("o_hidden", !this.isInApp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
onAllAppsBtnClick() {
|
||||
super.onAllAppsBtnClick();
|
||||
this.hm.toggle(true);
|
||||
this._closeAppMenuSidebar();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// = Main Navbar
|
||||
// ============================================================================
|
||||
.o_main_navbar {
|
||||
--NavBar-entry-color--active: #{$o-component-active-color};
|
||||
--NavBar-entry-borderColor-active: #{$o-component-active-border};
|
||||
--NavBar-entry-backgroundColor--active: #{$o-component-active-bg};
|
||||
--NavBar-entry-backgroundColor--hover: #{$o-gray-200};
|
||||
--NavBar-entry-backgroundColor--focus: #{$o-gray-200};
|
||||
|
||||
--Dropdown_menu-margin-y: #{map-get($spacers, 1)};
|
||||
|
||||
.o_menu_toggle {
|
||||
color: var(--NavBar-menuToggle-color, #{$o-brand-odoo});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
body.o_is_superuser .o_menu_systray {
|
||||
border-image-outset: map-get($border-widths, 5);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// = Navbar Variables
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
$o-navbar-background: $o-view-background-color !default;
|
||||
$o-navbar-entry-color: $o-gray-900 !default;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
|
||||
$o-navbar-background: $o-white !default;
|
||||
$o-navbar-padding-v: 10px !default;
|
||||
$o-navbar-border-bottom: 0 !default;
|
||||
$o-navbar-font-size: $o-font-size-base !default;
|
||||
|
||||
$o-navbar-entry-margin-h: 1px !default;
|
||||
$o-navbar-entry-border-radius: $o-border-radius !default;
|
||||
$o-navbar-entry-color: $o-gray-800 !default;
|
||||
$o-navbar-entry-padding-h: .63em !default;
|
||||
|
||||
$o-navbar-entry-bg--hover: $o-gray-200 !default;
|
||||
$o-navbar-entry-color--hover: $o-gray-900 !default;
|
||||
|
||||
$o-navbar-entry-bg--active: unset !default;
|
||||
$o-navbar-entry-color--active: unset !default;
|
||||
|
||||
$o-navbar-brand-color: $o-gray-700 !default;
|
||||
|
||||
$o-navbar-badge-size: .7em !default;
|
||||
$o-navbar-badge-padding: 6px !default;
|
||||
$o-navbar-badge-bg: $o-danger !default;
|
||||
$o-navbar-badge-color: $o-white !default;
|
||||
$o-navbar-badge-text-shadow: none !default;
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="odex30_web.OdexNavBar" t-inherit="web.NavBar" t-inherit-mode="primary">
|
||||
<xpath expr="//nav" position="attributes">
|
||||
<attribute name="t-ref">nav</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-call='web.NavBar.AppsMenu']" position="replace">
|
||||
<a t-if="!isScopedApp" href="/odoo" class="o_menu_toggle border-0" t-att-class="{'hasImage': currentApp?.webIconData}" accesskey="h" t-ref="menuApps" t-on-click.prevent="() => { env.isSmall ? this._openAppMenuSidebar() : this.hm.toggle() }">
|
||||
<t t-if="env.isSmall and !hm.hasHomeMenu">
|
||||
<t t-call="web.NavBar.AppsMenu.Sidebar"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<svg class="o_menu_toggle_icon pe-none" width="14px" height="14px" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
|
||||
<g t-foreach="[0, 5, 10]" t-as="Y" t-att-id="'o_menu_toggle_row_' + Y_index" fill="currentColor" t-key="'o_menu_toggle_row_' + Y_index">
|
||||
<rect t-foreach="[0, 5, 10]" t-as="X" width="3" height="3" t-att-x="X" t-att-y="Y" t-key="'o_menu_toggle_cell_' + X_index"/>
|
||||
</g>
|
||||
</svg>
|
||||
<t t-if="!env.isSmall and currentApp">
|
||||
<img
|
||||
t-if="currentApp.webIconData"
|
||||
t-att-src="currentApp.webIconData"
|
||||
class="o_menu_brand_icon d-inline position-absolute start-0 h-100 ps-1 ms-2"
|
||||
t-att-alt="currentApp.name"
|
||||
t-ref="appIcon"/>
|
||||
<span t-esc="currentApp.name" class="o_menu_brand d-flex ms-3 pe-0"/>
|
||||
</t>
|
||||
</t>
|
||||
</a>
|
||||
<a t-else="" t-att-href="pwa.startUrl" class="o_menu_toggle" t-ref="menuApps">
|
||||
<img
|
||||
t-if="currentApp && currentApp.webIconData"
|
||||
t-att-src="currentApp.webIconData"
|
||||
class="o_menu_brand_icon d-none d-lg-inline position-absolute start-0 h-100 ps-1 ms-2"
|
||||
t-att-alt="currentApp.name"
|
||||
t-ref="appIcon"/>
|
||||
|
||||
<span
|
||||
t-if="currentApp"
|
||||
t-esc="currentApp.name"
|
||||
class="o_menu_brand d-none d-md-flex ps-4 pe-0"/>
|
||||
</a>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//DropdownItem[@t-esc='currentApp.name']" position="replace"/>
|
||||
</t>
|
||||
|
||||
<t t-name="odex30_web.OdexNavBar.SectionsMenu" t-inherit="web.NavBar.SectionsMenu" t-inherit-mode="extension">
|
||||
<xpath expr="//Dropdown/button" position="attributes">
|
||||
<attribute name="class" add="fw-normal" separator=" "/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/** @odoo-module */
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { useChildRef, useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { Component, useExternalListener } from "@odoo/owl";
|
||||
|
||||
export class PromoteStudioDialog extends Component {
|
||||
static template = "odex30_web.PromoteStudioDialog";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
title: String,
|
||||
close: Function,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.ormService = useService("orm");
|
||||
this.uiService = useService("ui");
|
||||
|
||||
this.modalRef = useChildRef();
|
||||
|
||||
useExternalListener(window, "mousedown", this.onWindowMouseDown);
|
||||
}
|
||||
|
||||
async onClickInstallStudio() {
|
||||
this.disableClick = true;
|
||||
this.uiService.block();
|
||||
const modules = await this.ormService.searchRead(
|
||||
"ir.module.module",
|
||||
[["name", "=", "web_studio"]],
|
||||
["id"]
|
||||
);
|
||||
await this.ormService.call("ir.module.module", "button_immediate_install", [
|
||||
[modules[0].id],
|
||||
]);
|
||||
// on rpc call return, the framework unblocks the page
|
||||
// make sure to keep the page blocked until the reload ends.
|
||||
this.uiService.unblock();
|
||||
browser.localStorage.setItem("openStudioOnReload", "main");
|
||||
browser.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the dialog on outside click.
|
||||
*/
|
||||
onWindowMouseDown(ev) {
|
||||
const dialogContent = this.modalRef.el.querySelector(".modal-content");
|
||||
if (!this.disableClick && !dialogContent.contains(ev.target)) {
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PromoteStudioAutomationDialog extends PromoteStudioDialog {
|
||||
static template = "odex30_web.PromoteStudioAutomationDialog";
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<template>
|
||||
|
||||
<t t-name="odex30_web.PromoteStudioDialog">
|
||||
<Dialog title="props.title" modalRef="modalRef">
|
||||
<div class="modal-studio">
|
||||
<div id="studio_install_dialog"
|
||||
class="d-flex flex-row align-items-center flex-wrap">
|
||||
<div id="studio_dialog_pitch"
|
||||
class="w-100 w-md-50">
|
||||
<h4>Want to tailor-make your Odoo?</h4>
|
||||
<p>Unleash the power of Odoo Studio:</p>
|
||||
<ul>
|
||||
<li>Create automation rules</li>
|
||||
<li>Customize any screen</li>
|
||||
<li>Customize Reports</li>
|
||||
<li>Build new reports</li>
|
||||
<li>Build new apps from scratch</li>
|
||||
<li>Define webhooks</li>
|
||||
<li>and more!</li>
|
||||
</ul>
|
||||
<a role="button" class="btn btn-secondary btn-block"
|
||||
href="https://www.odoo.com/app/studio" target="_blank"> Learn More <i
|
||||
class="fa fa-external-link" />
|
||||
</a>
|
||||
</div>
|
||||
<div id="studio_video" class="o_video_embed w-100 w-md-50 ratio ratio-16x9">
|
||||
<iframe class="embed-responsive-item"
|
||||
t-attf-src="https://www.youtube.com/embed/xCvFZrrQq7k?autoplay=1"
|
||||
frameborder="0" allowfullscreen="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary btn-block o_install_studio"
|
||||
t-on-click.stop="onClickInstallStudio"
|
||||
data-tooltip="Install Odoo Studio and its dependencies">
|
||||
Start using Odoo Studio
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary btn-block"
|
||||
t-on-click="props.close">
|
||||
Discard
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
|
||||
<t t-name="odex30_web.PromoteStudioAutomationDialog"
|
||||
t-inherit="odex30_web.PromoteStudioDialog" t-inherit-mode="primary">
|
||||
<xpath expr="//div[@id='studio_video']" position="replace">
|
||||
<img class="w-100 w-md-50"
|
||||
src="/odex30_web/static/img/automation.svg" />
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</template>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-inherit="res_config_edition" t-inherit-mode="extension">
|
||||
|
||||
<xpath expr="//h3" position="replace">
|
||||
<h3 class="px-0">
|
||||
Odoo <t t-esc="serverVersion"/> (Enterprise Edition)
|
||||
</h3>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//*[@id='license']" position="replace">
|
||||
<a id="license" target="_blank" href="https://www.odoo.com/documentation/master/legal/licenses.html" style="text-decoration: underline;">Odoo Enterprise Edition License V1.0</a>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//h3" position="after">
|
||||
<t t-if="expirationDate">
|
||||
<h5>Database expiration: <t t-esc="expirationDate"/></h5>
|
||||
</t>
|
||||
</xpath>
|
||||
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// = Settings
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_base_settings_view .o_form_renderer {
|
||||
--settings__tab-bg: #{$o-gray-100};
|
||||
--settings__tab-bg--active: #{mix($o-action, $o-gray-100, 10%)};
|
||||
--settings__tab-color: #{$o-gray-700};
|
||||
--settings__title-bg: #{$o-gray-300};
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { isDisplayStandalone } from "@web/core/browser/feature_detection";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { BurgerMenu } from "@web/webclient/burger_menu/burger_menu";
|
||||
import { shareUrl } from "./share_url";
|
||||
|
||||
if (navigator.share && isDisplayStandalone()) {
|
||||
patch(BurgerMenu.prototype, {
|
||||
shareUrl,
|
||||
});
|
||||
|
||||
patch(BurgerMenu, {
|
||||
template: "odex30_web.BurgerMenu",
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="odex30_web.BurgerMenu" t-inherit="web.BurgerMenu" t-inherit-mode="primary">
|
||||
<xpath expr="//button[hasclass('o_sidebar_close')]" position="replace">
|
||||
<div class="d-flex align-items-center h-100 bg-transparent">
|
||||
<button class="o_burger_menu_share fa fa-share-alt btn border-0 fs-5 text-reset" aria-label="Share URL" title="Share URL" t-on-click.stop="shareUrl"/>
|
||||
<button class="o_sidebar_close oi oi-close btn border-0 fs-2 text-reset" aria-label="Close menu" title="Close menu" t-on-click.stop="_closeBurger"/>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { markup } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { isDisplayStandalone } from "@web/core/browser/feature_detection";
|
||||
import { escape } from "@web/core/utils/strings";
|
||||
|
||||
export async function shareUrl() {
|
||||
await navigator
|
||||
.share({
|
||||
url: browser.location.href,
|
||||
title: document.title,
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!(e instanceof DOMException && e.name === "AbortError")) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function shareUrlMenuItem(env) {
|
||||
const translatedText = _t("Share");
|
||||
return {
|
||||
type: "item",
|
||||
hide: env.isSmall || !isDisplayStandalone(),
|
||||
id: "share_url",
|
||||
description: markup(
|
||||
`<div class="d-flex align-items-center justify-content-between">
|
||||
<span>${escape(translatedText)}</span>
|
||||
<span class="fa fa-share-alt"></span>
|
||||
</div>`
|
||||
),
|
||||
callback: shareUrl,
|
||||
sequence: 25,
|
||||
};
|
||||
}
|
||||
|
||||
if (navigator.share) {
|
||||
registry.category("user_menuitems").add("share_url", shareUrlMenuItem);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { OdexNavBar } from "./navbar/navbar";
|
||||
|
||||
export class WebClientOdex extends WebClient {
|
||||
static components = {
|
||||
...WebClient.components,
|
||||
NavBar: OdexNavBar,
|
||||
};
|
||||
setup() {
|
||||
super.setup();
|
||||
this.hm = useService("home_menu");
|
||||
}
|
||||
_loadDefaultApp() {
|
||||
return this.hm.toggle(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { createWebClient } from "@web/../tests/webclient/helpers";
|
||||
import { WebClientOdex } from "@odex30_web/webclient/webclient";
|
||||
|
||||
export function createOdexWebClient(params) {
|
||||
params.WebClientClass = WebClientOdex;
|
||||
return createWebClient(params);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { click, queryAll } from "@odoo/hoot-dom";
|
||||
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
import { defineActions, defineMenus, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { Component, onMounted, xml } from "@odoo/owl";
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { WebClientOdex } from "@odex30_web/webclient/webclient";
|
||||
|
||||
const actionRegistry = registry.category("actions");
|
||||
|
||||
const queryAllRoot = (selector) => queryAll(selector, { root: document.body });
|
||||
|
||||
class TestClientAction extends Component {
|
||||
static template = xml`
|
||||
<div class="test_client_action">
|
||||
ClientAction_<t t-esc="props.action.params?.description"/>
|
||||
</div>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
onMounted(() => this.env.config.setDisplayName(`Client action ${this.props.action.id}`));
|
||||
}
|
||||
}
|
||||
|
||||
describe.current.tags("mobile");
|
||||
|
||||
beforeEach(() => {
|
||||
defineMenus([
|
||||
{
|
||||
id: 1,
|
||||
name: "App1",
|
||||
appID: 1,
|
||||
actionID: 1001,
|
||||
xmlid: "menu_1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Burger Menu on home menu", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
expect(queryAllRoot(".o_burger_menu")).toHaveCount(0);
|
||||
expect(queryAllRoot(".o_home_menu")).toBeVisible();
|
||||
|
||||
await click(queryAllRoot(".o_mobile_menu_toggle"));
|
||||
await runAllTimers();
|
||||
await animationFrame();
|
||||
expect(queryAllRoot(".o_burger_menu")).toHaveCount(1);
|
||||
expect(queryAllRoot(".o_user_menu_mobile")).toHaveCount(1);
|
||||
await click(queryAllRoot(".o_sidebar_close"));
|
||||
await animationFrame();
|
||||
expect(".o_burger_menu").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Burger Menu on home menu over an App", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
actionRegistry.add("__test__client__action__", TestClientAction);
|
||||
|
||||
defineMenus([
|
||||
{
|
||||
id: 1,
|
||||
children: [
|
||||
{
|
||||
id: 99,
|
||||
name: "SubMenu",
|
||||
appID: 1,
|
||||
actionID: 1002,
|
||||
webIconData: undefined,
|
||||
webIcon: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
defineActions([
|
||||
{
|
||||
id: 1001,
|
||||
tag: "__test__client__action__",
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "Id 1" },
|
||||
},
|
||||
]);
|
||||
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
await click(queryAllRoot(".o_draggable:first-of-type .o_app"));
|
||||
await animationFrame();
|
||||
await click(queryAllRoot(".o_menu_toggle"));
|
||||
await animationFrame();
|
||||
await click(queryAllRoot(".o_sidebar_topbar a.btn-primary"));
|
||||
await animationFrame();
|
||||
|
||||
expect(queryAllRoot(".o_burger_menu")).toHaveCount(0);
|
||||
expect(queryAllRoot(".o_home_menu")).toBeVisible();
|
||||
|
||||
await click(queryAllRoot(".o_mobile_menu_toggle"));
|
||||
await animationFrame();
|
||||
expect(queryAllRoot(".o_burger_menu")).toHaveCount(1);
|
||||
expect(queryAllRoot(".o_burger_menu nav.o_burger_menu_content li")).toHaveCount(0);
|
||||
expect(queryAllRoot(".o_burger_menu_content")).not.toHaveClass("o_burger_menu_dark");
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { click } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
|
||||
|
||||
class Partner extends models.Model {
|
||||
foo = fields.Integer({ aggregator: "sum" });
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
foo: 12,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
foo: 1,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
foo: 17,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
foo: 2,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
defineModels([Partner]);
|
||||
|
||||
describe.current.tags("mobile");
|
||||
|
||||
test("simple pivot rendering", async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
await mountView({
|
||||
type: "pivot",
|
||||
resModel: "partner",
|
||||
arch: /* xml */ `
|
||||
<pivot string="Partners">
|
||||
<field name="foo" type="measure"/>
|
||||
</pivot>
|
||||
`,
|
||||
});
|
||||
|
||||
expect(".o_pivot_view").toHaveClass("o_view_controller");
|
||||
expect("td.o_pivot_cell_value:contains(32)").toHaveCount(1, {
|
||||
message: "should contain a pivot cell with the sum of all records",
|
||||
});
|
||||
});
|
||||
|
||||
test("unselecting all measures should not crash pivot rendering", async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
await mountView({
|
||||
type: "pivot",
|
||||
resModel: "partner",
|
||||
arch: /* xml */ `
|
||||
<pivot string="Partners">
|
||||
<field name="foo" type="measure"/>
|
||||
</pivot>
|
||||
`,
|
||||
});
|
||||
|
||||
await click(".dropdown-toggle.btn.btn-primary:eq(1)");
|
||||
await animationFrame();
|
||||
await click(".dropdown-item.o_menu_item.selected:eq(0)");
|
||||
await animationFrame();
|
||||
expect("div.o_nocontent_help").toHaveCount(1, {
|
||||
message: "Instead of error action helper will appear",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { click, queryFirst } from "@odoo/hoot-dom";
|
||||
import { animationFrame, mockMatchMedia } from "@odoo/hoot-mock";
|
||||
import {
|
||||
defineActions,
|
||||
defineModels,
|
||||
fields,
|
||||
getService,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { UserMenu } from "@web/webclient/user_menu/user_menu";
|
||||
import { WebClientOdex } from "@odex30_web/webclient/webclient";
|
||||
|
||||
class Partner extends models.Model {
|
||||
name = fields.Char();
|
||||
|
||||
_records = [
|
||||
{ id: 1, name: "First record" },
|
||||
{ id: 2, name: "Second record" },
|
||||
];
|
||||
_views = {
|
||||
form: `
|
||||
<form>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
</group>
|
||||
</form>
|
||||
`,
|
||||
kanban: `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="name"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
`,
|
||||
list: `<list><field name="name"/></list>`,
|
||||
};
|
||||
}
|
||||
|
||||
defineModels([Partner]);
|
||||
|
||||
defineActions([
|
||||
{
|
||||
id: 1,
|
||||
xml_id: "action_1",
|
||||
name: "Partners Action 1",
|
||||
res_model: "partner",
|
||||
views: [[false, "kanban"]],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
xml_id: "action_3",
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "kanban"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
describe.current.tags("mobile");
|
||||
|
||||
test("scroll position is kept", async () => {
|
||||
// This test relies on the fact that the scrollable element in mobile
|
||||
// is view's root node.
|
||||
const firstRecord = Partner._records[0];
|
||||
delete firstRecord.id;
|
||||
Partner._records = [...Array(80)].map((_, i) => ({
|
||||
...firstRecord,
|
||||
name: `Record ${i + 1}`,
|
||||
}));
|
||||
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
// partners in list/kanban
|
||||
await getService("action").doAction(3);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
|
||||
queryFirst(".o_kanban_view").scrollTo(0, 123);
|
||||
await click(".o_kanban_record:eq(20)");
|
||||
await animationFrame();
|
||||
expect(".o_form_view").toHaveCount(1);
|
||||
expect(".o_kanban_view").toHaveCount(0);
|
||||
|
||||
await click(".o_breadcrumb .o_back_button");
|
||||
await animationFrame();
|
||||
expect(".o_form_view").toHaveCount(0);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Share URL item is not present in the user menu when screen is small", async () => {
|
||||
mockMatchMedia({ ["display-mode"]: "standalone" });
|
||||
|
||||
await mountWithCleanup(UserMenu);
|
||||
|
||||
expect(".o_user_menu").toHaveCount(1);
|
||||
queryFirst(".o_user_menu").classList.remove("d-none");
|
||||
|
||||
await click(".o_user_menu button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_user_menu .dropdown-item").toHaveCount(0, {
|
||||
message: "share button is not visible",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/** @odoo-module */
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
registry.category("web_tour.tours").add("odex30_web.test_studio_list_upsell", {
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".o_list_view",
|
||||
},
|
||||
{
|
||||
trigger: ".o_optional_columns_dropdown > button",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: " .o-dropdown--menu .dropdown-item-studio",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { click, queryAll } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
getDropdownMenu,
|
||||
getService,
|
||||
models,
|
||||
mountView,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { user } from "@web/core/user";
|
||||
import { WebClientOdex } from "@odex30_web/webclient/webclient";
|
||||
|
||||
class Foo extends models.Model {
|
||||
foo = fields.Char();
|
||||
bar = fields.Boolean();
|
||||
|
||||
_records = [
|
||||
{ id: 1, bar: true, foo: "yop" },
|
||||
{ id: 2, bar: true, foo: "blip" },
|
||||
{ id: 3, bar: true, foo: "gnap" },
|
||||
{ id: 4, bar: false, foo: "blip" },
|
||||
];
|
||||
}
|
||||
|
||||
defineModels([Foo]);
|
||||
|
||||
const getDefaultConfig = () => ({
|
||||
actionId: 1,
|
||||
actionType: "ir.actions.act_window",
|
||||
});
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
beforeEach(() => {
|
||||
onRpc("has_group", () => true);
|
||||
});
|
||||
|
||||
test("add custom field button with other optional columns - studio not installed", async () => {
|
||||
expect.assertions(8);
|
||||
|
||||
onRpc("search_read", ({ model }) => {
|
||||
if (model === "ir.module.module") {
|
||||
expect.step("studio_module_id");
|
||||
return [{ id: 42 }];
|
||||
}
|
||||
});
|
||||
onRpc("button_immediate_install", ({ model, args }) => {
|
||||
if (model === "ir.module.module") {
|
||||
expect(args[0]).toEqual([42], {
|
||||
message: "Should be the id of studio module returned by the search read",
|
||||
});
|
||||
expect.step("studio_module_install");
|
||||
return true;
|
||||
}
|
||||
});
|
||||
await mountView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
arch: /* xml */ `
|
||||
<list>
|
||||
<field name="foo"/>
|
||||
<field name="bar" optional="hide"/>
|
||||
</list>
|
||||
`,
|
||||
config: getDefaultConfig(),
|
||||
});
|
||||
|
||||
patchWithCleanup(browser.location, {
|
||||
reload: function () {
|
||||
expect.step("window_reload");
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_data_row").toHaveCount(4);
|
||||
expect(".o_optional_columns_dropdown_toggle").toHaveCount(1);
|
||||
|
||||
await click(".o_optional_columns_dropdown_toggle");
|
||||
await animationFrame();
|
||||
const dropdown = getDropdownMenu(".o_optional_columns_dropdown");
|
||||
|
||||
expect(queryAll(".dropdown-item", { root: dropdown })).toHaveCount(2);
|
||||
expect(queryAll(".dropdown-item-studio", { root: dropdown })).toHaveCount(1);
|
||||
|
||||
await click(".dropdown-item-studio");
|
||||
await animationFrame();
|
||||
expect(".modal-studio").toHaveCount(1);
|
||||
|
||||
await click(".modal .o_install_studio");
|
||||
await animationFrame();
|
||||
expect(browser.localStorage.getItem("openStudioOnReload")).toBe("main");
|
||||
expect.verifySteps(["studio_module_id", "studio_module_install", "window_reload"]);
|
||||
});
|
||||
|
||||
test("add custom field button without other optional columns - studio not installed", async () => {
|
||||
expect.assertions(8);
|
||||
|
||||
onRpc("search_read", ({ model }) => {
|
||||
if (model === "ir.module.module") {
|
||||
expect.step("studio_module_id");
|
||||
return [{ id: 42 }];
|
||||
}
|
||||
});
|
||||
onRpc("button_immediate_install", ({ model, args }) => {
|
||||
if (model === "ir.module.module") {
|
||||
expect(args[0]).toEqual([42], {
|
||||
message: "Should be the id of studio module returned by the search read",
|
||||
});
|
||||
expect.step("studio_module_install");
|
||||
return true;
|
||||
}
|
||||
});
|
||||
await mountView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
config: getDefaultConfig(),
|
||||
arch: /* xml */ `
|
||||
<list>
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
|
||||
patchWithCleanup(browser.location, {
|
||||
reload: function () {
|
||||
expect.step("window_reload");
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_data_row").toHaveCount(4);
|
||||
expect(".o_optional_columns_dropdown_toggle").toHaveCount(1);
|
||||
|
||||
await click(".o_optional_columns_dropdown_toggle");
|
||||
await animationFrame();
|
||||
const dropdown = getDropdownMenu(".o_optional_columns_dropdown");
|
||||
|
||||
expect(queryAll(".dropdown-item", { root: dropdown })).toHaveCount(1);
|
||||
expect(queryAll(".dropdown-item-studio", { root: dropdown })).toHaveCount(1);
|
||||
|
||||
await click(".dropdown-item-studio");
|
||||
await animationFrame();
|
||||
expect(".modal-studio").toHaveCount(1);
|
||||
|
||||
await click(".modal .o_install_studio");
|
||||
await animationFrame();
|
||||
expect(browser.localStorage.getItem("openStudioOnReload")).toBe("main");
|
||||
expect.verifySteps(["studio_module_id", "studio_module_install", "window_reload"]);
|
||||
});
|
||||
|
||||
test("add custom field button not shown to non-system users (with opt. col.)", async () => {
|
||||
expect.assertions(3);
|
||||
|
||||
patchWithCleanup(user, { isSystem: false });
|
||||
|
||||
await mountView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
config: getDefaultConfig(),
|
||||
arch: /* xml */ `
|
||||
<list>
|
||||
<field name="foo"/>
|
||||
<field name="bar" optional="hide"/>
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
|
||||
expect(".o_optional_columns_dropdown_toggle").toHaveCount(1);
|
||||
|
||||
await click(".o_optional_columns_dropdown_toggle");
|
||||
await animationFrame();
|
||||
const dropdown = getDropdownMenu(".o_optional_columns_dropdown");
|
||||
expect(queryAll(".dropdown-item", { root: dropdown })).toHaveCount(1);
|
||||
expect(queryAll(".dropdown-item-studio", { root: dropdown })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("add custom field button not shown to non-system users (wo opt. col.)", async () => {
|
||||
patchWithCleanup(user, { isSystem: false });
|
||||
|
||||
await mountView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
config: getDefaultConfig(),
|
||||
arch: /* xml */ `
|
||||
<list>
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
|
||||
expect(".o_optional_columns_dropdown_toggle").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("add custom field button not shown with invalid action", async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
patchWithCleanup(user, { isSystem: false });
|
||||
|
||||
await mountView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
config: { ...getDefaultConfig(), actionId: null },
|
||||
arch: /* xml */ `
|
||||
<list>
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
|
||||
expect(".o_optional_columns_dropdown_toggle").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("add custom field button not shown with bank statement line model", async () => {
|
||||
class AccountBankStatementLine extends models.Model {
|
||||
name = fields.Char();
|
||||
_views = {
|
||||
kanban: `
|
||||
<kanban>
|
||||
<template>
|
||||
<t t-name="card">
|
||||
<field name="display_name"/>
|
||||
</t>
|
||||
</template>
|
||||
</kanban>
|
||||
`,
|
||||
list: `<list><field name="display_name" /> <field name="name" optional="1" /></list>`,
|
||||
};
|
||||
}
|
||||
defineModels([AccountBankStatementLine]);
|
||||
|
||||
expect.assertions(3);
|
||||
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
await getService("action").doAction({
|
||||
xml_id: "test",
|
||||
id: 1312,
|
||||
name: "test",
|
||||
res_id: 1,
|
||||
res_model: "account.bank.statement.line",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "kanban"], [false, "list"]],
|
||||
});
|
||||
|
||||
expect("button.o_switch_view.o_list[data-tooltip='List']").toHaveCount(1);
|
||||
await contains("button.o_switch_view.o_list[data-tooltip='List']").click();
|
||||
expect(".o_list_renderer .o_list_controller button.dropdown-toggle").toHaveCount(1);
|
||||
await contains(".o_list_renderer .o_list_controller button.dropdown-toggle").click();
|
||||
expect(".dropdown-item-studio").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("x2many should not be editable", async () => {
|
||||
class Bar extends models.Model {}
|
||||
defineModels([Bar]);
|
||||
Foo._fields.o2m = fields.One2many({ relation: "bar" });
|
||||
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "foo",
|
||||
arch: /* xml */ `
|
||||
<form>
|
||||
<notebook>
|
||||
<page>
|
||||
<field name="o2m">
|
||||
<list>
|
||||
<field name="display_name"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page><div class="test_empty_page" /></page>
|
||||
</notebook>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
expect(".o_optional_columns_dropdown_toggle").toHaveCount(0);
|
||||
await click(".nav-link:eq(1)");
|
||||
await animationFrame();
|
||||
await click(".nav-link:eq(0)");
|
||||
await animationFrame();
|
||||
expect(".o_field_widget").toHaveCount(1);
|
||||
expect(".o_optional_columns_dropdown_toggle").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("upsell studio feature is not polluted by another view", async () => {
|
||||
class Partner extends models.Model {
|
||||
name = fields.Char();
|
||||
|
||||
_views = {
|
||||
list: `<list><field name="display_name" /> <field name="name" optional="1" /></list>`,
|
||||
};
|
||||
}
|
||||
|
||||
defineModels([Partner]);
|
||||
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
await getService("action").doAction({
|
||||
xml_id: "editable",
|
||||
id: 999,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "list"]],
|
||||
res_model: "partner",
|
||||
});
|
||||
|
||||
await click(".o_optional_columns_dropdown_toggle");
|
||||
await animationFrame();
|
||||
expect(".dropdown-item").toHaveCount(2);
|
||||
expect(".dropdown-item-studio").toHaveCount(1);
|
||||
|
||||
await getService("action").doAction({
|
||||
id: 99,
|
||||
xml_id: "in_dialog",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "list"]],
|
||||
res_model: "partner",
|
||||
target: "new",
|
||||
});
|
||||
|
||||
await click(".modal .o_optional_columns_dropdown_toggle");
|
||||
await animationFrame();
|
||||
let dropdown = getDropdownMenu(".modal .o_optional_columns_dropdown");
|
||||
expect(queryAll(".dropdown-item", { root: dropdown })).toHaveCount(1);
|
||||
expect(queryAll(".dropdown-item-studio", { root: dropdown })).toHaveCount(0);
|
||||
await click(".modal-header .btn-close");
|
||||
await animationFrame();
|
||||
expect(".modal").toHaveCount(0);
|
||||
|
||||
await click(".o_optional_columns_dropdown_toggle");
|
||||
await animationFrame();
|
||||
expect(".o-dropdown--menu").toHaveCount(0);
|
||||
await click(".o_optional_columns_dropdown_toggle");
|
||||
await animationFrame();
|
||||
|
||||
dropdown = getDropdownMenu(".o_optional_columns_dropdown");
|
||||
expect(queryAll(".dropdown-item", { root: dropdown })).toHaveCount(2);
|
||||
expect(queryAll(".dropdown-item-studio", { root: dropdown })).toHaveCount(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { click } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import {
|
||||
defineActions,
|
||||
defineModels,
|
||||
getService,
|
||||
fields,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
stepAllNetworkCalls,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { redirect } from "@web/core/utils/urls";
|
||||
import { WebClientOdex } from "@odex30_web/webclient/webclient";
|
||||
|
||||
class Partner extends models.Model {
|
||||
name = fields.Char();
|
||||
|
||||
_records = [
|
||||
{ id: 1, name: "First record" },
|
||||
{ id: 2, name: "Second record" },
|
||||
];
|
||||
_views = {
|
||||
form: `
|
||||
<form>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
</group>
|
||||
</form>
|
||||
`,
|
||||
kanban: `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="name"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
`,
|
||||
list: `<list><field name="name"/></list>`,
|
||||
};
|
||||
}
|
||||
|
||||
defineModels([Partner]);
|
||||
|
||||
defineActions([
|
||||
{
|
||||
id: 1,
|
||||
name: "Partners Action 1",
|
||||
res_model: "partner",
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "kanban"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Partners Action 2",
|
||||
res_model: "partner",
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
describe.current.tags("mobile");
|
||||
|
||||
test("uses a mobile-friendly view by default (if possible)", async () => {
|
||||
onRpc("has_group", () => true);
|
||||
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
// should default on a mobile-friendly view (kanban) for action 1
|
||||
await getService("action").doAction(1);
|
||||
|
||||
expect(".o_list_view").toHaveCount(0);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
|
||||
// there is no mobile-friendly view for action 2, should use the first one (list)
|
||||
await getService("action").doAction(2);
|
||||
|
||||
expect(".o_list_view").toHaveCount(1);
|
||||
expect(".o_kanban_view").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("lazy load mobile-friendly view", async () => {
|
||||
stepAllNetworkCalls();
|
||||
|
||||
redirect("/odoo/action-1/new");
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_list_view").toHaveCount(0);
|
||||
expect(".o_kanban_view").toHaveCount(0);
|
||||
expect(".o_form_view").toHaveCount(1);
|
||||
|
||||
// go back to lazy loaded view
|
||||
await click(".o_breadcrumb .o_back_button");
|
||||
await animationFrame();
|
||||
expect(".o_list_view").toHaveCount(0);
|
||||
expect(".o_form_view").toHaveCount(0);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
|
||||
expect.verifySteps([
|
||||
"/web/webclient/translations",
|
||||
"/web/webclient/load_menus",
|
||||
"/web/action/load",
|
||||
"get_views",
|
||||
"onchange", // default_get/onchange to open form view
|
||||
"web_search_read", // web search read when coming back to Kanban
|
||||
]);
|
||||
});
|
||||
|
||||
test("lazy load mobile-friendly view; legacy url", async () => {
|
||||
stepAllNetworkCalls();
|
||||
|
||||
redirect("/web#action=1&view_type=form");
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_list_view").toHaveCount(0);
|
||||
expect(".o_kanban_view").toHaveCount(0);
|
||||
expect(".o_form_view").toHaveCount(1);
|
||||
|
||||
// go back to lazy loaded view
|
||||
await click(".o_breadcrumb .o_back_button");
|
||||
await animationFrame();
|
||||
expect(".o_list_view").toHaveCount(0);
|
||||
expect(".o_form_view").toHaveCount(0);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
|
||||
expect.verifySteps([
|
||||
"/web/webclient/translations",
|
||||
"/web/webclient/load_menus",
|
||||
"/web/action/load",
|
||||
"get_views",
|
||||
"onchange", // default_get/onchange to open form view
|
||||
"web_search_read", // web search read when coming back to Kanban
|
||||
]);
|
||||
});
|
||||
|
||||
test("view switcher button should be displayed in dropdown on mobile screens", async () => {
|
||||
// This test will spawn a kanban view (mobile friendly).
|
||||
// so, the "legacy" code won't be tested here.
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
await getService("action").doAction(1);
|
||||
|
||||
expect(".o_control_panel .o_cp_switch_buttons > button").toHaveCount(1);
|
||||
expect(".o_control_panel .o_cp_switch_buttons .o_switch_view.o_kanban").toHaveCount(0);
|
||||
expect(".o_control_panel .o_cp_switch_buttons button.o_switch_view").toHaveCount(0);
|
||||
|
||||
expect(".o_control_panel .o_cp_switch_buttons > button > i").toHaveClass("oi-view-kanban");
|
||||
await click(".o_control_panel .o_cp_switch_buttons > button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".dropdown-item:has(.oi-view-kanban)").toHaveClass("selected");
|
||||
expect(".dropdown-item:has(.oi-view-list)").not.toHaveClass("selected");
|
||||
});
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { Deferred, mockDate } from "@odoo/hoot-mock";
|
||||
import {
|
||||
defineActions,
|
||||
defineMenus,
|
||||
defineModels,
|
||||
fields,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
import { SUCCESS_SIGNAL } from "@web/webclient/clickbot/clickbot";
|
||||
|
||||
class Foo extends models.Model {
|
||||
foo = fields.Char();
|
||||
bar = fields.Boolean();
|
||||
date = fields.Date();
|
||||
|
||||
_records = [
|
||||
{ id: 1, bar: true, foo: "yop", date: "2017-01-25" },
|
||||
{ id: 2, bar: true, foo: "blip" },
|
||||
{ id: 3, bar: true, foo: "gnap" },
|
||||
{ id: 4, bar: false, foo: "blip" },
|
||||
];
|
||||
|
||||
_views = {
|
||||
search: /* xml */ `
|
||||
<search>
|
||||
<filter string="Not Bar" name="not bar" domain="[['bar','=',False]]"/>
|
||||
<filter string="Date" name="date" date="date"/>
|
||||
</search>
|
||||
`,
|
||||
list: /* xml */ `
|
||||
<list>
|
||||
<field name="foo" />
|
||||
</list>
|
||||
`,
|
||||
kanban: /* xml */ `
|
||||
<kanban class="o_kanban_test">
|
||||
<templates><t t-name="card">
|
||||
<field name="foo"/>
|
||||
</t></templates>
|
||||
</kanban>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
defineModels([Foo]);
|
||||
|
||||
beforeEach(() => {
|
||||
defineActions([
|
||||
{
|
||||
id: 1001,
|
||||
name: "App1",
|
||||
res_model: "foo",
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "kanban"],
|
||||
],
|
||||
xml_id: "app1",
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
name: "App2 Menu 1",
|
||||
res_model: "foo",
|
||||
views: [[false, "kanban"]],
|
||||
xml_id: "app2_menu1",
|
||||
},
|
||||
{
|
||||
id: 1022,
|
||||
name: "App2 Menu 2",
|
||||
res_model: "foo",
|
||||
views: [[false, "list"]],
|
||||
xml_id: "app2_menu2",
|
||||
},
|
||||
]);
|
||||
defineMenus([
|
||||
{ id: 1, name: "App1", appID: 1, actionID: 1001, xmlid: "app1" },
|
||||
{
|
||||
id: 2,
|
||||
children: [
|
||||
{
|
||||
id: 3,
|
||||
name: "menu 1",
|
||||
appID: 2,
|
||||
actionID: 1002,
|
||||
xmlid: "app2_menu1",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "menu 2",
|
||||
appID: 2,
|
||||
actionID: 1022,
|
||||
xmlid: "app2_menu2",
|
||||
},
|
||||
],
|
||||
name: "App2",
|
||||
appID: 2,
|
||||
actionID: 1002,
|
||||
xmlid: "app2",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("clickbot clickeverywhere test", async () => {
|
||||
onRpc("has_group", () => true);
|
||||
mockDate("2017-10-08T15:35:11.000");
|
||||
const clickEverywhereDef = new Deferred();
|
||||
patchWithCleanup(browser, {
|
||||
console: {
|
||||
log: (msg) => {
|
||||
expect.step(msg);
|
||||
if (msg === SUCCESS_SIGNAL) {
|
||||
clickEverywhereDef.resolve();
|
||||
}
|
||||
},
|
||||
error: (msg) => {
|
||||
expect.step(msg);
|
||||
clickEverywhereDef.resolve();
|
||||
},
|
||||
},
|
||||
});
|
||||
const webClient = await mountWithCleanup(WebClient);
|
||||
patchWithCleanup(odoo, {
|
||||
__WOWL_DEBUG__: { root: webClient },
|
||||
});
|
||||
window.clickEverywhere();
|
||||
await clickEverywhereDef;
|
||||
expect.verifySteps([
|
||||
"Clicking on: apps menu toggle button",
|
||||
"Testing app menu: app1",
|
||||
"Testing menu App1 app1",
|
||||
'Clicking on: menu item "App1"',
|
||||
"Testing 2 filters",
|
||||
'Clicking on: filter "Not Bar"',
|
||||
'Clicking on: filter "Date"',
|
||||
'Clicking on: filter option "October"',
|
||||
"Testing view switch: kanban",
|
||||
"Clicking on: kanban view switcher",
|
||||
"Testing 2 filters",
|
||||
'Clicking on: filter "Not Bar"',
|
||||
'Clicking on: filter "Date"',
|
||||
'Clicking on: filter option "October"',
|
||||
"Clicking on: apps menu toggle button",
|
||||
"Testing app menu: app2",
|
||||
"Testing menu App2 app2",
|
||||
'Clicking on: menu item "App2"',
|
||||
"Testing 2 filters",
|
||||
'Clicking on: filter "Not Bar"',
|
||||
'Clicking on: filter "Date"',
|
||||
'Clicking on: filter option "October"',
|
||||
"Testing menu menu 1 app2_menu1",
|
||||
'Clicking on: menu item "menu 1"',
|
||||
"Testing 2 filters",
|
||||
'Clicking on: filter "Not Bar"',
|
||||
'Clicking on: filter "Date"',
|
||||
'Clicking on: filter option "October"',
|
||||
"Testing menu menu 2 app2_menu2",
|
||||
'Clicking on: menu item "menu 2"',
|
||||
"Testing 2 filters",
|
||||
'Clicking on: filter "Not Bar"',
|
||||
'Clicking on: filter "Date"',
|
||||
'Clicking on: filter option "October"',
|
||||
"Successfully tested 2 apps",
|
||||
"Successfully tested 2 menus",
|
||||
"Successfully tested 0 modals",
|
||||
"Successfully tested 10 filters",
|
||||
SUCCESS_SIGNAL,
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,615 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { click, queryFirst, edit } from "@odoo/hoot-dom";
|
||||
import { animationFrame, mockDate, runAllTimers } from "@odoo/hoot-mock";
|
||||
import {
|
||||
getService,
|
||||
mockService,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { session } from "@web/session";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { WebClientOdex } from "@odex30_web/webclient/webclient";
|
||||
|
||||
test("Expiration Panel one app installed", async () => {
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-11-09 12:00:00",
|
||||
expiration_reason: "",
|
||||
storeData: true,
|
||||
warning: "admin",
|
||||
});
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
await getService("action").doAction("menu");
|
||||
|
||||
expect(".oe_instance_register").toHaveText("This database will expire in 1 month.");
|
||||
|
||||
expect(".database_expiration_panel").toHaveClass("alert-info");
|
||||
|
||||
// Close the expiration panel
|
||||
await click(".oe_instance_hide_panel");
|
||||
await animationFrame();
|
||||
|
||||
expect(".database_expiration_panel").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Expiration Panel one app installed, buy subscription", async () => {
|
||||
expect.assertions(6);
|
||||
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-24 12:00:00",
|
||||
expiration_reason: "demo",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
onRpc("res.users", "search_count", () => 7);
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
await runAllTimers();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"This demo database will expire in 14 days. Register your subscription or buy a subscription."
|
||||
);
|
||||
|
||||
expect(".database_expiration_panel").toHaveClass("alert-warning", {
|
||||
message: "Color should be orange",
|
||||
});
|
||||
|
||||
expect(".oe_instance_register_show").toHaveCount(1, {
|
||||
message: "Part 'Register your subscription'",
|
||||
});
|
||||
expect(".oe_instance_buy").toHaveCount(1, { message: "Part 'buy a subscription'" });
|
||||
expect(".oe_instance_register_form").toHaveCount(0, {
|
||||
message: "There should be no registration form",
|
||||
});
|
||||
|
||||
// Click on 'buy subscription'
|
||||
await click(".oe_instance_buy");
|
||||
await animationFrame();
|
||||
|
||||
expect(browser.location.href).toBe("https://www.odoo.com/odoo-enterprise/upgrade?num_users=7");
|
||||
});
|
||||
|
||||
test("Expiration Panel one app installed, try several times to register subscription", async () => {
|
||||
expect.assertions(33);
|
||||
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
|
||||
let callToGetParamCount = 0;
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-15 12:00:00",
|
||||
expiration_reason: "trial",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
|
||||
mockService("notification", {
|
||||
add: (message, options) => {
|
||||
expect.step(JSON.stringify({ message, options }));
|
||||
},
|
||||
});
|
||||
onRpc("get_param", ({ args }) => {
|
||||
expect.step("get_param");
|
||||
if (args[0] === "database.already_linked_subscription_url") {
|
||||
return false;
|
||||
}
|
||||
if (args[0] === "database.already_linked_email") {
|
||||
return "super_company_admin@gmail.com";
|
||||
}
|
||||
expect(args[0]).toBe("database.expiration_date");
|
||||
callToGetParamCount++;
|
||||
if (callToGetParamCount <= 3) {
|
||||
return "2019-10-15 12:00:00";
|
||||
} else {
|
||||
return "2019-11-15 12:00:00";
|
||||
}
|
||||
});
|
||||
onRpc("set_param", ({ args }) => {
|
||||
expect.step("set_param");
|
||||
expect(args[0]).toBe("database.enterprise_code");
|
||||
if (callToGetParamCount === 1) {
|
||||
expect(args[1]).toBe("ABCDEF");
|
||||
} else {
|
||||
expect(args[1]).toBe("ABC");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
onRpc("update_notification", ({ args }) => {
|
||||
expect.step("update_notification");
|
||||
expect(args[0]).toBeInstanceOf(Array);
|
||||
expect(args[0]).toHaveLength(0);
|
||||
return true;
|
||||
});
|
||||
onRpc("res.users", "search_count", () => 7);
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"This database will expire in 5 days. Register your subscription or buy a subscription."
|
||||
);
|
||||
|
||||
expect(".database_expiration_panel").toHaveClass("alert-danger", {
|
||||
message: "Color should be red",
|
||||
});
|
||||
|
||||
expect(".oe_instance_register_show").toHaveCount(1, {
|
||||
message: "Part 'Register your subscription'",
|
||||
});
|
||||
expect(".oe_instance_buy").toHaveCount(1, { message: "Part 'buy a subscription'" });
|
||||
expect(".oe_instance_register_form").toHaveCount(0, {
|
||||
message: "There should be no registration form",
|
||||
});
|
||||
|
||||
// Click on 'buy subscription'
|
||||
await click(".oe_instance_register_show");
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register_form").toHaveCount(1, {
|
||||
message: "there should be a registration form",
|
||||
});
|
||||
expect('.oe_instance_register_form input[placeholder="Paste code here"]').toHaveCount(1, {
|
||||
message: "with an input with place holder 'Paste code here'",
|
||||
});
|
||||
expect(".oe_instance_register_form button").toHaveCount(1, {
|
||||
message: "and a button 'Register'",
|
||||
});
|
||||
expect(".oe_instance_register_form button").toHaveText("Register");
|
||||
|
||||
await click(".oe_instance_register_form button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register_form").toHaveCount(1, {
|
||||
message: "there should be a registration form",
|
||||
});
|
||||
expect('.oe_instance_register_form input[placeholder="Paste code here"]').toHaveCount(1, {
|
||||
message: "with an input with place holder 'Paste code here'",
|
||||
});
|
||||
expect(".oe_instance_register_form button").toHaveCount(1, {
|
||||
message: "and a button 'Register'",
|
||||
});
|
||||
|
||||
await click(".oe_instance_register_form input");
|
||||
await edit("ABCDEF");
|
||||
await animationFrame();
|
||||
await click(".oe_instance_register_form button");
|
||||
await animationFrame();
|
||||
|
||||
expect(queryFirst(".oe_instance_register")).toHaveText(
|
||||
"Something went wrong while registering your database. You can try again or contact Odoo Support."
|
||||
);
|
||||
expect(".database_expiration_panel").toHaveClass("alert-danger", {
|
||||
message: "Color should be red",
|
||||
});
|
||||
expect("span.oe_instance_error").toHaveCount(1);
|
||||
expect(".oe_instance_register_form").toHaveCount(1, {
|
||||
message: "there should be a registration form",
|
||||
});
|
||||
expect('.oe_instance_register_form input[placeholder="Paste code here"]').toHaveCount(1, {
|
||||
message: "with an input with place holder 'Paste code here'",
|
||||
});
|
||||
expect(".oe_instance_register_form button").toHaveCount(1, {
|
||||
message: "and a button 'Register'",
|
||||
});
|
||||
expect(".oe_instance_register_form button").toHaveText("Retry");
|
||||
|
||||
await click(".oe_instance_register_form input");
|
||||
await edit("ABC");
|
||||
await animationFrame();
|
||||
await click(".oe_instance_register_form button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".database_expiration_panel").toHaveCount(0, {
|
||||
message: "expiration panel should be gone",
|
||||
});
|
||||
|
||||
expect.verifySteps([
|
||||
// second try to submit
|
||||
"get_param",
|
||||
"set_param",
|
||||
"update_notification",
|
||||
"get_param",
|
||||
"get_param",
|
||||
"get_param",
|
||||
// third try
|
||||
"get_param",
|
||||
"set_param",
|
||||
"update_notification",
|
||||
"get_param",
|
||||
"get_param",
|
||||
"get_param",
|
||||
`{"message":"Thank you, your registration was successful! Your database is valid until November 15, 2019.","options":{"type":"success"}}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("Expiration Panel one app installed, subscription already linked", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
|
||||
let getExpirationDateCount = 0;
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-15 12:00:00",
|
||||
expiration_reason: "trial",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
onRpc("/already/linked/send/mail/url", () => ({
|
||||
result: false,
|
||||
reason: "By design",
|
||||
}));
|
||||
onRpc("get_param", ({ args }) => {
|
||||
expect.step("get_param");
|
||||
if (args[0] === "database.expiration_date") {
|
||||
getExpirationDateCount++;
|
||||
if (getExpirationDateCount === 1) {
|
||||
return "2019-10-15 12:00:00";
|
||||
} else {
|
||||
return "2019-11-17 12:00:00";
|
||||
}
|
||||
}
|
||||
if (args[0] === "database.already_linked_subscription_url") {
|
||||
return "www.super_company.com";
|
||||
}
|
||||
if (args[0] === "database.already_linked_send_mail_url") {
|
||||
return "/already/linked/send/mail/url";
|
||||
}
|
||||
if (args[0] === "database.already_linked_email") {
|
||||
return "super_company_admin@gmail.com";
|
||||
}
|
||||
});
|
||||
onRpc("set_param", () => {
|
||||
expect.step("set_param");
|
||||
return true;
|
||||
});
|
||||
onRpc("update_notification", () => {
|
||||
expect.step("update_notification");
|
||||
return true;
|
||||
});
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"This database will expire in 5 days. Register your subscription or buy a subscription."
|
||||
);
|
||||
// Click on 'register your subscription'
|
||||
await click(".oe_instance_register_show");
|
||||
await animationFrame();
|
||||
await click(".oe_instance_register_form input");
|
||||
await edit("ABC");
|
||||
await click(".oe_instance_register_form button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register.oe_database_already_linked").toHaveText(
|
||||
`Your subscription is already linked to a database.\nSend an email to the subscription owner to confirm the change, enter a new code or buy a subscription.`
|
||||
);
|
||||
|
||||
await click("a.oe_contract_send_mail");
|
||||
await animationFrame();
|
||||
expect(".database_expiration_panel").toHaveClass("alert-danger", {
|
||||
message: "Color should be red",
|
||||
});
|
||||
|
||||
expect(".oe_instance_register.oe_database_already_linked").toHaveText(
|
||||
`Your subscription is already linked to a database.\nSend an email to the subscription owner to confirm the change, enter a new code or buy a subscription.\n\nUnable to send the instructions by email, please contact the Odoo Support\nError reason: By design`
|
||||
);
|
||||
|
||||
expect.verifySteps([
|
||||
"get_param",
|
||||
"set_param",
|
||||
"update_notification",
|
||||
"get_param",
|
||||
"get_param",
|
||||
"get_param",
|
||||
"get_param",
|
||||
]);
|
||||
});
|
||||
|
||||
test("One app installed, database expired", async () => {
|
||||
expect.assertions(8);
|
||||
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
|
||||
let callToGetParamCount = 0;
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-08 12:00:00",
|
||||
expiration_reason: "trial",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
onRpc("/already/linked/send/mail/url", () => ({
|
||||
result: false,
|
||||
reason: "By design",
|
||||
}));
|
||||
onRpc("get_param", ({ args }) => {
|
||||
if (args[0] === "database.already_linked_subscription_url") {
|
||||
return false;
|
||||
}
|
||||
callToGetParamCount++;
|
||||
if (callToGetParamCount === 1) {
|
||||
return "2019-10-09 12:00:00";
|
||||
} else {
|
||||
return "2019-11-09 12:00:00";
|
||||
}
|
||||
});
|
||||
onRpc("set_param", () => true);
|
||||
onRpc("update_notification", () => true);
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"This database has expired. Register your subscription or buy a subscription."
|
||||
);
|
||||
expect(".o_blockUI").toHaveCount(1, { message: "UI should be blocked" });
|
||||
|
||||
expect(".database_expiration_panel").toHaveClass("alert-danger", {
|
||||
message: "Color should be red",
|
||||
});
|
||||
expect(".oe_instance_register_show").toHaveCount(1, {
|
||||
message: "Part 'Register your subscription'",
|
||||
});
|
||||
expect(".oe_instance_buy").toHaveCount(1, { message: "Part 'buy a subscription'" });
|
||||
|
||||
expect(".oe_instance_register_form").toHaveCount(0);
|
||||
|
||||
// Click on 'Register your subscription'
|
||||
await click(".oe_instance_register_show");
|
||||
await animationFrame();
|
||||
await click(".oe_instance_register_form input");
|
||||
await edit("ABC");
|
||||
await click(".oe_instance_register_form button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"Thank you, your registration was successful! Your database is valid until November 9, 2019."
|
||||
);
|
||||
expect(".o_blockUI").toHaveCount(0, { message: "UI should no longer be blocked" });
|
||||
});
|
||||
|
||||
test("One app installed, renew", async () => {
|
||||
expect.assertions(7);
|
||||
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-20 12:00:00",
|
||||
expiration_reason: "renewal",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
onRpc("get_param", ({ args }) => {
|
||||
expect.step("get_param");
|
||||
expect(args[0]).toBe("database.enterprise_code");
|
||||
return "ABC";
|
||||
});
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"Your subscription expired 9 days ago. This database will be blocked soon.\n" +
|
||||
"Renew now\n" +
|
||||
"I paid, please recheck!"
|
||||
);
|
||||
|
||||
expect(".database_expiration_panel").toHaveClass("alert-warning", {
|
||||
message: "Color should be orange",
|
||||
});
|
||||
expect(".oe_instance_renew").toHaveCount(1, { message: "Part 'Register your subscription'" });
|
||||
expect("a.check_enterprise_status").toHaveCount(1, {
|
||||
message: "there should be a button for status checking",
|
||||
});
|
||||
|
||||
expect(".oe_instance_register_form").toHaveCount(0);
|
||||
|
||||
// Click on 'Renew your subscription'
|
||||
await click(".oe_instance_renew");
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["get_param"]);
|
||||
});
|
||||
|
||||
test("One app installed, check status and get success", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-20 12:00:00",
|
||||
expiration_reason: "renewal",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
onRpc("get_param", ({ args }) => {
|
||||
expect.step("get_param");
|
||||
expect(args[0]).toBe("database.expiration_date");
|
||||
return "2019-10-24 12:00:00";
|
||||
});
|
||||
onRpc("update_notification", () => {
|
||||
expect.step("update_notification");
|
||||
return true;
|
||||
});
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
// click on "I paid, please recheck!"
|
||||
expect("a.check_enterprise_status").toHaveText("I paid, please recheck!");
|
||||
await click("a.check_enterprise_status");
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register.oe_subscription_updated").toHaveText(
|
||||
"Your subscription was updated and is valid until October 24, 2019."
|
||||
);
|
||||
|
||||
expect.verifySteps(["update_notification", "get_param"]);
|
||||
});
|
||||
|
||||
// Why would we want to reload the page when we check the status and it hasn't changed?
|
||||
test.skip("One app installed, check status and get page reload", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-20 12:00:00",
|
||||
expiration_reason: "renewal",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
onRpc("get_param", () => {
|
||||
expect.step("get_param");
|
||||
return "2019-10-20 12:00:00";
|
||||
});
|
||||
onRpc("update_notification", () => {
|
||||
expect.step("update_notification");
|
||||
return true;
|
||||
});
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
// click on "I paid, please recheck!"
|
||||
await click("a.check_enterprise_status");
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["update_notification", "get_param", "reloadPage"]);
|
||||
});
|
||||
|
||||
test("One app installed, upgrade database", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-20 12:00:00",
|
||||
expiration_reason: "upsell",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
onRpc("get_param", ({ args }) => {
|
||||
expect.step("get_param");
|
||||
expect(args[0]).toBe("database.enterprise_code");
|
||||
return "ABC";
|
||||
});
|
||||
onRpc("search_count", () => {
|
||||
expect.step("search_count");
|
||||
return 13;
|
||||
});
|
||||
onRpc("update_notification", () => true);
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
await runAllTimers();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"This database will expire in 10 days. You have more users or more apps installed than your subscription allows.\n\n" +
|
||||
"Upgrade your subscription\n" +
|
||||
"I paid, please recheck!"
|
||||
);
|
||||
|
||||
await click("a.oe_instance_upsell");
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["get_param", "search_count"]);
|
||||
expect(browser.location.href).toBe(
|
||||
"https://www.odoo.com/odoo-enterprise/upsell?num_users=13&contract=ABC"
|
||||
);
|
||||
});
|
||||
|
||||
test("One app installed, message for non admin user", async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
mockDate("2019-10-10T12:00:00");
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-11-08 12:00:00",
|
||||
expiration_reason: "",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "user",
|
||||
});
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"This database will expire in 29 days. Log in as an administrator to correct the issue."
|
||||
);
|
||||
|
||||
expect(".database_expiration_panel").toHaveClass("alert-info", {
|
||||
message: "Color should be grey",
|
||||
});
|
||||
});
|
||||
|
||||
test("One app installed, navigation to renewal page", async () => {
|
||||
expect.assertions(8);
|
||||
|
||||
mockDate("2019-11-10T00:00:00");
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-20 12:00:00",
|
||||
expiration_reason: "renewal",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
onRpc("get_param", ({ args }) => {
|
||||
expect.step("get_param");
|
||||
expect(args[0]).toBe("database.enterprise_code");
|
||||
return "ABC";
|
||||
});
|
||||
onRpc("update_notification", () => {
|
||||
expect.step("update_notification");
|
||||
return true;
|
||||
});
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
await runAllTimers();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"This database has expired.\nRenew now\nI paid, please recheck!"
|
||||
);
|
||||
|
||||
expect(".database_expiration_panel").toHaveClass("alert-danger");
|
||||
expect(".oe_instance_renew").toHaveCount(1, { message: "Part 'Register your subscription'" });
|
||||
expect("a.check_enterprise_status").toHaveCount(1, {
|
||||
message: "there should be a button for status checking",
|
||||
});
|
||||
|
||||
expect(".oe_instance_register_form").toHaveCount(0);
|
||||
|
||||
// Click on 'Renew your subscription'
|
||||
await click(".oe_instance_renew");
|
||||
await animationFrame();
|
||||
|
||||
expect(browser.location.href).toBe("https://www.odoo.com/odoo-enterprise/renew?contract=ABC");
|
||||
|
||||
expect.verifySteps(["get_param"]);
|
||||
});
|
||||
|
||||
test("One app installed, different locale (arabic)", async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
mockDate("2019-25-09T12:00:00");
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-10-20 12:00:00",
|
||||
expiration_reason: "renewal",
|
||||
storeData: true, // used by subscription service to know whether mail is installed
|
||||
warning: "admin",
|
||||
});
|
||||
serverState.lang = "ar-001";
|
||||
onRpc("get_param", () => "2019-11-09 12:00:00");
|
||||
onRpc("update_notification", () => true);
|
||||
await mountWithCleanup(WebClientOdex);
|
||||
await animationFrame();
|
||||
|
||||
await click("a.check_enterprise_status");
|
||||
await animationFrame();
|
||||
|
||||
expect(".oe_instance_register").toHaveText(
|
||||
"Your subscription was updated and is valid until ٩ نوفمبر ٢٠١٩."
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { click, drag, keyDown, pointerDown, queryFirst } from "@odoo/hoot-dom";
|
||||
import { advanceTime, animationFrame, mockDate, mockTouch } from "@odoo/hoot-mock";
|
||||
import {
|
||||
getService,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { session } from "@web/session";
|
||||
import { HomeMenu } from "@odex30_web/webclient/home_menu/home_menu";
|
||||
import { reorderApps } from "@web/webclient/menus/menu_helpers";
|
||||
|
||||
async function walkOn(path) {
|
||||
for (const step of path) {
|
||||
await keyDown(`${step.shiftKey ? "shift+" : ""}${step.key}`);
|
||||
await animationFrame();
|
||||
expect(`.o_menuitem:eq(${step.index})`).toHaveClass("o_focused", {
|
||||
message: `step ${step.number}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const getDefaultHomeMenuProps = () => {
|
||||
const apps = [
|
||||
{
|
||||
actionID: 121,
|
||||
href: "/odoo/action-121",
|
||||
appID: 1,
|
||||
id: 1,
|
||||
label: "Discuss",
|
||||
parents: "",
|
||||
webIcon: false,
|
||||
xmlid: "app.1",
|
||||
},
|
||||
{
|
||||
actionID: 122,
|
||||
href: "/odoo/action-122",
|
||||
appID: 2,
|
||||
id: 2,
|
||||
label: "Calendar",
|
||||
parents: "",
|
||||
webIcon: false,
|
||||
xmlid: "app.2",
|
||||
},
|
||||
{
|
||||
actionID: 123,
|
||||
href: "/odoo/contacts",
|
||||
appID: 3,
|
||||
id: 3,
|
||||
label: "Contacts",
|
||||
parents: "",
|
||||
webIcon: false,
|
||||
xmlid: "app.3",
|
||||
},
|
||||
];
|
||||
return { apps, reorderApps: (order) => reorderApps(apps, order) };
|
||||
};
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("ESC Support", async () => {
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: getDefaultHomeMenuProps(),
|
||||
});
|
||||
patchWithCleanup(getService("home_menu"), {
|
||||
async toggle(show) {
|
||||
expect.step(`toggle ${show}`);
|
||||
},
|
||||
});
|
||||
await keyDown("escape");
|
||||
expect.verifySteps(["toggle false"]);
|
||||
});
|
||||
|
||||
test("Click on an app", async () => {
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: getDefaultHomeMenuProps(),
|
||||
});
|
||||
patchWithCleanup(getService("menu"), {
|
||||
async selectMenu(menu) {
|
||||
expect.step(`selectMenu ${menu.id}`);
|
||||
},
|
||||
});
|
||||
await click(".o_menuitem:eq(0)");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["selectMenu 1"]);
|
||||
});
|
||||
|
||||
test("Display Expiration Panel (no module installed)", async () => {
|
||||
mockDate("2019-10-09T00:00:00");
|
||||
|
||||
patchWithCleanup(session, {
|
||||
expiration_date: "2019-11-01 12:00:00",
|
||||
expiration_reason: "",
|
||||
isMailInstalled: false,
|
||||
warning: "admin",
|
||||
});
|
||||
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: getDefaultHomeMenuProps(),
|
||||
});
|
||||
|
||||
expect(".database_expiration_panel").toHaveCount(1);
|
||||
expect(".database_expiration_panel .oe_instance_register").toHaveText(
|
||||
"You will be able to register your database once you have installed your first app.",
|
||||
{ message: "There should be an expiration panel displayed" }
|
||||
);
|
||||
|
||||
// Close the expiration panel
|
||||
await click(".database_expiration_panel .oe_instance_hide_panel");
|
||||
await animationFrame();
|
||||
expect(".database_expiration_panel").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Navigation (only apps, only one line)", async () => {
|
||||
expect.assertions(8);
|
||||
|
||||
const homeMenuProps = {
|
||||
apps: new Array(3).fill().map((x, i) => {
|
||||
return {
|
||||
actionID: 120 + i,
|
||||
href: "/odoo/act" + (120 + i),
|
||||
appID: i + 1,
|
||||
id: i + 1,
|
||||
label: `0${i}`,
|
||||
parents: "",
|
||||
webIcon: false,
|
||||
xmlid: `app.${i}`,
|
||||
};
|
||||
}),
|
||||
reorderApps: (order) => reorderApps(homeMenuProps.apps, order),
|
||||
};
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: homeMenuProps,
|
||||
});
|
||||
|
||||
const path = [
|
||||
{ number: 0, key: "ArrowDown", index: 0 },
|
||||
{ number: 1, key: "ArrowRight", index: 1 },
|
||||
{ number: 2, key: "Tab", index: 2 },
|
||||
{ number: 3, key: "ArrowRight", index: 0 },
|
||||
{ number: 4, key: "Tab", shiftKey: true, index: 2 },
|
||||
{ number: 5, key: "ArrowLeft", index: 1 },
|
||||
{ number: 6, key: "ArrowDown", index: 1 },
|
||||
{ number: 7, key: "ArrowUp", index: 1 },
|
||||
];
|
||||
|
||||
await walkOn(path);
|
||||
});
|
||||
|
||||
test("Navigation (only apps, two lines, one incomplete)", async () => {
|
||||
expect.assertions(19);
|
||||
|
||||
const homeMenuProps = {
|
||||
apps: new Array(8).fill().map((x, i) => {
|
||||
return {
|
||||
actionID: 121,
|
||||
href: "/odoo/action-121",
|
||||
appID: i + 1,
|
||||
id: i + 1,
|
||||
label: `0${i}`,
|
||||
parents: "",
|
||||
webIcon: false,
|
||||
xmlid: `app.${i}`,
|
||||
};
|
||||
}),
|
||||
reorderApps: (order) => reorderApps(homeMenuProps.apps, order),
|
||||
};
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: homeMenuProps,
|
||||
});
|
||||
|
||||
const path = [
|
||||
{ number: 1, key: "ArrowRight", index: 0 },
|
||||
{ number: 2, key: "ArrowUp", index: 6 },
|
||||
{ number: 3, key: "ArrowUp", index: 0 },
|
||||
{ number: 4, key: "ArrowDown", index: 6 },
|
||||
{ number: 5, key: "ArrowDown", index: 0 },
|
||||
{ number: 6, key: "ArrowRight", index: 1 },
|
||||
{ number: 7, key: "ArrowRight", index: 2 },
|
||||
{ number: 8, key: "ArrowUp", index: 7 },
|
||||
{ number: 9, key: "ArrowUp", index: 1 },
|
||||
{ number: 10, key: "ArrowRight", index: 2 },
|
||||
{ number: 11, key: "ArrowDown", index: 7 },
|
||||
{ number: 12, key: "ArrowDown", index: 1 },
|
||||
{ number: 13, key: "ArrowUp", index: 7 },
|
||||
{ number: 14, key: "ArrowRight", index: 6 },
|
||||
{ number: 15, key: "ArrowLeft", index: 7 },
|
||||
{ number: 16, key: "ArrowUp", index: 1 },
|
||||
{ number: 17, key: "ArrowLeft", index: 0 },
|
||||
{ number: 18, key: "ArrowLeft", index: 5 },
|
||||
{ number: 19, key: "ArrowRight", index: 0 },
|
||||
];
|
||||
|
||||
await walkOn(path);
|
||||
});
|
||||
|
||||
test("Navigation and open an app in the home menu", async () => {
|
||||
expect.assertions(6);
|
||||
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: getDefaultHomeMenuProps(),
|
||||
});
|
||||
patchWithCleanup(getService("menu"), {
|
||||
async selectMenu(menu) {
|
||||
expect.step(`selectMenu ${menu.id}`);
|
||||
},
|
||||
});
|
||||
// No app selected so nothing to open
|
||||
await keyDown("enter");
|
||||
expect.verifySteps([]);
|
||||
|
||||
const path = [
|
||||
{ number: 0, key: "ArrowDown", index: 0 },
|
||||
{ number: 1, key: "ArrowRight", index: 1 },
|
||||
{ number: 2, key: "Tab", index: 2 },
|
||||
{ number: 3, key: "shift+Tab", index: 1 },
|
||||
];
|
||||
|
||||
await walkOn(path);
|
||||
|
||||
// open first app (Calendar)
|
||||
await keyDown("enter");
|
||||
|
||||
expect.verifySteps(["selectMenu 2"]);
|
||||
});
|
||||
|
||||
test("Reorder apps in home menu using drag and drop", async () => {
|
||||
const homeMenuProps = {
|
||||
apps: reactive(
|
||||
new Array(8).fill().map((x, i) => {
|
||||
return {
|
||||
actionID: 121,
|
||||
href: "/odoo/action-121",
|
||||
appID: i + 1,
|
||||
id: i + 1,
|
||||
label: `0${i}`,
|
||||
parents: "",
|
||||
webIcon: false,
|
||||
xmlid: `app.${i}`,
|
||||
};
|
||||
})
|
||||
),
|
||||
reorderApps: (order) => reorderApps(homeMenuProps.apps, order),
|
||||
};
|
||||
onRpc("set_res_users_settings", () => {
|
||||
expect.step(`set_res_users_settings`);
|
||||
return {
|
||||
id: 1,
|
||||
homemenu_config: '["app.1","app.2","app.3","app.0","app.4","app.5","app.6","app.7"]',
|
||||
};
|
||||
});
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: homeMenuProps,
|
||||
});
|
||||
|
||||
const { moveTo, drop } = await drag(".o_draggable:first-child");
|
||||
await advanceTime(250);
|
||||
expect(".o_draggable:first-child a").not.toHaveClass("o_dragged_app");
|
||||
await advanceTime(250);
|
||||
expect(".o_draggable:first-child a").toHaveClass("o_dragged_app");
|
||||
await moveTo(".o_draggable:first-child", {
|
||||
position: {
|
||||
x: 70,
|
||||
y: 35,
|
||||
},
|
||||
relative: true,
|
||||
});
|
||||
await drop(".o_draggable:not(.o_dragged):eq(3)");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["set_res_users_settings"]);
|
||||
expect(".o_app:eq(0)").toHaveAttribute("data-menu-xmlid", "app.1", {
|
||||
message: "first displayed app has app.1 xmlid",
|
||||
});
|
||||
expect(".o_app:eq(3)").toHaveAttribute("data-menu-xmlid", "app.0", {
|
||||
message: "app 0 is now at 4th position",
|
||||
});
|
||||
});
|
||||
|
||||
test("The HomeMenu input takes the focus when you press a key only if no other element is the activeElement", async () => {
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: getDefaultHomeMenuProps(),
|
||||
});
|
||||
expect(".o_search_hidden").toBeFocused();
|
||||
|
||||
const activeElement = document.createElement("div");
|
||||
getService("ui").activateElement(activeElement);
|
||||
// remove the focus from the input
|
||||
const otherInput = document.createElement("input");
|
||||
queryFirst(".o_home_menu").appendChild(otherInput);
|
||||
await pointerDown(otherInput);
|
||||
await pointerDown(document.body);
|
||||
expect(".o_search_hidden").not.toBeFocused();
|
||||
|
||||
await keyDown("a");
|
||||
await animationFrame();
|
||||
expect(".o_search_hidden").not.toBeFocused();
|
||||
|
||||
getService("ui").deactivateElement(activeElement);
|
||||
await keyDown("a");
|
||||
await animationFrame();
|
||||
expect(".o_search_hidden").toBeFocused();
|
||||
});
|
||||
|
||||
test("The HomeMenu input does not take the focus if it is already on another input", async () => {
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: getDefaultHomeMenuProps(),
|
||||
});
|
||||
expect(".o_search_hidden").toBeFocused();
|
||||
|
||||
const otherInput = document.createElement("input");
|
||||
queryFirst(".o_home_menu").appendChild(otherInput);
|
||||
await pointerDown(otherInput);
|
||||
await keyDown("a");
|
||||
await animationFrame();
|
||||
expect(".o_search_hidden").not.toBeFocused();
|
||||
|
||||
otherInput.remove();
|
||||
await keyDown("a");
|
||||
await animationFrame();
|
||||
expect(".o_search_hidden").toBeFocused();
|
||||
});
|
||||
|
||||
test("The HomeMenu input does not take the focus if it is already on a textarea", async () => {
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: getDefaultHomeMenuProps(),
|
||||
});
|
||||
expect(".o_search_hidden").toBeFocused();
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
queryFirst(".o_home_menu").appendChild(textarea);
|
||||
await pointerDown(textarea);
|
||||
await keyDown("a");
|
||||
await animationFrame();
|
||||
expect(".o_search_hidden").not.toBeFocused();
|
||||
|
||||
textarea.remove();
|
||||
await keyDown("a");
|
||||
await animationFrame();
|
||||
expect(".o_search_hidden").toBeFocused();
|
||||
});
|
||||
|
||||
test("home search input shouldn't be focused on touch devices", async () => {
|
||||
mockTouch(true);
|
||||
await mountWithCleanup(HomeMenu, {
|
||||
props: getDefaultHomeMenuProps(),
|
||||
});
|
||||
expect(".o_search_hidden").not.toBeFocused({
|
||||
message: "home menu search input shouldn't have the focus",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,798 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { click, keyDown, queryAll, queryFirst } from "@odoo/hoot-dom";
|
||||
import { animationFrame, Deferred, mockMatchMedia } from "@odoo/hoot-mock";
|
||||
import { Component, onMounted, xml } from "@odoo/owl";
|
||||
import {
|
||||
clearRegistry,
|
||||
contains,
|
||||
defineActions,
|
||||
defineMenus,
|
||||
defineModels,
|
||||
fields,
|
||||
getMockEnv,
|
||||
getService,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
serverState,
|
||||
stepAllNetworkCalls,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { router } from "@web/core/browser/router";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { config as transitionConfig } from "@web/core/transition";
|
||||
import { user } from "@web/core/user";
|
||||
import { redirect } from "@web/core/utils/urls";
|
||||
import { UserMenu } from "@web/webclient/user_menu/user_menu";
|
||||
import { shareUrlMenuItem } from "@odex30_web/webclient/share_url/share_url";
|
||||
import { WebClientOdex } from "@odex30_web/webclient/webclient";
|
||||
|
||||
const actionRegistry = registry.category("actions");
|
||||
|
||||
/**
|
||||
* @param {{ env: import("@web/env").OdooEnv }} [options]
|
||||
*/
|
||||
async function mountWebClientOdex(options) {
|
||||
await mountWithCleanup(WebClientOdex, options);
|
||||
// Wait for visual changes caused by a potential loadState
|
||||
await animationFrame();
|
||||
// wait for BlankComponent
|
||||
await animationFrame();
|
||||
// wait for the regular rendering
|
||||
await animationFrame();
|
||||
}
|
||||
|
||||
async function goToHomeMenu() {
|
||||
await click(".o_menu_toggle");
|
||||
await animationFrame();
|
||||
|
||||
if (getMockEnv().isSmall) {
|
||||
await click(queryFirst(".o_sidebar_topbar a.btn-primary", { root: document.body }));
|
||||
await animationFrame();
|
||||
}
|
||||
}
|
||||
|
||||
defineActions([
|
||||
{
|
||||
id: 1,
|
||||
xml_id: "action_1",
|
||||
name: "Partners Action 1",
|
||||
res_model: "partner",
|
||||
views: [[false, "kanban"]],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
xml_id: "action_2",
|
||||
type: "ir.actions.server",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
xml_id: "action_3",
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "kanban"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
xml_id: "action_4",
|
||||
name: "Partners Action 4",
|
||||
res_model: "partner",
|
||||
views: [
|
||||
[false, "kanban"],
|
||||
[false, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
xml_id: "action_5",
|
||||
name: "Create a Partner",
|
||||
res_model: "partner",
|
||||
target: "new",
|
||||
views: [[false, "form"]],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
xml_id: "action_6",
|
||||
name: "Partner",
|
||||
res_id: 2,
|
||||
res_model: "partner",
|
||||
target: "inline",
|
||||
views: [[false, "form"]],
|
||||
},
|
||||
{
|
||||
id: 1001,
|
||||
tag: "__test__client__action__",
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "Id 1" },
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
tag: "__test__client__action__",
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "Id 2" },
|
||||
},
|
||||
]);
|
||||
|
||||
defineMenus([
|
||||
{ id: 0 }, // prevents auto-loading the first action
|
||||
{ id: 1, name: "App1", appID: 1, actionID: 1001, xmlid: "menu_1" },
|
||||
{ id: 2, name: "App2", appID: 2, actionID: 1002, xmlid: "menu_2" },
|
||||
]);
|
||||
class Partner extends models.Model {
|
||||
name = fields.Char();
|
||||
foo = fields.Char();
|
||||
parent_id = fields.Many2one({ relation: "partner" });
|
||||
child_ids = fields.One2many({ relation: "partner", relation_field: "parent_id" });
|
||||
|
||||
_records = [
|
||||
{ id: 1, name: "First record", foo: "yop", parent_id: 3 },
|
||||
{ id: 2, name: "Second record", foo: "blip", parent_id: 3 },
|
||||
{ id: 3, name: "Third record", foo: "gnap", parent_id: 1 },
|
||||
{ id: 4, name: "Fourth record", foo: "plop", parent_id: 1 },
|
||||
{ id: 5, name: "Fifth record", foo: "zoup", parent_id: 1 },
|
||||
];
|
||||
_views = {
|
||||
kanban: `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="foo"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
`,
|
||||
list: `<list><field name="foo"/></list>`,
|
||||
form: `
|
||||
<form>
|
||||
<header>
|
||||
<button name="object" string="Call method" type="object"/>
|
||||
<button name="4" string="Execute action" type="action"/>
|
||||
</header>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="foo"/>
|
||||
</group>
|
||||
</form>
|
||||
`,
|
||||
search: `<search><field name="foo" string="Foo"/></search>`,
|
||||
};
|
||||
}
|
||||
defineModels([Partner]);
|
||||
class TestClientAction extends Component {
|
||||
static template = xml`
|
||||
<div class="test_client_action">
|
||||
ClientAction_<t t-esc="props.action.params?.description"/>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
this.env.config.setDisplayName(`Client action ${this.props.action.id}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onRpc("has_group", () => true);
|
||||
|
||||
beforeEach(() => {
|
||||
actionRegistry.add("__test__client__action__", TestClientAction);
|
||||
patchWithCleanup(transitionConfig, { disabled: true });
|
||||
});
|
||||
|
||||
describe("basic flow with home menu", () => {
|
||||
stepAllNetworkCalls();
|
||||
onRpc("partner", "get_formview_action", () => ({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "partner",
|
||||
view_type: "form",
|
||||
view_mode: "form",
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
res_id: 2,
|
||||
}));
|
||||
defineMenus(
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
name: "App1",
|
||||
appID: 1,
|
||||
actionID: 4,
|
||||
xmlid: "menu_1",
|
||||
},
|
||||
],
|
||||
{ mode: "replace" }
|
||||
);
|
||||
test("1 -- start up", async () => {
|
||||
await mountWebClientOdex();
|
||||
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
|
||||
expect(document.body).toHaveClass("o_home_menu_background");
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
expect(".o_menu_toggle").not.toBeVisible();
|
||||
expect(".o_app.o_menuitem").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("2 -- navbar updates on displaying an action", async () => {
|
||||
await mountWebClientOdex();
|
||||
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
|
||||
await contains(".o_app.o_menuitem").click();
|
||||
await animationFrame();
|
||||
expect.verifySteps(["/web/action/load", "get_views", "web_search_read"]);
|
||||
expect(document.body).not.toHaveClass("o_home_menu_background");
|
||||
expect(".o_home_menu").toHaveCount(0);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
expect(".o_menu_toggle").toBeVisible();
|
||||
expect(".o_menu_toggle").not.toHaveClass("o_menu_toggle_back");
|
||||
});
|
||||
|
||||
test("3 -- push another action in the breadcrumb", async () => {
|
||||
await mountWebClientOdex();
|
||||
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
|
||||
await contains(".o_app.o_menuitem").click();
|
||||
await animationFrame();
|
||||
expect.verifySteps(["/web/action/load", "get_views", "web_search_read"]);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
await contains(".o_kanban_record").click();
|
||||
await animationFrame(); // there is another tick to update navbar and destroy HomeMenu
|
||||
expect.verifySteps(["web_read"]);
|
||||
expect(".o_menu_toggle").toBeVisible();
|
||||
expect(".o_form_view").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("First record");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("4 -- push a third action in the breadcrumb", async () => {
|
||||
Partner._views["form"] = `
|
||||
<form>
|
||||
<field name="display_name"/>
|
||||
<field name="parent_id" open_target="current"/>
|
||||
</form>
|
||||
`;
|
||||
await mountWebClientOdex();
|
||||
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
|
||||
await contains(".o_app.o_menuitem").click();
|
||||
await animationFrame();
|
||||
expect.verifySteps(["/web/action/load", "get_views", "web_search_read"]);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
await contains(".o_kanban_record").click();
|
||||
expect.verifySteps(["web_read"]);
|
||||
await contains('.o_field_widget[name="parent_id"] .o_external_button', {
|
||||
visible: false,
|
||||
}).click();
|
||||
expect.verifySteps(["get_formview_action", "get_views", "web_read"]);
|
||||
expect(".o_form_view").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Second record");
|
||||
// The third one is the active one
|
||||
expect(".breadcrumb-item").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("5 -- switch to HomeMenu from an action with 2 breadcrumbs", async () => {
|
||||
Partner._views["form"] = `
|
||||
<form>
|
||||
<field name="display_name"/>
|
||||
<field name="parent_id" open_target="current"/>
|
||||
</form>
|
||||
`;
|
||||
await mountWebClientOdex();
|
||||
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
|
||||
await contains(".o_app.o_menuitem").click();
|
||||
await animationFrame();
|
||||
expect.verifySteps(["/web/action/load", "get_views", "web_search_read"]);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
await contains(".o_kanban_record").click();
|
||||
expect.verifySteps(["web_read"]);
|
||||
await contains('.o_field_widget[name="parent_id"] .o_external_button', {
|
||||
visible: false,
|
||||
}).click();
|
||||
expect.verifySteps(["get_formview_action", "get_views", "web_read"]);
|
||||
await goToHomeMenu();
|
||||
expect.verifySteps([]);
|
||||
expect(".o_menu_toggle").toHaveClass("o_menu_toggle_back");
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
expect(".o_form_view").not.toHaveCount();
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("6 -- back to underlying action with many breadcrumbs", async () => {
|
||||
Partner._views["form"] = `
|
||||
<form>
|
||||
<field name="display_name"/>
|
||||
<field name="parent_id" open_target="current"/>
|
||||
</form>
|
||||
`;
|
||||
await mountWebClientOdex();
|
||||
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
|
||||
await contains(".o_app.o_menuitem").click();
|
||||
await animationFrame();
|
||||
expect.verifySteps(["/web/action/load", "get_views", "web_search_read"]);
|
||||
expect(".o_kanban_view").toHaveCount(1);
|
||||
await contains(".o_kanban_record").click();
|
||||
expect.verifySteps(["web_read"]);
|
||||
await contains('.o_field_widget[name="parent_id"] .o_external_button', {
|
||||
visible: false,
|
||||
}).click();
|
||||
expect.verifySteps(["get_formview_action", "get_views", "web_read"]);
|
||||
await contains(".o_menu_toggle").click();
|
||||
|
||||
// can't click again too soon because of the mutex in home_menu
|
||||
// service (waiting for the url to be updated)
|
||||
await animationFrame();
|
||||
|
||||
await contains(".o_menu_toggle").click();
|
||||
|
||||
expect.verifySteps(["web_read"]);
|
||||
expect(".o_home_menu").toHaveCount(0);
|
||||
expect(".o_form_view").toHaveCount(1);
|
||||
expect(".o_menu_toggle").not.toHaveClass("o_menu_toggle_back");
|
||||
expect(".o_breadcrumb .active").toHaveText("Second record");
|
||||
// Third breadcrumb is the active one
|
||||
expect(".breadcrumb-item").toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
test("restore the newly created record in form view", async () => {
|
||||
defineActions(
|
||||
[
|
||||
{
|
||||
id: 6,
|
||||
xml_id: "action_6",
|
||||
name: "Partner",
|
||||
res_model: "partner",
|
||||
views: [[false, "form"]],
|
||||
},
|
||||
],
|
||||
{ mode: "replace" }
|
||||
);
|
||||
await mountWebClientOdex();
|
||||
|
||||
await getService("action").doAction(6);
|
||||
expect(".o_form_view").toHaveCount(1);
|
||||
expect(".o_form_view .o_form_editable").toHaveCount(1);
|
||||
await contains(".o_field_widget[name=name] input").edit("red right hand");
|
||||
await contains(".o_form_button_save").click();
|
||||
expect(".o_breadcrumb .active").toHaveText("red right hand");
|
||||
await goToHomeMenu();
|
||||
expect(".o_form_view").not.toHaveCount();
|
||||
|
||||
// can't click again too soon because of the mutex in home_menu
|
||||
// service (waiting for the url to be updated)
|
||||
await animationFrame();
|
||||
|
||||
await contains(".o_menu_toggle").click();
|
||||
expect(".o_form_view").toHaveCount(1);
|
||||
expect(".o_form_view .o_form_saved").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("red right hand");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("fast clicking on restore (implementation detail)", async () => {
|
||||
expect.assertions(8);
|
||||
|
||||
let doVeryFastClick = false;
|
||||
|
||||
class DelayedClientAction extends Component {
|
||||
static template = xml`<div class='delayed_client_action'>
|
||||
<button t-on-click="resolve">RESOLVE</button>
|
||||
</div>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
if (doVeryFastClick) {
|
||||
doVeryFastClick = false;
|
||||
click(".o_menu_toggle"); // go to home menu
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("DelayedClientAction", DelayedClientAction);
|
||||
await mountWebClientOdex();
|
||||
await getService("action").doAction("DelayedClientAction");
|
||||
await animationFrame();
|
||||
await contains(".o_menu_toggle").click(); // go to home menu
|
||||
expect(".o_home_menu").toBeVisible();
|
||||
expect(".delayed_client_action").not.toHaveCount();
|
||||
|
||||
doVeryFastClick = true;
|
||||
await contains(".o_menu_toggle").click(); // back
|
||||
expect(".o_home_menu").toHaveCount(0);
|
||||
expect(".delayed_client_action").toHaveCount(1);
|
||||
await animationFrame(); // waiting for DelayedClientAction
|
||||
expect(".o_home_menu").toBeVisible();
|
||||
expect(".delayed_client_action").not.toHaveCount();
|
||||
|
||||
await contains(".o_menu_toggle").click(); // back
|
||||
await animationFrame();
|
||||
expect(".o_home_menu").toHaveCount(0);
|
||||
expect(".delayed_client_action").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("clear unCommittedChanges when toggling home menu", async () => {
|
||||
expect.assertions(6);
|
||||
// Edit a form view, don't save, toggle home menu
|
||||
// the autosave feature of the Form view is activated
|
||||
// and relied upon by this test
|
||||
|
||||
onRpc("web_save", ({ args, model }) => {
|
||||
expect(model).toBe("partner");
|
||||
expect(args[1]).toEqual({
|
||||
name: "red right hand",
|
||||
foo: false,
|
||||
});
|
||||
});
|
||||
|
||||
await mountWebClientOdex();
|
||||
await getService("action").doAction(3, { viewType: "form" });
|
||||
expect(".o_form_view .o_form_editable").toHaveCount(1);
|
||||
await contains(".o_field_widget[name=name] input").edit("red right hand");
|
||||
|
||||
await goToHomeMenu();
|
||||
expect(".o_form_view").toHaveCount(0);
|
||||
expect(".modal").toHaveCount(0);
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("can have HomeMenu and dialog action", async () => {
|
||||
await mountWebClientOdex();
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
expect(".modal .o_form_view").toHaveCount(0);
|
||||
await getService("action").doAction(5);
|
||||
expect(".modal .o_form_view").toHaveCount(1);
|
||||
expect(".modal .o_form_view").toBeVisible();
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("supports attachments of apps deleted", async () => {
|
||||
// When doing a pg_restore without the filestore
|
||||
// LPE fixme: may not be necessary anymore since menus are not HomeMenu props anymore
|
||||
defineMenus([
|
||||
{
|
||||
id: 1,
|
||||
appID: 1,
|
||||
actionID: 1,
|
||||
xmlid: "",
|
||||
name: "Partners",
|
||||
webIconData: "",
|
||||
webIcon: "bloop,bloop",
|
||||
},
|
||||
]);
|
||||
serverState.debug = "1";
|
||||
await mountWebClientOdex();
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("debug manager resets to global items when home menu is displayed", async () => {
|
||||
const debugRegistry = registry.category("debug");
|
||||
debugRegistry.category("default").add("item_1", () => ({
|
||||
type: "item",
|
||||
description: "globalItem",
|
||||
callback: () => {},
|
||||
sequence: 10,
|
||||
}));
|
||||
onRpc("has_access", () => true);
|
||||
serverState.debug = "1";
|
||||
await mountWebClientOdex();
|
||||
await contains(".o_debug_manager .dropdown-toggle").click();
|
||||
expect(".dropdown-item:contains('globalItem')").toHaveCount(1);
|
||||
expect(".dropdown-item:contains('View: Kanban')").toHaveCount(0);
|
||||
|
||||
await contains(".o_debug_manager .dropdown-toggle").click();
|
||||
await getService("action").doAction(1);
|
||||
await contains(".o_debug_manager .dropdown-toggle").click();
|
||||
expect(".dropdown-item:contains('globalItem')").toHaveCount(1);
|
||||
expect(".dropdown-item:contains('View: Kanban')").toHaveCount(1);
|
||||
|
||||
await contains(".o_menu_toggle").click();
|
||||
await contains(".o_debug_manager .dropdown-toggle").click();
|
||||
expect(".dropdown-item:contains('globalItem')").toHaveCount(1);
|
||||
expect(".dropdown-item:contains('View: Kanban')").toHaveCount(0);
|
||||
|
||||
await contains(".o_debug_manager .dropdown-toggle").click();
|
||||
await getService("action").doAction(3);
|
||||
await contains(".o_debug_manager .dropdown-toggle").click();
|
||||
expect(".dropdown-item:contains('globalItem')").toHaveCount(1);
|
||||
expect(".dropdown-item:contains('View: List')").toHaveCount(1);
|
||||
expect(".dropdown-item:contains('View: Kanban')").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("url state is well handled when going in and out of the HomeMenu", async () => {
|
||||
patchWithCleanup(browser.location, {
|
||||
origin: "http://example.com",
|
||||
});
|
||||
redirect("/odoo");
|
||||
await mountWebClientOdex();
|
||||
expect(router.current).toEqual({
|
||||
action: "menu",
|
||||
actionStack: [
|
||||
{
|
||||
action: "menu",
|
||||
displayName: "Home",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(browser.history.length).toBe(1);
|
||||
|
||||
await contains(".o_apps > .o_draggable:eq(1) > .o_app").click();
|
||||
await animationFrame();
|
||||
expect(router.current).toEqual({
|
||||
action: 1002,
|
||||
actionStack: [
|
||||
{
|
||||
action: 1002,
|
||||
displayName: "Client action 1002",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(browser.history.length).toBe(2);
|
||||
expect(browser.location.href).toBe("http://example.com/odoo/action-1002");
|
||||
|
||||
await goToHomeMenu();
|
||||
await animationFrame();
|
||||
expect(router.current).toEqual(
|
||||
{
|
||||
action: "menu",
|
||||
actionStack: [
|
||||
{
|
||||
action: 1002,
|
||||
displayName: "Client action 1002",
|
||||
},
|
||||
{
|
||||
action: "menu",
|
||||
displayName: "Home",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
message:
|
||||
"the actionStack is required to be able to restore the menu toggle back button and the underlying breadcrumbs",
|
||||
}
|
||||
);
|
||||
expect(browser.history.length).toBe(3);
|
||||
expect(browser.location.href).toBe("http://example.com/odoo", {
|
||||
message:
|
||||
"despite the actionStack being in the router state, the url shouldn't have any path",
|
||||
});
|
||||
|
||||
await contains(".o_apps > .o_draggable:eq(0) > .o_app").click();
|
||||
await animationFrame();
|
||||
expect(router.current).toEqual(
|
||||
{
|
||||
action: 1001,
|
||||
actionStack: [
|
||||
{
|
||||
action: 1001,
|
||||
displayName: "Client action 1001",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ message: "clicking another app creates a new action stack (ie empties the breadcrumb)" }
|
||||
);
|
||||
expect(browser.history.length).toBe(4);
|
||||
expect(browser.location.href).toBe("http://example.com/odoo/action-1001");
|
||||
|
||||
browser.history.back();
|
||||
await animationFrame();
|
||||
expect(router.current).toEqual(
|
||||
{
|
||||
action: "menu",
|
||||
actionStack: [
|
||||
{
|
||||
action: 1002,
|
||||
displayName: "Client action 1002",
|
||||
},
|
||||
{
|
||||
action: "menu",
|
||||
displayName: "Home",
|
||||
},
|
||||
],
|
||||
globalState: {},
|
||||
},
|
||||
{ message: "actionStack was restored" }
|
||||
);
|
||||
expect(browser.history.length).toBe(4, {
|
||||
message: "the previous history entry still exists (available with forward button)",
|
||||
});
|
||||
expect(browser.location.href).toBe("http://example.com/odoo");
|
||||
|
||||
await contains(".o_menu_toggle").click();
|
||||
await animationFrame();
|
||||
expect(router.current).toEqual({
|
||||
action: 1002,
|
||||
actionStack: [
|
||||
{
|
||||
action: 1002,
|
||||
displayName: "Client action 1002",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(browser.history.length).toBe(4);
|
||||
expect(browser.location.href).toBe("http://example.com/odoo/action-1002");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("underlying action's menu items are invisible when HomeMenu is displayed", async () => {
|
||||
defineMenus([
|
||||
{
|
||||
id: 1,
|
||||
children: [
|
||||
{
|
||||
id: 99,
|
||||
name: "SubMenu",
|
||||
appID: 1,
|
||||
actionID: 1002,
|
||||
xmlid: "",
|
||||
webIconData: undefined,
|
||||
webIcon: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
await mountWebClientOdex();
|
||||
expect("nav .o_menu_sections").toHaveCount(0);
|
||||
expect("nav .o_menu_brand").toHaveCount(0);
|
||||
await contains(".o_app.o_menuitem:nth-child(1)").click();
|
||||
await animationFrame();
|
||||
expect("nav .o_menu_sections").toHaveCount(1);
|
||||
expect("nav .o_menu_brand").toHaveCount(1);
|
||||
expect(".o_menu_sections").toBeVisible();
|
||||
expect(".o_menu_brand").toBeVisible();
|
||||
await contains(".o_menu_toggle").click();
|
||||
expect("nav .o_menu_sections").toHaveCount(1);
|
||||
expect("nav .o_menu_brand").toHaveCount(1);
|
||||
expect(".o_menu_sections").not.toBeVisible();
|
||||
expect(".o_menu_brand").not.toBeVisible();
|
||||
});
|
||||
|
||||
test("go back to home menu using browser back button", async () => {
|
||||
await mountWebClientOdex();
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
expect(".o_main_navbar .o_menu_toggle").not.toBeVisible();
|
||||
|
||||
await contains(".o_apps > .o_draggable:nth-child(2) > .o_app").click();
|
||||
expect(".test_client_action").toHaveCount(0);
|
||||
await animationFrame();
|
||||
expect(".test_client_action").toHaveCount(1);
|
||||
expect(".o_home_menu").toHaveCount(0);
|
||||
|
||||
browser.history.back();
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
expect(".test_client_action").toHaveCount(0);
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
expect(".o_main_navbar .o_menu_toggle").not.toBeVisible();
|
||||
});
|
||||
|
||||
test("initial action crashes", async () => {
|
||||
expect.errors(1);
|
||||
redirect("/odoo/action-__test__client__action__?menu_id=1");
|
||||
const ClientAction = registry.category("actions").get("__test__client__action__");
|
||||
class Override extends ClientAction {
|
||||
setup() {
|
||||
super.setup();
|
||||
expect.step("clientAction setup");
|
||||
throw new Error("my error");
|
||||
}
|
||||
}
|
||||
registry.category("actions").add("__test__client__action__", Override, { force: true });
|
||||
|
||||
await mountWebClientOdex();
|
||||
expect.verifySteps(["clientAction setup"]);
|
||||
expect("nav .o_menu_toggle").toHaveCount(1);
|
||||
expect("nav .o_menu_toggle").toBeVisible();
|
||||
expect(".o_action_manager").toHaveInnerHTML("");
|
||||
expect(router.current).toEqual({
|
||||
action: "__test__client__action__",
|
||||
menu_id: 1,
|
||||
actionStack: [
|
||||
{
|
||||
action: "__test__client__action__",
|
||||
},
|
||||
],
|
||||
});
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["my error"]);
|
||||
});
|
||||
|
||||
test("Apps are reordered at startup based on session's user settings", async () => {
|
||||
// Config is written with apps xmlids order (default is menu_1, menu_2)
|
||||
patchWithCleanup(user, {
|
||||
get settings() {
|
||||
return { id: 1, homemenu_config: '["menu_2","menu_1"]' };
|
||||
},
|
||||
});
|
||||
await mountWebClientOdex();
|
||||
|
||||
const apps = queryAll(".o_app");
|
||||
expect(apps[0]).toHaveAttribute("data-menu-xmlid", "menu_2", {
|
||||
message: "first displayed app has menu_2 xmlid",
|
||||
});
|
||||
expect(apps[1]).toHaveAttribute("data-menu-xmlid", "menu_1", {
|
||||
message: "second displayed app has menu_1 xmlid",
|
||||
});
|
||||
expect(apps[0]).toHaveText("App2", { message: "first displayed app is App2" });
|
||||
expect(apps[1]).toHaveText("App1", { message: "second displayed app is App1" });
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("Share URL item is present in the user menu when running as PWA", async () => {
|
||||
mockMatchMedia({ ["display-mode"]: "standalone" });
|
||||
clearRegistry(registry.category("user_menuitems"));
|
||||
// This service adds a "Dark Mode" item to the user menu items on start
|
||||
registry.category("services").remove("color_scheme");
|
||||
registry.category("user_menuitems").add("share_url", shareUrlMenuItem);
|
||||
|
||||
await mountWithCleanup(UserMenu);
|
||||
await contains(".o_user_menu button").click();
|
||||
|
||||
expect(".o-dropdown--menu .dropdown-item").toHaveCount(1);
|
||||
expect(".o-dropdown--menu .dropdown-item").toHaveText("Share");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("Share URL item is not present in the user menu when not running as PWA", async () => {
|
||||
mockMatchMedia({ ["display-mode"]: "browser" });
|
||||
clearRegistry(registry.category("user_menuitems"));
|
||||
// This service adds a "Dark Mode" item to the user menu items on start
|
||||
registry.category("services").remove("color_scheme");
|
||||
registry.category("user_menuitems").add("share_url", shareUrlMenuItem);
|
||||
|
||||
await mountWithCleanup(UserMenu);
|
||||
await contains(".o_user_menu button").click();
|
||||
|
||||
expect(".o-dropdown--menu .dropdown-item").not.toHaveCount();
|
||||
});
|
||||
|
||||
test("Navigate to an application from the HomeMenu should generate only one pushState", async () => {
|
||||
patchWithCleanup(history, {
|
||||
pushState(state, title, url) {
|
||||
super.pushState(...arguments);
|
||||
const parsedUrl = new URL(url);
|
||||
expect.step(parsedUrl.pathname + parsedUrl.search);
|
||||
},
|
||||
});
|
||||
await mountWebClientOdex();
|
||||
|
||||
await contains(".o_apps > .o_draggable:nth-child(2) > .o_app").click();
|
||||
await animationFrame();
|
||||
expect(".test_client_action").toHaveCount(1);
|
||||
expect(".test_client_action").toHaveText("ClientAction_Id 2");
|
||||
|
||||
await goToHomeMenu();
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
|
||||
await contains(".o_apps > .o_draggable:nth-child(1) > .o_app").click();
|
||||
await animationFrame();
|
||||
expect(".test_client_action").toHaveCount(1);
|
||||
expect(".test_client_action").toHaveText("ClientAction_Id 1");
|
||||
|
||||
await goToHomeMenu();
|
||||
await animationFrame();
|
||||
expect(".o_home_menu").toHaveCount(1);
|
||||
expect.verifySteps(["/odoo", "/odoo/action-1002", "/odoo", "/odoo/action-1001", "/odoo"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("Should not crash when opening an app via palette and immediately entering input in the palette search", async () => {
|
||||
await mountWebClientOdex();
|
||||
|
||||
const def = new Deferred();
|
||||
onRpc("web_search_read", () => def);
|
||||
await keyDown("a");
|
||||
await animationFrame();
|
||||
await keyDown("Enter");
|
||||
await keyDown("a");
|
||||
await animationFrame();
|
||||
def.resolve();
|
||||
await animationFrame();
|
||||
expect(".test_client_action").toHaveCount(1);
|
||||
expect(".test_client_action").toHaveText("ClientAction_Id 1");
|
||||
});
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import test_odex
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import base64
|
||||
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
|
||||
class LoadMenusTests(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.menu = self.env["ir.ui.menu"].create({
|
||||
"name": "test_menu",
|
||||
"parent_id": False,
|
||||
})
|
||||
|
||||
def search(*args, **kwargs):
|
||||
return self.menu
|
||||
|
||||
self.patch(type(self.env["ir.ui.menu"]), "search", search)
|
||||
self.authenticate("admin", "admin")
|
||||
|
||||
def test_web_icon(self):
|
||||
self.menu.web_icon = False
|
||||
self.menu.web_icon_data = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+BCQAHBQICJmhD1AAAAABJRU5ErkJggg=="
|
||||
|
||||
menu_loaded = self.url_open("/web/webclient/load_menus/1234")
|
||||
|
||||
expected = {
|
||||
str(self.menu.id): {
|
||||
"actionID": False,
|
||||
"actionModel": False,
|
||||
"actionPath": False,
|
||||
"appID": self.menu.id,
|
||||
"children": [],
|
||||
"id": self.menu.id,
|
||||
"name": "test_menu",
|
||||
"webIcon": False,
|
||||
"webIconData": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+BCQAHBQICJmhD1AAAAABJRU5ErkJggg==",
|
||||
"webIconDataMimetype": "image/png",
|
||||
"xmlid": ""
|
||||
},
|
||||
"root": {
|
||||
"actionID": False,
|
||||
"actionModel": False,
|
||||
"actionPath": False,
|
||||
"appID": False,
|
||||
"children": [
|
||||
self.menu.id
|
||||
],
|
||||
"id": "root",
|
||||
"name": "root",
|
||||
"webIcon": None,
|
||||
"webIconData": None,
|
||||
"webIconDataMimetype": None,
|
||||
"xmlid": "",
|
||||
"backgroundImage": None,
|
||||
}
|
||||
}
|
||||
|
||||
self.assertDictEqual(menu_loaded.json(), expected)
|
||||
|
||||
|
||||
@tagged("-at_install", "post_install")
|
||||
class TestWeb(HttpCase):
|
||||
def test_studio_list_upsell(self):
|
||||
invoice_action = self.env.ref("account.action_move_out_invoice_type", raise_if_not_found=False)
|
||||
if not invoice_action:
|
||||
return
|
||||
self.start_tour("/odoo/action-account.action_move_out_invoice_type", "odex30_web.test_studio_list_upsell", login="admin")
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
|
||||
import odoo
|
||||
|
||||
odoo.release.version_info = odoo.release.version_info[:5] + ('e',)
|
||||
if '+e' not in odoo.release.version:
|
||||
odoo.release.version = '{0}+e{1}{2}'.format(*odoo.release.version.partition('-'))
|
||||
|
||||
odoo.service.common.RPC_VERSION_1.update(
|
||||
server_version=odoo.release.version,
|
||||
server_version_info=odoo.release.version_info)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="webclient_login" inherit_id="web.login_layout">
|
||||
<xpath expr="//t[@t-call='web.frontend_layout']/t[last()]" position="after">
|
||||
<t t-set="body_classname" t-value="'o_home_menu_background'"/>
|
||||
<t t-set="login_card_classes" t-value="'rounded-0 shadow-sm bg-white'"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="webclient_bootstrap" inherit_id="web.webclient_bootstrap">
|
||||
<xpath expr="//meta[@name='theme-color']" position="replace">
|
||||
<meta name="theme-color" t-att-content="'#242733' if request.cookies.get('color_scheme') == 'dark' else '#714B67'"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import validation
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
{
|
||||
'name': 'Cohort View',
|
||||
'summary': 'Basic Cohort view for odoo',
|
||||
'category': 'Hidden',
|
||||
'author': 'Expert Co. Ltd.',
|
||||
'website': 'http://www.exp-sa.com',
|
||||
'depends': ['web'],
|
||||
'assets': {
|
||||
'web.assets_backend_lazy': [
|
||||
'odex30_web_cohort/static/src/**/*',
|
||||
],
|
||||
'web.assets_unit_tests': [
|
||||
'odex30_web_cohort/static/tests/**/*.js',
|
||||
],
|
||||
},
|
||||
'auto_install': True,
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import main
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
|
||||
import io
|
||||
import json
|
||||
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import content_disposition, request
|
||||
from odoo.tools import osutil
|
||||
from odoo.tools.misc import xlsxwriter
|
||||
|
||||
|
||||
class WebCohort(http.Controller):
|
||||
|
||||
@http.route('/web/cohort/export', type='http', auth='user')
|
||||
def export_xls(self, data, **kw):
|
||||
result = json.load(data) if isinstance(data, FileStorage) else json.loads(data)
|
||||
|
||||
output = io.BytesIO()
|
||||
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
|
||||
worksheet = workbook.add_worksheet(result['title'])
|
||||
style_highlight = workbook.add_format({'bold': True, 'pattern': 1, 'bg_color': '#E0E0E0', 'align': 'center'})
|
||||
style_normal = workbook.add_format({'align': 'center'})
|
||||
row = 0
|
||||
|
||||
def write_data(report, row, col):
|
||||
# Headers
|
||||
columns_length = len(result[report]['rows'][0]['columns'])
|
||||
if result['timeline'] == 'backward':
|
||||
header_sign = ''
|
||||
col_range = range(-(columns_length - 1), 1)
|
||||
else:
|
||||
header_sign = '+'
|
||||
col_range = range(columns_length)
|
||||
|
||||
worksheet.merge_range(row, col + 2, row, columns_length + 1,
|
||||
_('%(date_stop)s - By %(interval)s', date_stop=result['date_stop_string'], interval=result['interval_string']), style_highlight)
|
||||
row += 1
|
||||
worksheet.write(row, col, result['date_start_string'], style_highlight)
|
||||
worksheet.set_column(col, col, 15)
|
||||
col += 1
|
||||
worksheet.write(col, col, result['measure_string'], style_highlight)
|
||||
worksheet.set_column(col, col, 15)
|
||||
col += 1
|
||||
for n in col_range:
|
||||
worksheet.write(row, col, '%s%s' % (header_sign, n), style_highlight)
|
||||
col += 1
|
||||
|
||||
# Rows
|
||||
row += 1
|
||||
for res in result[report]['rows']:
|
||||
col = 0
|
||||
worksheet.write(row, col, res['date'], style_normal)
|
||||
col += 1
|
||||
worksheet.write(row, col, res['value'], style_normal)
|
||||
col += 1
|
||||
for i in res['columns']:
|
||||
worksheet.write(row, col, i['percentage'] == '-' and i['percentage'] or str(i['percentage']) + '%', style_normal)
|
||||
col += 1
|
||||
row += 1
|
||||
|
||||
# Total
|
||||
col = 0
|
||||
worksheet.write(row, col, _('Average'), style_highlight)
|
||||
col += 1
|
||||
worksheet.write(row, col, '%.1f' % result[report]['avg']['avg_value'], style_highlight)
|
||||
col += 1
|
||||
total = result[report]['avg']['columns_avg']
|
||||
for n in range(columns_length):
|
||||
if total[str(n)]['count']:
|
||||
worksheet.write(row, col, '%.1f' % float(total[str(n)]['percentage'] / total[str(n)]['count']) + '%', style_highlight)
|
||||
else:
|
||||
worksheet.write(row, col, '-', style_highlight)
|
||||
col += 1
|
||||
|
||||
return row
|
||||
|
||||
report_length = len(result['report']['rows'])
|
||||
comparison_report = result.get('comparisonReport', False)
|
||||
if comparison_report:
|
||||
comparison_report_length = len(comparison_report['rows'])
|
||||
|
||||
if comparison_report:
|
||||
if report_length:
|
||||
row = write_data('report', row, 0)
|
||||
if comparison_report_length:
|
||||
write_data('comparisonReport', row + 2, 0)
|
||||
elif comparison_report_length:
|
||||
write_data('comparisonReport', row, 0)
|
||||
else:
|
||||
row = write_data('report', row, 0)
|
||||
|
||||
workbook.close()
|
||||
xlsx_data = output.getvalue()
|
||||
filename = osutil.clean_filename(_("Cohort %(title)s (%(model_name)s)", title=result['title'], model_name=result['model']))
|
||||
response = request.make_response(
|
||||
xlsx_data,
|
||||
headers=[('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
|
||||
('Content-Disposition', content_disposition(filename + '.xlsx'))],
|
||||
)
|
||||
return response
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * odex30_web_cohort
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-01 22:36+0000\n"
|
||||
"PO-Revision-Date: 2026-01-01 22:36+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_web_cohort/controllers/main.py:0
|
||||
msgid "%(date_stop)s - By %(interval)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_renderer.xml:0
|
||||
msgid "- By"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#: model:ir.model,name:odex30_web_cohort.model_ir_actions_act_window_view
|
||||
msgid "Action Window View"
|
||||
msgstr "عرض نافذة الإجراء"
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_web_cohort/controllers/main.py:0
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_renderer.xml:0
|
||||
msgid "Average"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#: model:ir.model,name:odex30_web_cohort.model_base
|
||||
msgid "Base"
|
||||
msgstr "قاعدة "
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#: model:ir.model.fields.selection,name:odex30_web_cohort.selection__ir_actions_act_window_view__view_mode__cohort
|
||||
#: model:ir.model.fields.selection,name:odex30_web_cohort.selection__ir_ui_view__type__cohort
|
||||
msgid "Cohort"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_web_cohort/controllers/main.py:0
|
||||
msgid "Cohort %(title)s (%(model_name)s)"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_arch_parser.js:0
|
||||
msgid "Cohort view has not defined \"date_start\" attribute."
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_arch_parser.js:0
|
||||
msgid "Cohort view has not defined \"date_stop\" attribute."
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_model.js:0
|
||||
msgid "Day"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_renderer.xml:0
|
||||
msgid "Download as Excel file"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_renderer.xml:0
|
||||
msgid "Main actions"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_model.js:0
|
||||
msgid "Month"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_renderer.xml:0
|
||||
msgid "No data available."
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_renderer.js:0
|
||||
msgid ""
|
||||
"Period: %(period)s\n"
|
||||
"%(measure)s: %(count)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_arch_parser.js:0
|
||||
msgid ""
|
||||
"The argument %(interval)s is not a valid interval. Here are the intervals: "
|
||||
"%(intervals)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_arch_parser.js:0
|
||||
msgid ""
|
||||
"The argument %(mode)s is not a valid mode. Here are the modes: %(modes)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_arch_parser.js:0
|
||||
msgid ""
|
||||
"The argument %(timeline)s is not a valid timeline. Here are the timelines: "
|
||||
"%(timelines)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#: model:ir.model,name:odex30_web_cohort.model_ir_ui_view
|
||||
msgid "View"
|
||||
msgstr "أداة العرض"
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#: model:ir.model.fields,field_description:odex30_web_cohort.field_ir_actions_act_window_view__view_mode
|
||||
#: model:ir.model.fields,field_description:odex30_web_cohort.field_ir_ui_view__type
|
||||
msgid "View Type"
|
||||
msgstr "نوع واجهة العرض"
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_model.js:0
|
||||
msgid "Week"
|
||||
msgstr ""
|
||||
|
||||
#. module: odex30_web_cohort
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_cohort/static/src/cohort_model.js:0
|
||||
msgid "Year"
|
||||
msgstr ""
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
from . import ir_action_act_window
|
||||
from . import ir_ui_view
|
||||
from . import models
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
from odoo import fields, models
|
||||
|
||||
|
||||
class ActWindowView(models.Model):
|
||||
_inherit = 'ir.actions.act_window.view'
|
||||
|
||||
view_mode = fields.Selection(selection_add=[
|
||||
('cohort', 'Cohort')
|
||||
], ondelete={'cohort': 'cascade'})
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
from odoo import fields, models
|
||||
|
||||
|
||||
class View(models.Model):
|
||||
_inherit = 'ir.ui.view'
|
||||
|
||||
type = fields.Selection(selection_add=[('cohort', 'Cohort')])
|
||||
|
||||
def _postprocess_tag_cohort(self, node, name_manager, node_info):
|
||||
for additional_field in ('date_start', 'date_stop'):
|
||||
if fnames := node.get(additional_field):
|
||||
name_manager.has_field(node, fnames.split('.', 1)[0], node_info)
|
||||
|
||||
def _get_view_info(self):
|
||||
return {'cohort': {'icon': 'oi oi-view-cohort'}} | super()._get_view_info()
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
import babel.dates
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.misc import get_lang
|
||||
|
||||
DISPLAY_FORMATS = {
|
||||
'day': '%d %b %Y',
|
||||
'week': 'W%W %Y',
|
||||
'month': '%B %Y',
|
||||
'year': '%Y',
|
||||
}
|
||||
|
||||
|
||||
class Base(models.AbstractModel):
|
||||
_inherit = 'base'
|
||||
|
||||
@api.model
|
||||
def get_cohort_data(self, date_start, date_stop, measure, interval, domain, mode, timeline):
|
||||
|
||||
rows = []
|
||||
columns_avg = defaultdict(lambda: dict(percentage=0, count=0))
|
||||
total_value = 0
|
||||
initial_churn_value = 0
|
||||
if measure != '__count':
|
||||
domain = expression.AND([domain, [(measure, '!=', False)]])
|
||||
measures = [f'{measure}:sum']
|
||||
field = self._fields[measure]
|
||||
if field.type == 'many2one':
|
||||
measure = f'{measure}:count_distinct'
|
||||
else:
|
||||
measure = f'{measure}:{field.aggregator}'
|
||||
measures.append(measure)
|
||||
else:
|
||||
measures = ['__count', '__count']
|
||||
|
||||
locale = get_lang(self.env).code
|
||||
|
||||
domain = expression.AND([domain, [(date_start, '!=', False)]])
|
||||
row_groups = self._read_group(
|
||||
domain=domain,
|
||||
groupby=[date_start + ':' + interval],
|
||||
aggregates=measures,
|
||||
)
|
||||
|
||||
date_start_field = self._fields[date_start]
|
||||
if date_start_field.type == 'datetime':
|
||||
today = datetime.today()
|
||||
convert_method = fields.Datetime.to_datetime
|
||||
else:
|
||||
today = date.today()
|
||||
convert_method = fields.Date.to_date
|
||||
|
||||
for group_value, sum_value, value in row_groups:
|
||||
total_value += value
|
||||
group_domain = expression.AND([
|
||||
domain,
|
||||
['&', (date_start, '>=', group_value), (date_start, '<', group_value + models.READ_GROUP_TIME_GRANULARITY[interval])]
|
||||
])
|
||||
sub_group = self._read_group(
|
||||
domain=group_domain,
|
||||
groupby=[date_stop + ':' + interval],
|
||||
aggregates=[measure],
|
||||
)
|
||||
sub_group_per_period = {
|
||||
convert_method(group_value): aggregate_value
|
||||
for group_value, aggregate_value in sub_group
|
||||
}
|
||||
|
||||
columns = []
|
||||
initial_value = sum_value
|
||||
col_range = range(-15, 1) if timeline == 'backward' else range(0, 16)
|
||||
for col_index, col in enumerate(col_range):
|
||||
col_start_date = group_value
|
||||
if interval == 'day':
|
||||
col_start_date += relativedelta(days=col)
|
||||
col_end_date = col_start_date + relativedelta(days=1)
|
||||
elif interval == 'week':
|
||||
col_start_date += relativedelta(days=7 * col)
|
||||
col_end_date = col_start_date + relativedelta(days=7)
|
||||
elif interval == 'month':
|
||||
col_start_date += relativedelta(months=col)
|
||||
col_end_date = col_start_date + relativedelta(months=1)
|
||||
else:
|
||||
col_start_date += relativedelta(years=col)
|
||||
col_end_date = col_start_date + relativedelta(years=1)
|
||||
|
||||
if col_start_date > today:
|
||||
columns_avg[col_index]
|
||||
columns.append({
|
||||
'value': '-',
|
||||
'churn_value': '-',
|
||||
'percentage': '',
|
||||
})
|
||||
continue
|
||||
|
||||
col_value = sub_group_per_period.get(col_start_date, 0.0)
|
||||
|
||||
if timeline == 'backward' and col_index == 0:
|
||||
outside_timeline_domain = expression.AND(
|
||||
[
|
||||
group_domain,
|
||||
['|',
|
||||
(date_stop, '=', False),
|
||||
(date_stop, '>=', fields.Datetime.to_string(col_start_date)),
|
||||
]
|
||||
]
|
||||
)
|
||||
col_group = self._read_group(
|
||||
domain=outside_timeline_domain,
|
||||
aggregates=[measure],
|
||||
)
|
||||
initial_value = float(col_group[0][0])
|
||||
initial_churn_value = sum_value - initial_value
|
||||
|
||||
previous_col_remaining_value = initial_value if col_index == 0 else columns[-1]['value']
|
||||
col_remaining_value = previous_col_remaining_value - col_value
|
||||
percentage = sum_value and (col_remaining_value) / sum_value or 0
|
||||
if mode == 'churn':
|
||||
percentage = 1 - percentage
|
||||
|
||||
percentage = round(100 * percentage, 1)
|
||||
|
||||
columns_avg[col_index]['percentage'] += percentage
|
||||
columns_avg[col_index]['count'] += 1
|
||||
if interval == 'week':
|
||||
period = "%s - %s" % (col_start_date.strftime('%d %b'), (col_end_date - relativedelta(days=1)).strftime('%d %b'))
|
||||
else:
|
||||
period = col_start_date.strftime(DISPLAY_FORMATS[interval])
|
||||
|
||||
if mode == 'churn':
|
||||
mode_domain = [
|
||||
(date_stop, '<', col_end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)),
|
||||
]
|
||||
else:
|
||||
mode_domain = ['|',
|
||||
(date_stop, '>=', col_end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)),
|
||||
(date_stop, '=', False),
|
||||
]
|
||||
|
||||
columns.append({
|
||||
'value': col_remaining_value,
|
||||
'churn_value': col_value + (columns[-1]['churn_value'] if col_index > 0 else initial_churn_value),
|
||||
'percentage': percentage,
|
||||
'domain': mode_domain,
|
||||
'period': period,
|
||||
})
|
||||
|
||||
rows.append({
|
||||
'date': babel.dates.format_date(
|
||||
group_value, format=models.READ_GROUP_DISPLAY_FORMAT[interval],
|
||||
locale=locale,
|
||||
),
|
||||
'value': value,
|
||||
'domain': group_domain,
|
||||
'columns': columns,
|
||||
})
|
||||
|
||||
return {
|
||||
'rows': rows,
|
||||
'avg': {'avg_value': total_value / len(rows) if rows else 0, 'columns_avg': columns_avg},
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
$o-cohort-heading-bg-color: darken(map-get($theme-colors, 'light'), 4%);
|
||||
$o-cohort-border-color: darken($o-cohort-heading-bg-color, 6%);
|
||||
$o-cohort-hover-color: lighten($o-cohort-heading-bg-color, 2%);
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { exprToBoolean } from "@web/core/utils/strings";
|
||||
import { visitXML } from "@web/core/utils/xml";
|
||||
import { INTERVALS, MODES, TIMELINES } from "./cohort_model";
|
||||
|
||||
export class CohortArchParser {
|
||||
parse(arch, fields) {
|
||||
const archInfo = {
|
||||
fieldAttrs: {},
|
||||
widgets: {},
|
||||
};
|
||||
visitXML(arch, (node) => {
|
||||
switch (node.tagName) {
|
||||
case "cohort": {
|
||||
if (node.hasAttribute("disable_linking")) {
|
||||
archInfo.disableLinking = exprToBoolean(
|
||||
node.getAttribute("disable_linking")
|
||||
);
|
||||
}
|
||||
const title = node.getAttribute("string");
|
||||
if (title) {
|
||||
archInfo.title = title;
|
||||
}
|
||||
const dateStart = node.getAttribute("date_start");
|
||||
if (dateStart) {
|
||||
archInfo.dateStart = dateStart;
|
||||
archInfo.dateStartString = fields[dateStart].string;
|
||||
} else {
|
||||
throw new Error(_t('Cohort view has not defined "date_start" attribute.'));
|
||||
}
|
||||
const dateStop = node.getAttribute("date_stop");
|
||||
if (dateStop) {
|
||||
archInfo.dateStop = dateStop;
|
||||
archInfo.dateStopString = fields[dateStop].string;
|
||||
} else {
|
||||
throw new Error(_t('Cohort view has not defined "date_stop" attribute.'));
|
||||
}
|
||||
const mode = node.getAttribute("mode") || "retention";
|
||||
if (mode && MODES.includes(mode)) {
|
||||
archInfo.mode = mode;
|
||||
} else {
|
||||
throw new Error(
|
||||
_t(
|
||||
"The argument %(mode)s is not a valid mode. Here are the modes: %(modes)s",
|
||||
{ mode, modes: MODES }
|
||||
)
|
||||
);
|
||||
}
|
||||
const timeline = node.getAttribute("timeline") || "forward";
|
||||
if (timeline && TIMELINES.includes(timeline)) {
|
||||
archInfo.timeline = timeline;
|
||||
} else {
|
||||
throw new Error(
|
||||
_t(
|
||||
"The argument %(timeline)s is not a valid timeline. Here are the timelines: %(timelines)s",
|
||||
{ timeline, timelines: TIMELINES }
|
||||
)
|
||||
);
|
||||
}
|
||||
archInfo.measure = node.getAttribute("measure") || "__count";
|
||||
const interval = node.getAttribute("interval") || "day";
|
||||
if (interval && interval in INTERVALS) {
|
||||
archInfo.interval = interval;
|
||||
} else {
|
||||
throw new Error(
|
||||
_t(
|
||||
"The argument %(interval)s is not a valid interval. Here are the intervals: %(intervals)s",
|
||||
{ interval, intervals: INTERVALS }
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "field": {
|
||||
const fieldName = node.getAttribute("name"); // exists (rng validation)
|
||||
|
||||
archInfo.fieldAttrs[fieldName] = {};
|
||||
if (node.hasAttribute("string")) {
|
||||
archInfo.fieldAttrs[fieldName].string = node.getAttribute("string");
|
||||
}
|
||||
if (
|
||||
node.getAttribute("invisible") === "True" ||
|
||||
node.getAttribute("invisible") === "1"
|
||||
) {
|
||||
archInfo.fieldAttrs[fieldName].isInvisible = true;
|
||||
break;
|
||||
}
|
||||
if (node.hasAttribute("widget")) {
|
||||
archInfo.widgets[fieldName] = node.getAttribute("widget");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return archInfo;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Layout } from "@web/search/layout";
|
||||
import { useModelWithSampleData } from "@web/model/model";
|
||||
import { standardViewProps } from "@web/views/standard_view_props";
|
||||
import { useSetupAction } from "@web/search/action_hook";
|
||||
import { SearchBar } from "@web/search/search_bar/search_bar";
|
||||
import { CogMenu } from "@web/search/cog_menu/cog_menu";
|
||||
|
||||
import { Component, toRaw, useRef } from "@odoo/owl";
|
||||
|
||||
export class CohortController extends Component {
|
||||
static template = "odex30_web_cohort.CohortView";
|
||||
static components = { Layout, SearchBar, CogMenu };
|
||||
static props = {
|
||||
...standardViewProps,
|
||||
Model: Function,
|
||||
modelParams: Object,
|
||||
Renderer: Function,
|
||||
buttonTemplate: String,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.actionService = useService("action");
|
||||
this.model = useModelWithSampleData(this.props.Model, toRaw(this.props.modelParams));
|
||||
|
||||
useSetupAction({
|
||||
rootRef: useRef("root"),
|
||||
getLocalState: () => {
|
||||
return { metaData: this.model.metaData };
|
||||
},
|
||||
getContext: () => this.getContext(),
|
||||
});
|
||||
}
|
||||
|
||||
getContext() {
|
||||
const { measure, interval } = this.model.metaData;
|
||||
return { cohort_measure: measure, cohort_interval: interval };
|
||||
}
|
||||
|
||||
|
||||
onRowClicked(row) {
|
||||
if (row.value === undefined || this.model.metaData.disableLinking) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = Object.assign({}, this.model.searchParams.context);
|
||||
const domain = row.domain;
|
||||
const views = {};
|
||||
for (const [viewId, viewType] of this.env.config.views || []) {
|
||||
views[viewType] = viewId;
|
||||
}
|
||||
function getView(viewType) {
|
||||
return [context[`${viewType}_view_id`] || views[viewType] || false, viewType];
|
||||
}
|
||||
const actionViews = [getView("list"), getView("form")];
|
||||
this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
name: this.model.metaData.title,
|
||||
res_model: this.model.metaData.resModel,
|
||||
views: actionViews,
|
||||
view_mode: "list",
|
||||
target: "current",
|
||||
context: context,
|
||||
domain: domain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
.o_cohort_view {
|
||||
.table {
|
||||
th,
|
||||
td {
|
||||
border-color: $o-cohort-border-color;
|
||||
}
|
||||
|
||||
thead,
|
||||
tfoot {
|
||||
background-color: $o-cohort-heading-bg-color;
|
||||
}
|
||||
|
||||
tbody > tr.o_cohort_row_clickable:hover {
|
||||
background-color: $o-cohort-hover-color;
|
||||
|
||||
.o_cohort_value {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
td:first-child {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
@include o-position-absolute($top: 0, $bottom: 0, $left: 0);
|
||||
width: 3px;
|
||||
background-color: $o-brand-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_cohort_no_data {
|
||||
font-size: 18px;
|
||||
background-color: $o-cohort-heading-bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.o_cohort_view .o_view_sample_data .table-responsive {
|
||||
@include o-sample-data-disabled;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue