This commit is contained in:
esam 2026-01-05 09:53:33 -05:00
parent 7c8012b7af
commit 01b9498193
333 changed files with 168085 additions and 0 deletions

View File

@ -0,0 +1,3 @@
from . import models
from . import version

View File

@ -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',
}

View File

@ -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 ""

View File

@ -0,0 +1,3 @@
from . import ir_http
from . import res_users_settings

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
declare module "@odoo/owl" {
export * from "@odoo/owl/dist/types/owl"
}

View File

@ -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%)};
}
}

View File

@ -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};
}

View File

@ -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};
}
}

View File

@ -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};
}
}

View File

@ -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};
}
}

View File

@ -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);
}
}
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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);
}

View File

@ -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);

View File

@ -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);

View File

@ -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);
}

View File

@ -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;

View File

@ -0,0 +1,7 @@
// = Search Bar
// ============================================================================
// No CSS hacks, variables overrides only
.o_searchview_facet {
--SearchBar-facet-background: #{$o-black};
}

View File

@ -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};
}

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
// = Image Field
// ============================================================================
// No CSS hacks, variables overrides only
.o_field_image {
--ImageField-background-color: #{$o-gray-900};
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,4 @@
.o-form-buttonbox {
--o-stat-button-color: currentColor;
--o-stat-text-color: #{o-text-color('primary')};
}

View File

@ -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;

View File

@ -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};
}

View File

@ -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 }
);

View File

@ -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');
}
}
}

View File

@ -0,0 +1,4 @@
// = ListRenderer
// ============================================================================
// No CSS hacks, variables overrides only

View File

@ -0,0 +1,3 @@
.o_list_renderer {
--ListRenderer-thead-border-end-color: transparent;
}

View File

@ -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());

View File

@ -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>

View File

@ -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);
},
});

View File

@ -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%;
}
}
}
}
}

View File

@ -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>

View File

@ -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 });

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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);

View File

@ -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 &lt;= 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);

View File

@ -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");
}
}
}

View File

@ -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
}
}

View File

@ -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 &gt; 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>

View File

@ -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)};
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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 &lt;= 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>

View File

@ -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");
}

View File

@ -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"));
}
}

View File

@ -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);

View File

@ -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};
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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 &amp;&amp; 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>

View File

@ -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";
}

View File

@ -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>

View File

@ -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>

View File

@ -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};
}

View File

@ -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",
});
}

View File

@ -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>

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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");
});

View File

@ -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",
});
});

View File

@ -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",
});
});

View File

@ -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",
},
],
});

View File

@ -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);
});

View File

@ -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");
});

View File

@ -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,
]);
});

View File

@ -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 ٩ نوفمبر ٢٠١٩."
);
});

View File

@ -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",
});
});

View File

@ -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");
});

View File

@ -0,0 +1,2 @@
from . import test_odex

View File

@ -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")

View File

@ -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)

View File

@ -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>

View File

@ -0,0 +1,4 @@
from . import controllers
from . import models
from . import validation

View File

@ -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',
}

View File

@ -0,0 +1,2 @@
from . import main

View File

@ -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

View File

@ -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 ""

View File

@ -0,0 +1,4 @@
from . import ir_action_act_window
from . import ir_ui_view
from . import models

View File

@ -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'})

View File

@ -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()

View File

@ -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},
}

View File

@ -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%);

View File

@ -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;
}
}

View File

@ -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,
});
}
}

View File

@ -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