合并企业版代码(未测试,先提交到测试分支)

This commit is contained in:
qihao.gong@jikimo.com
2023-04-14 17:42:23 +08:00
parent 7a7b3d7126
commit d28525526a
1300 changed files with 513579 additions and 5426 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1540" height="1025" viewBox="0 0 1540 1025">
<defs>
<linearGradient id="a" x1="36.456%" x2="27.554%" y1="61.828%" y2="30.068%">
<stop offset="0%" stop-color="#D5D5D5" stop-opacity="0" />
<stop offset="100%" stop-color="#F7F7F7" stop-opacity=".192" />
</linearGradient>
<linearGradient id="b" x1="27.448%" x2="23.156%" y1="61.964%" y2="37.686%">
<stop offset="0%" stop-color="#CECECE" stop-opacity="0" />
<stop offset="100%" stop-color="#E4E4E4" stop-opacity=".125" />
</linearGradient>
<linearGradient id="c" x1="8.322%" x2="3.065%" y1="63.424%" y2="52.838%">
<stop offset="0%" stop-color="#DCDCDC" stop-opacity=".126" />
<stop offset="100%" stop-color="#E6E6E6" stop-opacity="0" />
</linearGradient>
<linearGradient id="d" x1="21.074%" x2="23.138%" y1="65.575%" y2="56.383%">
<stop offset="0%" stop-color="#FFF" stop-opacity=".05" />
<stop offset="100%" stop-color="#FFF" stop-opacity="0" />
</linearGradient>
<linearGradient id="strokeLarge" x1="50%" x2="50%" y1="0%" y2="100%">
<stop offset="28%" stop-color="#FFF" stop-opacity="0" />
<stop offset="35%" stop-color="#FFF" stop-opacity=".2" />
<stop offset="100%" stop-color="#FFF" stop-opacity=".2" />
</linearGradient>
<linearGradient id="stroke" x1="50%" x2="50%" y1="0%" y2="100%">
<stop offset="70%" stop-color="#FFF" stop-opacity="0" />
<stop offset="72%" stop-color="#FFF" stop-opacity=".1" />
</linearGradient>
</defs>
<g fill="none" fill-rule="evenodd" transform="translate(-145 -1542)">
<ellipse cx="1213" cy="2184" fill="url(#a)" rx="1212" ry="1573" />
<ellipse cx="1440" cy="2297" fill="url(#b)" stroke="url(#strokeLarge)" rx="1313" ry="1473" />
<ellipse cx="2477" cy="1573" fill="url(#c)" stroke="url(#stroke)" rx="2158" ry="1573" />
<ellipse cx="2336" cy="1667" fill="url(#d)" stroke="url(#stroke)" rx="1807" ry="1103"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,12 @@
// Custom SCSS for enterprise version of notebook tabs
.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 @@
$o-notification-shadow: 0 12px 14px -10px rgba(0, 0, 0, .25) !default;

View File

@@ -0,0 +1,11 @@
odoo.define('web_enterprise.apps', function (require) {
"use strict";
var Apps = require('web.Apps');
Apps.include({
// Do nothing on update count as needactions have been removed in enterprise
_on_update_count: function() {},
});
});

View File

@@ -0,0 +1,145 @@
odoo.define('web_enterprise.ControlPanel', function (require) {
"use strict";
const ControlPanel = require('web.ControlPanel');
const { device } = require('web.config');
const { patch } = require('web.utils');
const { onMounted, useExternalListener, useRef, useState, useEffect } = owl;
const STICKY_CLASS = 'o_mobile_sticky';
if (!device.isMobile) {
return;
}
/**
* Control panel: mobile layout
*
* This patch handles the scrolling behaviour of the control panel in a mobile
* environment: the panel sticks to the top of the window when scrolling into
* the view. It is revealed when scrolling up and hiding when scrolling down.
* The panel's position is reset to default when at the top of the view.
*/
patch(ControlPanel.prototype, 'web_enterprise.ControlPanel', {
setup() {
this._super();
this.controlPanelRef = useRef("controlPanel");
this.state = useState({
showSearchBar: false,
showMobileSearch: false,
showViewSwitcher: false,
});
this.onWindowClick = this._onWindowClick.bind(this);
this.onScrollThrottled = this._onScrollThrottled.bind(this);
useExternalListener(window, "click", this.onWindowClick);
useEffect(() => {
const scrollingEl = this._getScrollingElement();
scrollingEl.addEventListener("scroll", this.onScrollThrottled);
this.controlPanelRef.el.style.top = "0px";
return () => {
scrollingEl.removeEventListener("scroll", this.onScrollThrottled);
}
})
onMounted(() => {
this.oldScrollTop = 0;
this.lastScrollTop = 0;
this.initialScrollTop = this._getScrollingElement().scrollTop;
});
},
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
_getScrollingElement() {
return this.controlPanelRef.el.parentElement;
},
/**
* Get today's date (number).
* @private
* @returns {number}
*/
_getToday() {
return new Date().getDate();
},
/**
* Reset mobile search state
* @private
*/
_resetSearchState() {
Object.assign(this.state, {
showSearchBar: false,
showMobileSearch: false,
showViewSwitcher: false,
});
},
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* Show or hide the control panel on the top screen.
* The function is throttled to avoid refreshing the scroll position more
* often than necessary.
* @private
*/
_onScrollThrottled() {
if (!this.controlPanelRef.el || this.isScrolling) {
return;
}
this.isScrolling = true;
requestAnimationFrame(() => this.isScrolling = false);
const scrollTop = this._getScrollingElement().scrollTop;
const delta = Math.round(scrollTop - this.oldScrollTop);
if (scrollTop > this.initialScrollTop) {
// Beneath initial position => sticky display
this.controlPanelRef.el.classList.add(STICKY_CLASS);
this.lastScrollTop = delta < 0 ?
// Going up
Math.min(0, this.lastScrollTop - delta) :
// Going down | not moving
Math.max(-this.controlPanelRef.el.offsetHeight, -this.controlPanelRef.el.offsetTop - delta);
this.controlPanelRef.el.style.top = `${this.lastScrollTop}px`;
} else {
// Above initial position => standard display
this.controlPanelRef.el.classList.remove(STICKY_CLASS);
this.lastScrollTop = 0;
}
this.oldScrollTop = scrollTop;
},
/**
* Reset mobile search state on switch view.
* @private
*/
_onSwitchView() {
this._resetSearchState();
},
/**
* @private
* @param {MouseEvent} ev
*/
_onWindowClick(ev) {
if (
this.state.showViewSwitcher &&
!ev.target.closest('.o_cp_switch_buttons')
) {
this.state.showViewSwitcher = false;
}
},
});
patch(ControlPanel, 'web_enterprise.ControlPanel', {
template: 'web_enterprise._ControlPanel',
});
});

View File

@@ -0,0 +1,75 @@
odoo.define("web_enterprise.Dialog", function(require) {
"use strict";
const config = require("web.config");
if (!config.device.isMobile) {
return;
}
const Dialog = require("web.Dialog");
Dialog.include({
/**
* @override
*
* @param {Widget} parent
* @param {Array} options.headerButtons
*/
init(parent, { headerButtons }) {
this._super.apply(this, arguments);
this.headerButtons = headerButtons || [];
},
/**
* Renders the header as a "Top App Bar" layout :
* - navigation icon: right arrow that closes the dialog;
* - title: same as on desktop;
* - action buttons: optional actions related to the current screen.
* Only applied for fullscreen dialog.
*
* Also, get the current scroll position for mobile devices in order to
* maintain offset while dialog is closed.
*
* @override
*/
async willStart() {
const prom = await this._super.apply(this, arguments);
if (this.renderHeader && this.fullscreen) {
const $modalHeader = this.$modal.find(".modal-header");
$modalHeader.find("button.btn-close").remove();
const $navigationBtn = $("<button>", {
class: "btn fa fa-arrow-left",
"data-bs-dismiss": "modal",
"aria-label": "close",
});
$modalHeader.prepend($navigationBtn);
const $btnContainer = $("<div>");
this._setButtonsTo($btnContainer, this.headerButtons);
$modalHeader.append($btnContainer);
}
// need to get scrollPosition prior opening the dialog else body will scroll to
// top due to fixed position applied on it with help of 'modal-open' class.
this.scrollPosition = {
top: window.scrollY || document.documentElement.scrollTop,
left: window.scrollX || document.documentElement.scrollLeft,
};
return prom;
},
/**
* Scroll to original position while closing modal in mobile devices
*
* @override
*/
destroy() {
if (this.$modal && $('.modal[role="dialog"]').filter(":visible").length <= 1) {
// in case of multiple open dialogs, only reset scroll while closing the last one
// (it can be done only if there's no fixed position on body and thus by removing
// 'modal-open' class responsible for fixed position)
this.$modal.closest("body").removeClass("modal-open");
window.scrollTo(this.scrollPosition);
}
this._super(...arguments);
},
});
});

View File

@@ -0,0 +1,104 @@
odoo.define("web.SearchPanel.Small", function (require) {
"use strict";
const SearchPanel = require("web.searchPanel");
const { device } = require("web.config");
const { patch } = require('web.utils');
if (!device.isMobile) {
return;
}
//-------------------------------------------------------------------------
// Helpers
//-------------------------------------------------------------------------
const isFilter = (s) => s.type === "filter";
/**
* @param {Map} values
* @returns {Object[]}
*/
function nameOfCheckedValues(values) {
const names = [];
for (const [ , value] of values) {
if (value.checked) {
names.push(value.display_name);
}
}
return names;
}
patch(SearchPanel.prototype, "web_enterprise.SearchPanel.Mobile", {
setup() {
this._super(...arguments);
this.state.showMobileSearch = false;
},
//-----------------------------------------------------------------
// Private
//-----------------------------------------------------------------
/**
* Returns a formatted version of the active categories to populate
* the selection banner of the control panel summary.
* @private
* @returns {Object[]}
*/
_getCategorySelection() {
const activeCategories = this.model.get("sections",
(s) => s.type === "category" && s.activeValueId
);
const selection = [];
for (const category of activeCategories) {
const parentIds = this._getAncestorValueIds(
category,
category.activeValueId
);
const orderedCategoryNames = [
...parentIds,
category.activeValueId,
].map(
(valueId) => category.values.get(valueId).display_name
);
selection.push({
values: orderedCategoryNames,
icon: category.icon,
color: category.color,
});
}
return selection;
},
/**
* Returns a formatted version of the active filters to populate
* the selection banner of the control panel summary.
* @private
* @returns {Object[]}
*/
_getFilterSelection() {
const filters = this.model.get("sections", isFilter);
const selection = [];
for (const { groups, values, icon, color } of filters) {
let filterValues;
if (groups) {
filterValues = Object.keys(groups)
.map((groupId) =>
nameOfCheckedValues(groups[groupId].values)
)
.flat();
} else if (values) {
filterValues = nameOfCheckedValues(values);
}
if (filterValues.length) {
selection.push({ values: filterValues, icon, color });
}
}
return selection;
},
});
patch(SearchPanel, "web_enterprise.SearchPanel.Mobile", {
template: "web_enterprise.Legacy.SearchPanel.Mobile",
});
});

View File

@@ -0,0 +1,121 @@
odoo.define('web_mobile.barcode_fields', function (require) {
"use strict";
var field_registry = require('web.field_registry');
require('web._field_registry');
var relational_fields = require('web.relational_fields');
const { _t } = require('web.core');
const BarcodeScanner = require('@web/webclient/barcode/barcode_scanner');
/**
* Override the Many2One to open a dialog in mobile.
*/
var FieldMany2OneBarcode = relational_fields.FieldMany2One.extend({
template: "FieldMany2OneBarcode",
events: _.extend({}, relational_fields.FieldMany2One.prototype.events, {
'click .o_barcode': '_onBarcodeButtonClick',
}),
/**
* @override
*/
start: function () {
var result = this._super.apply(this, arguments);
this._startBarcode();
return result;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* External button is visible
*
* @return {boolean}
* @private
*/
_isExternalButtonVisible: function () {
return this.$external_button.is(':visible');
},
/**
* Hide the search more option
*
* @param {Array} values
*/
_manageSearchMore(values) {
return values;
},
/**
* @override
* @private
*/
_renderEdit: function () {
this._super.apply(this, arguments);
// Hide button if a record is set or external button is visible
if (this.$barcode_button) {
this.$barcode_button.toggle(!this._isExternalButtonVisible());
}
},
/**
* Initialisation of barcode button
*
* @private
*/
_startBarcode: function () {
this.$barcode_button = this.$('.o_barcode');
// Hide button if a record is set
this.$barcode_button.toggle(!this.isSet());
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* On click on button
*
* @private
*/
async _onBarcodeButtonClick() {
const barcode = await BarcodeScanner.scanBarcode();
if (barcode) {
this._onBarcodeScanned(barcode);
if ('vibrate' in window.navigator) {
window.navigator.vibrate(100);
}
} else {
this.displayNotification({
type: 'warning',
message: 'Please, scan again !',
});
}
},
/**
* When barcode is scanned
*
* @param barcode
* @private
*/
async _onBarcodeScanned(barcode) {
const results = await this._search(barcode);
const records = results.filter(r => !!r.id);
if (records.length === 1) {
this._setValue({ id: records[0].id });
} else {
const dynamicFilters = [{
description: _.str.sprintf(_t('Quick search: %s'), barcode),
domain: [['id', 'in', records.map(r => r.id)]],
}];
this._searchCreatePopup("search", false, {}, dynamicFilters);
}
},
});
if (BarcodeScanner.isBarcodeScannerSupported()) {
field_registry.add('many2one_barcode', FieldMany2OneBarcode);
}
return FieldMany2OneBarcode;
});

View File

@@ -0,0 +1,114 @@
odoo.define('web_enterprise.BasicRenderer', function (require) {
"use strict";
const config = require('web.config');
if (!config.device.isMobile) {
return;
}
/**
* This file defines the MobileBasicRenderer, an extension of the BasicRenderer
* implementing some tweaks to improve the UX in mobile.
*/
const BasicRenderer = require('web.BasicRenderer');
BasicRenderer.include({
SHOW_AFTER_DELAY: 250,
/**
* @override
*/
init() {
this._super(...arguments);
this.showTimer = undefined;
this.tooltipNodes = [];
this._onTouchStartTooltipBind = this._onTouchStartTooltip.bind(this);
this._onTouchEndTooltipBind = this._onTouchEndTooltip.bind(this);
},
/**
* @override
*/
on_attach_callback() {
this._super(...arguments);
this._addListener();
},
/**
* @override
*/
on_detach_callback() {
this._removeListeners();
this._super(...arguments);
},
_addListener: function () {
this.tooltipNodes.forEach((nodeElement) => {
nodeElement.addEventListener('touchstart', this._onTouchStartTooltipBind);
nodeElement.addEventListener('touchend', this._onTouchEndTooltipBind);
nodeElement.classList.add('o_user_select_none');
});
},
/**
* Allow to change when the tooltip appears
*
* @override
*/
_addFieldTooltip: function (widget, $node) {
this._super(...arguments);
$node = $node.length ? $node : widget.$el;
const nodeElement = $node[0];
if (!this.tooltipNodes.some(node => node === nodeElement)) {
this.tooltipNodes.push(nodeElement);
}
},
/**
* @override
*/
_getTooltipOptions: function () {
return Object.assign({}, this._super(...arguments), {
trigger: 'manual',
});
},
/**
* @override
*/
_render: function () {
return this._super(...arguments).then(() => {
this._addListener();
});
},
_removeListeners: function () {
while (this.tooltipNodes.length) {
const node = this.tooltipNodes.shift();
node.removeEventListener('touchstart', this._onTouchStartTooltipBind);
node.removeEventListener('touchend', this._onTouchEndTooltipBind);
node.classList.remove('o_user_select_none');
}
},
/**
* @private
* @param {TouchEvent} event
*/
_onTouchEndTooltip: function (event) {
clearTimeout(this.showTimer);
const $node = $(event.target);
$node.tooltip('hide');
},
/**
* @private
* @param {TouchEvent} event
*/
_onTouchStartTooltip: function (event) {
// Exclude children element from this handler
if (!event.target.classList.contains('o_user_select_none')) {
return;
}
const $node = $(event.target);
clearTimeout(this.showTimer);
this.showTimer = setTimeout(() => {
$node.tooltip('show');
}, this.SHOW_AFTER_DELAY);
},
});
});

View File

@@ -0,0 +1,84 @@
odoo.define('web_enterprise.MobileFormRenderer', function (require) {
"use strict";
const config = require('web.config');
if (!config.device.isMobile) {
return;
}
/**
* This file defines the MobileFormRenderer, an extension of the FormRenderer
* implementing some tweaks to improve the UX in mobile.
*/
const core = require('web.core');
const FormRenderer = require('web.FormRenderer');
const qweb = core.qweb;
FormRenderer.include({
/**
* Reset the 'state' of the drop down
* @override
*/
updateState: function () {
this.isStatusbarButtonsDropdownOpen = undefined;
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Show action drop-down if there are multiple visible buttons/widgets.
*
* @private
* @override
*/
_renderStatusbarButtons: function (buttons) {
// Removes 'o_invisible_modifier' buttons in addition to those that match this selector:
// .o_form_view.o_form_editable .oe_read_only {
// display: none !important;
// }
const $visibleButtons = buttons.filter(button => {
return !($(button).hasClass('o_invisible_modifier') || (this.mode === 'edit' && $(button).hasClass('oe_read_only')));
});
if ($visibleButtons.length > 1) {
const $statusbarButtonsDropdown = $(qweb.render('StatusbarButtonsDropdown', {
open: this.isStatusbarButtonsDropdownOpen,
}));
$statusbarButtonsDropdown.find('.btn-group').on('show.bs.dropdown', () => {
this.isStatusbarButtonsDropdownOpen = true;
});
$statusbarButtonsDropdown.find('.btn-group').on('hide.bs.dropdown', () => {
this.isStatusbarButtonsDropdownOpen = false;
});
const $dropdownMenu = $statusbarButtonsDropdown.find('.dropdown-menu');
buttons.forEach(button => {
const dropdownButton = $(button).addClass('dropdown-item');
return $dropdownMenu.append(dropdownButton);
});
return $statusbarButtonsDropdown;
}
buttons.forEach(button => $(button).removeClass('dropdown-item'));
return this._super.apply(this, arguments);
},
/**
* Update the UI statusbar button after all modifiers are updated.
*
* @override
* @private
*/
_updateAllModifiers: function () {
return this._super.apply(this, arguments).then(() => {
const $statusbarButtonsContainer = this.$('.o_statusbar_buttons');
const $statusbarButtons = $statusbarButtonsContainer.find('button.btn').toArray();
$statusbarButtonsContainer.replaceWith(this._renderStatusbarButtons($statusbarButtons));
});
},
});
});

View File

@@ -0,0 +1,40 @@
odoo.define('web_mobile.FormView', function (require) {
"use strict";
var config = require('web.config');
var FormView = require('web.FormView');
var QuickCreateFormView = require('web.QuickCreateFormView');
/**
* We don't want to see the keyboard after the opening of a form view.
* The keyboard takes a lot of space and the user doesn't have a global view
* on the form.
* Plus, some relational fields are overrided (Many2One for example) and the
* user have to click on it to set a value. On this kind of field, the autofocus
* doesn't make sense because you can't directly type on it.
* So, we have to disable the autofocus in mobile.
*/
FormView.include({
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
if (config.device.isMobile) {
this.controllerParams.disableAutofocus = true;
}
},
});
QuickCreateFormView.include({
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
this.controllerParams.disableAutofocus = false;
},
});
});

View File

@@ -0,0 +1,73 @@
odoo.define('web.KanbanTabsMobileMixin', function () {
"use strict";
const KanbanTabsMobileMixin = {
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Update the tabs positions
*
* @param tabs - All existing tabs in the kanban
* @param moveToIndex - The current Active tab in the index
* @param $tabsContainer - the jquery container of the tabs
* @private
*/
_computeTabPosition(tabs, moveToIndex, $tabsContainer) {
this._computeTabJustification(tabs, $tabsContainer);
this._computeTabScrollPosition(tabs, moveToIndex, $tabsContainer);
},
/**
* Update the tabs positions
*
* @param tabs - All existing tabs in the kanban
* @param moveToIndex - The current Active tab in the index
* @param $tabsContainer - the jquery container of the tabs
* @private
*/
_computeTabScrollPosition(tabs, moveToIndex, $tabsContainer) {
if (tabs.length) {
const lastItemIndex = tabs.length - 1;
let scrollToLeft = 0;
for (let i = 0; i < moveToIndex; i++) {
const columnWidth = this._getTabWidth(tabs[i]);
// apply
if (moveToIndex !== lastItemIndex && i === moveToIndex - 1) {
const partialWidth = 0.75;
scrollToLeft += columnWidth * partialWidth;
} else {
scrollToLeft += columnWidth;
}
}
// Apply the scroll x on the tabs
// XXX in case of RTL, should we use scrollRight?
$tabsContainer.scrollLeft(scrollToLeft);
}
},
/**
* Compute the justify content of the kanban tab headers
* @param tabs - All existing tabs in the kanban
* @param $tabsContainer - the jquery container of the tabs
* @private
*/
_computeTabJustification(tabs, $tabsContainer) {
if (tabs.length) {
// Use to compute the sum of the width of all tab
const widthChilds = tabs.reduce((total, column) => total + this._getTabWidth(column), 0);
// Apply a space around between child if the parent length is higher then the sum of the child width
$tabsContainer.toggleClass('justify-content-between', $tabsContainer.outerWidth() >= widthChilds);
}
},
/**
* Retrieve the outerWidth of a given tab
*
* @param tab
* @returns {integer} outerWidth of the found column
* @abstract
*/
_getTabWidth(tab) {
}
};
return KanbanTabsMobileMixin;
});

View File

@@ -0,0 +1,68 @@
odoo.define('web_enterprise.ListControllerMobile', function (require) {
"use strict";
const config = require('web.config');
if (!config.device.isMobile) {
return;
}
const ListController = require('web.ListController');
ListController.include({
events: Object.assign({}, ListController.prototype.events, {
'click .o_discard_selection': '_onDiscardSelection'
}),
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* In mobile, we hide the "export" button.
*
* @override
*/
renderButtons() {
this._super(...arguments);
this.$buttons.find('.o_list_export_xlsx').hide();
},
/**
* In mobile, we hide the "export" button.
*
* @override
*/
updateButtons() {
this._super(...arguments);
this.$buttons.find('.o_list_export_xlsx').hide();
},
/**
* In mobile, we let the selection banner be added to the ControlPanel to enable the ActionMenus.
*
* @override
*/
async updateControlPanel() {
const value = await this._super(...arguments);
const displayBanner = Boolean(this.$selectionBox);
if (displayBanner) {
this._controlPanelWrapper.el.querySelector('.o_cp_bottom').prepend(this.$selectionBox[0]);
}
return value;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Discard the current selection by unselecting any selected records.
*
* @private
*/
_onDiscardSelection() {
this.renderer.$('tbody .o_list_record_selector input:not(":disabled")').prop('checked', false);
this.renderer._updateSelection();
}
});
});

View File

@@ -0,0 +1,77 @@
odoo.define('web_enterprise.ListRenderer', function (require) {
"use strict";
const config = require('web.config');
if (config.device.isMobile) {
return;
}
const { qweb } = require('web.core');
const ListRenderer = require('web.ListRenderer');
const ListView = require('web.ListView');
const session = require('web.session');
const { patch } = require('web.utils');
const PromoteStudioDialog = require('web_enterprise.PromoteStudioDialog');
patch(ListView.prototype, 'web_enterprise.ListView', {
init: function (viewInfo, params) {
this._super(viewInfo, params);
this.rendererParams.isStudioEditable = params.action && !!params.action.xml_id;
},
});
patch(ListRenderer.prototype, 'web_enterprise.ListRenderer', {
init: function (parent, state, params) {
this._super(...arguments);
this.isStudioEditable = params.isStudioEditable;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* This function adds a button at the bottom of the optional
* columns dropdown menu. This button opens studio if installed and
* promote studio if not installed.
*
* @override
*/
_renderOptionalColumnsDropdown: function () {
const $optionalColumnsDropdown = this._super(...arguments);
if (session.is_system && this.isStudioEditable) {
const $dropdownMenu = $optionalColumnsDropdown.find('.dropdown-menu');
if (this.optionalColumns.length) {
$dropdownMenu.append($("<hr />"));
}
const $addCustomField = $(qweb.render('web_enterprise.open_studio_button'));
$dropdownMenu.append($addCustomField);
$addCustomField.click(this._onAddCustomFieldClick.bind(this));
}
return $optionalColumnsDropdown;
},
/**
* This function returns if the optional columns dropdown menu should be rendered.
* This function returns true iff there are optional columns or the user is system
* admin.
*
* @override
*/
_shouldRenderOptionalColumnsDropdown: function () {
return this._super(...arguments) || session.is_system;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* This function opens studio dialog
*
* @param {Event} event
* @private
*/
_onAddCustomFieldClick: function (event) {
event.stopPropagation();
new PromoteStudioDialog(this).open();
},
});
});

View File

@@ -0,0 +1,186 @@
odoo.define('web_enterprise.MobileListRenderer', function (require) {
"use strict";
const config = require('web.config');
if (!config.device.isMobile) {
return;
}
const ListRenderer = require('web.ListRenderer');
ListRenderer.include({
events: Object.assign({}, ListRenderer.prototype.events, {
'touchstart .o_data_row': '_onTouchStartSelectionMode',
'touchmove .o_data_row': '_onTouchMoveSelectionMode',
'touchend .o_data_row': '_onTouchEndSelectionMode',
}),
init() {
this._super(...arguments);
this.longTouchTimer = null;
this.LONG_TOUCH_THRESHOLD = 400;
this._onClickCapture = this._onClickCapture.bind(this);
this._ignoreEventInSelectionMode = this._ignoreEventInSelectionMode.bind(this);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* In mobile, disable the read-only editable list.
*
* @override
*/
isEditable() {
return this.editable;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Returns the jQuery node used to update the selection ; visiblity insensitive.
*
* @override
*/
_getSelectableRecordCheckboxes() {
return this.$('tbody .o_list_record_selector input:not(:disabled)');
},
/**
* In mobile, disable the read-only editable list.
*
* @override
*/
_isRecordEditable() {
return this.editable;
},
/**
* Reset the current long-touch timer.
*
* @private
*/
_resetLongTouchTimer() {
if (this.longTouchTimer) {
clearTimeout(this.longTouchTimer);
this.longTouchTimer = null;
}
},
/**
* @override
*/
_updateSelection() {
this._super(...arguments);
this._getSelectableRecordCheckboxes()
.each((index, input) => {
$(input).closest('.o_data_row').toggleClass('o_data_row_selected', input.checked);
});
},
_isInSelectionMode(ev) {
return !!this.selection.length;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Prevent from opening the record on click when in selection mode.
*
* @override
*/
_onRowClicked(ev) {
if (this.selection.length) {
ev.preventDefault();
return;
}
this._super(...arguments);
},
/**
* Following @see _onTouchStartSelectionMode, we cancel the long-touch if it was shorter
* than @see LONG_TOUCH_THRESHOLD.
*
* @private
*/
_onTouchEndSelectionMode() {
const elapsedTime = Date.now() - this.touchStartMs;
if (elapsedTime < this.LONG_TOUCH_THRESHOLD) {
this._resetLongTouchTimer();
}
},
/**
* Following @see _onTouchStartSelectionMode, we cancel the long-touch.
*
* @private
*/
_onTouchMoveSelectionMode() {
this._resetLongTouchTimer();
},
/**
* We simulate a long-touch on a row by delaying (@see LONG_TOUCH_THRESHOLD)
* the actual handler and potentially cancelling it if the user moves or end
* its 'touch' before the timer's end.
*
* @private
* @param ev
*/
_onTouchStartSelectionMode(ev) {
this.touchStartMs = Date.now();
if (this.longTouchTimer === null) {
this.longTouchTimer = setTimeout(() => {
$(ev.currentTarget).find('.o_list_record_selector').click();
this._resetLongTouchTimer();
}, this.LONG_TOUCH_THRESHOLD);
}
},
_ignoreEventInSelectionMode(ev) {
if (this._isInSelectionMode()) {
ev.stopPropagation();
ev.preventDefault();
}
},
_onClickCapture(ev) {
if (!this._isInSelectionMode()) {
return;
}
const currentRow = $(ev.target).closest('.o_data_row');
if (!currentRow.length) {
return;
}
if (!ev.target.classList.contains('o_list_record_selector')) {
ev.stopPropagation();
ev.preventDefault();
$('.o_list_record_selector', currentRow).click();
}
},
_delegateEvents() {
this._super(...arguments);
this.$el[0].addEventListener('click', this._onClickCapture, { capture: true });
['mouseover', 'mouseout'].forEach(name =>
this.$el[0].addEventListener(name, this._ignoreEventInSelectionMode, { capture: true }));
},
_undelegateEvents() {
this._super(...arguments);
this.$el[0].removeEventListener('click', this._onClickCapture, { capture: true });
['mouseover', 'mouseout'].forEach(name =>
this.$el[0].removeEventListener(name, this._ignoreEventInSelectionMode, { capture: true }));
},
});
});

View File

@@ -0,0 +1,125 @@
odoo.define('web_enterprise.relational_fields', function (require) {
"use strict";
var config = require('web.config');
if (!config.device.isMobile) {
return;
}
/**
* In this file, we override some relational fields to improve the UX in mobile.
*/
var core = require('web.core');
var relational_fields = require('web.relational_fields');
var FieldStatus = relational_fields.FieldStatus;
var FieldMany2One = relational_fields.FieldMany2One;
var FieldX2Many = relational_fields.FieldX2Many;
var qweb = core.qweb;
FieldStatus.include({
/**
* Override the custom behavior of FieldStatus to hide it if it is not set,
* in mobile (which is the default behavior for fields).
*
* @returns {boolean}
*/
isEmpty: function () {
return !this.isSet();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
*/
_render: function () {
this.$el.html(qweb.render("FieldStatus.content.mobile", {
selection: this.status_information,
status: _.findWhere(this.status_information, {selected: true}),
clickable: this.isClickable,
}));
},
});
/**
* Override the Many2One to prevent autocomplete and open kanban view in mobile for search.
*/
FieldMany2One.include({
start: function () {
var superRes = this._super.apply(this, arguments);
this.$input.prop('readonly', true);
return superRes;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Don't bind autocomplete in the mobile as it uses a different mechanism
* On clicking Many2One will directly open popup with kanban view
*
* @private
* @override
*/
_bindAutoComplete: function () {},
/**
* override to add selectionMode option to search create popup option
*
* @private
* @override
*/
_getSearchCreatePopupOptions: function () {
var self = this;
var searchCreatePopupOptions = this._super.apply(this, arguments);
_.extend(searchCreatePopupOptions, {
selectionMode: true,
on_clear: function () {
self.reinitialize(false);
},
});
return searchCreatePopupOptions;
},
/**
* We always open Many2One search dialog for select/update field value
* instead of autocomplete
*
* @private
* @override
*/
_toggleAutoComplete: function () {
this._searchCreatePopup("search");
},
});
FieldX2Many.include({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
*/
_renderButtons: function () {
var result = this._super.apply(this, arguments);
if (this.$buttons) {
this.$buttons
.find('.btn-secondary')
.removeClass('btn-secondary')
.addClass('btn-primary btn-add-record');
}
return result;
}
});
});

View File

@@ -0,0 +1,67 @@
odoo.define('web_enterprise.view_dialogs', function (require) {
"use strict";
var config = require('web.config');
if (!config.device.isMobile) {
return;
}
var core = require('web.core');
var view_dialogs = require('web.view_dialogs');
var _t = core._t;
view_dialogs.FormViewDialog.include({
/**
* @override
*/
init(parent, options) {
options.headerButtons = options.headerButtons || [];
this._super.apply(this, arguments);
},
/**
* Set the "Remove" button to the dialog's header buttons set
*
* @override
*/
_setRemoveButtonOption(options, btnClasses) {
options.headerButtons.push({
text: _t("Remove"),
classes: `btn-secondary ${btnClasses}`,
click: async () => {
await this._remove();
this.close();
},
});
},
});
view_dialogs.SelectCreateDialog.include({
init: function () {
this._super.apply(this,arguments);
this.on_clear = this.options.on_clear || (function () {});
this.viewType = 'kanban';
},
/**
* @override
*/
_prepareButtons: function () {
this._super.apply(this, arguments);
if (this.options.disable_multiple_selection) {
if (this.options.selectionMode) {
this.headerButtons.push({
text: _t("Clear"),
classes: 'btn-secondary o_clear_button',
close: true,
click: function () {
this.on_clear();
},
});
}
} else {
this.__buttons = this.__buttons.filter(button => !button.classes.split(' ').includes('o_select_button'));
}
},
});
});

View File

@@ -0,0 +1,96 @@
odoo.define('web_enterprise.PromoteStudioDialog', function (require) {
"use strict";
const core = require('web.core');
const Dialog = require('web.Dialog');
const framework = require('web.framework');
const localStorage = require('web.local_storage');
const qweb = core.qweb;
const PromoteStudioDialog = Dialog.extend({
events: _.extend({}, Dialog.prototype.events, {
'click button.o_install_studio': '_onInstallStudio',
}),
/**
* This init function adds a click listener on window to handle
* modal closing.
* @override
*/
init: function (parent, options) {
options = _.defaults(options || {}, {
$content: $(qweb.render('web_enterprise.install_web_studio')),
renderHeader: false,
renderFooter: false,
size: 'large',
});
this._super(parent, options);
},
/**
* This function adds an handler for window clicks at the end of start.
*
* @override
*/
start: async function () {
await this._super.apply(this, arguments);
core.bus.on('click', this, this._onWindowClick);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* This function both installs studio and reload the current view in studio mode.
*
* @param {Event} ev
*/
_onInstallStudio: async function (event) {
event.stopPropagation();
this.disableClick = true;
framework.blockUI();
const modules = await this._rpc({
model: 'ir.module.module',
method: 'search_read',
fields: ['id'],
domain: [['name', '=', 'web_studio']],
});
await this._rpc({
model: 'ir.module.module',
method: 'button_immediate_install',
args: [[modules[0].id]],
});
// on rpc call return, the framework unblocks the page
// make sure to keep the page blocked until the reload ends.
framework.blockUI();
localStorage.setItem('openStudioOnReload', 'main');
this._reloadPage();
},
/**
* Close modal when the user clicks outside the modal WITHOUT propagating
* the event.
* We must use this function to keep dropdown menu open when the user clicks in the modal, too.
* This behaviour cannot be handled by using the modal backdrop.
*
* @param {Event} event
*/
_onWindowClick: function (event) {
const $modal = $(event.target).closest('.modal-studio');
if (!$modal.length && !this.disableClick) {
this._onCloseDialog(event);
}
event.stopPropagation();
},
//--------------------------------------------------------------------------
// Utils
//--------------------------------------------------------------------------
/**
* This function isolate location reload in order to easily mock it (in tests for example)
*
* @private
*/
_reloadPage: function () {
window.location.reload();
},
});
return PromoteStudioDialog;
});

View File

@@ -0,0 +1,15 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
export const legacyServiceProvider = {
dependencies: ["home_menu"],
start({ services }) {
browser.addEventListener("show-home-menu", () => {
services.home_menu.toggle(true);
});
},
};
registry.category("services").add("enterprise_legacy_service_provider", legacyServiceProvider);

View File

@@ -0,0 +1,17 @@
@include media-breakpoint-down(md) {
.o_control_panel {
.o_cp_today_button {
margin-left: auto;
}
.o_calendar_button_today {
font-size: 0.7em;
line-height: 1.9em;
> .o_calendar_text {
margin-top: 3px;
}
}
.o_renderer_selection_banner .o_cp_action_menus {
margin-left: auto;
}
}
}

View File

@@ -0,0 +1,36 @@
// = Enterprise dropdown customizations
// ----------------------------------------------------------------------------
.o_web_client.o_touch_device {
.dropdown-item > *:first-child {
&:before {
padding-top: 7.5px;
}
}
}
.dropdown {
&.show .dropdown-toggle.btn-light,
.dropdown-toggle.o_dropdown_active.btn-light {
&, &:hover, &:focus {
position: relative;
z-index: $zindex-dropdown + 1;
border-color: $dropdown-border-color;
border-bottom-color: $dropdown-bg;
background-color: $dropdown-bg;
box-shadow: none;
color: $dropdown-link-active-color;
}
+ .dropdown-menu {
margin-top: $dropdown-border-width * -1;
}
}
}
// = Enterprise jQueryUI dropdown
// ----------------------------------------------------------------------------
.ui-widget.ui-autocomplete {
border-top: $dropdown-border-width solid transparent;
}

View File

@@ -0,0 +1,85 @@
//
// This file regroups all the rules which apply to field widgets wherever they
// are in the DOM, in enterprise version.
//
//------------------------------------------------------------------------------
// Fields
//------------------------------------------------------------------------------
.o_field_widget.o_legacy_field_widget {
.o_field_many2one, .o_field_many2manytags {
.o_input_dropdown .o_input {
transform: translateY($input-border-width + $dropdown-border-width);
}
}
// HTML fields
&.o_field_html.o_field_html { // Make rules more important
.note-editor {
border-color: map-get($grays, '400');
}
.note-editable {
border-radius: 0;
}
:not(.oe-bordered-editor).note-editable {
border: 0;
padding: 3px 0 5px;
}
.note-toolbar.panel-heading, .popover-body {
border-color: map-get($grays, '400');
background: map-get($grays, '100');
.btn-secondary {
background: transparent;
border-color: transparent;
@include o-hover-text-color($body-color, $link-color);
}
.show .btn-secondary, .btn-secondary.active, .btn-secondary:focus {
background: white !important;
color: $body-color !important;
border-color: map-get($grays, '400');
}
.dropdown-menu {
margin-top: 0;
border-top: none;
border-color: map-get($grays, '400');
background-color: white;
box-shadow: none;
a {
background-color: transparent;
color: $body-color;
&:hover {
background-color: map-get($grays, '200');
}
}
&.show .btn-secondary, .btn-secondary.active, .btn-secondary:focus {
border: none;
}
}
.btn-group.show::after {
@include o-position-absolute(auto, 1px, -1px, 1px);
height: 1px;
background-color: white;
}
}
}
}
.o_field_many2one {
input[type="text"]:disabled{
background-color:#FFF;
}
}
.ui-autocomplete .ui-menu-item {
&.o_m2o_dropdown_option > a {
color: $o-brand-primary;
&.ui-state-active {
color: $o-brand-primary;
}
}
}

View File

@@ -0,0 +1,361 @@
.o_legacy_form_view {
@include media-breakpoint-up(md) {
display: flex;
flex-flow: column nowrap;
min-height: 100%;
}
.o_statusbar_status .o_arrow_button:not(.disabled) {
@extend .btn-light;
&:hover, &:focus, &:active {
&:after {
border-left-color: $o-btn-light-background-hover;
}
}
}
// Sheet
.o_form_sheet_bg {
flex: 1 0 auto;
background-color: $o-webclient-background-color;
border-bottom: 1px solid $border-color;
> .o_form_sheet {
@include make-container();
@include make-container-max-widths();
background-color: $o-view-background-color;
border: 1px solid $border-color;
box-shadow: 0 5px 20px -15px rgba(#000, 0.4);
margin: $o-sheet-vpadding*0.2 auto;
@include media-breakpoint-up(md) {
margin: $o-sheet-vpadding*0.5 auto;
}
padding: $o-sheet-vpadding;
@include o-form-sheet-inner-right-padding;
@include o-form-sheet-inner-left-padding;
// Selection
> .o_selection {
float: right;
}
}
}
// Fields
.o_td_label .o_form_label:not(.o_status), .o_checkbox_optional_field > .o_form_label {
min-height: 23px;
}
td:not(.o_field_cell) .o_form_uri > span:first-child {
display: inline-block;
padding: 1px 0;
margin-bottom: 1px;
}
// Title & avatar
.oe_title {
color: $headings-color;
@include media-breakpoint-up(vsm, $o-extra-grid-breakpoints) {
padding-right: $o-innergroup-rpadding;
}
h1 {
min-height: 55px;
}
h2 {
min-height: 42px;
}
}
.oe_avatar + .oe_title {
padding-right: $o-avatar-size + 10;
}
// Groups
.o_group {
// all groups take width 100% in mobile
@mixin o-generate-groups($n) {
@for $i from 1 through $n {
.o_group_col_#{$i} {
@include media-breakpoint-down(lg) {
width: 100%;
}
}
}
}
@include o-generate-groups($o-form-group-cols);
&.o_inner_group {
> tbody > tr > td {
padding: 0 $o-innergroup-rpadding 0 0;
}
}
@include media-breakpoint-up(vsm, $o-extra-grid-breakpoints) {
.o_field_widget {
&.o_text_overflow {
width: 1px!important; // hack to make the table layout believe it is a small element (so that the table does not grow too much) ...
min-width: 100%; // ... but in fact it takes the whole table space
}
}
.o_form_label {
margin-bottom: $o-form-spacing-unit;
}
}
@include media-breakpoint-down(lg) {
&.o_label_nowrap .o_form_label {
white-space: normal;
}
}
}
// Labels
.o_form_label {
&.o_form_label_empty, &.o_form_label_false, &.o_form_label_readonly {
opacity: 0.5;
font-weight: $font-weight-normal;
}
@include media-breakpoint-down(md) {
font-size: $o-font-size-base-touch;
}
}
// Notebooks
.o_notebook {
> .o_notebook_headers {
@include o-form-sheet-negative-margin;
> .nav.nav-tabs {
@include o-form-sheet-inner-left-padding;
> .nav-item {
white-space: nowrap;
}
}
}
> .tab-content {
border-bottom: 1px solid map-get($grays, '400');
> .tab-pane {
> :first-child {
// Reset margin to 0 and use tab-pane's padding
// to define the distance between panel and content
margin-top: 0;
// These elements will appear attached to the tabs
&.o_field_html {
border: none;
.note-editor.panel {
border: none;
}
.note-toolbar.panel-heading {
@include o-form-sheet-inner-left-padding;
@include o-form-sheet-inner-right-padding;
border-top: none;
padding-top: 0;
background: white;
}
.note-editing-area, .o_readonly {
padding: $card-spacer-y $card-spacer-x;
@include o-form-sheet-inner-left-padding;
@include o-form-sheet-inner-right-padding;
}
.note-editable.panel-body {
padding: 0;
}
// If immediatly followed by an .clearfix element, the note-editor it's the 'only'
// tab's element. Reset margins to push the bar at the bottom.
+ .clearfix:last-child {
margin-bottom: -$o-horizontal-padding - $o-sheet-vpadding - $o-form-spacing-unit;
}
}
// Full width on first x2many or on second x2many if first is invisible
&.o_field_x2many.o_field_x2many_list,
&.o_field_widget.o_invisible_modifier + .o_field_x2many.o_field_x2many_list {
display: block;
width: auto;
@include o-form-sheet-negative-margin;
margin-top: -$o-horizontal-padding;
// use original padding-left for handle cell in editable list
tr > :first-child.o_handle_cell {
padding-left: 0.3rem;
}
tr > :first-child {
@include o-form-sheet-inner-left-padding;
}
tr > :last-child {
@include o-form-sheet-inner-right-padding;
}
}
}
}
}
&:last-child > .tab-content {
border-bottom: none;
}
}
// Notebooks inside form without sheet tag
&.o_form_nosheet .o_notebook {
> .o_notebook_headers {
@include o-form-nosheet-negative-margin;
> .nav.nav-tabs {
@include o-form-nosheet-inner-left-padding;
}
}
> .tab-content > .tab-pane > :first-child {
// These elements will appear attached to the tabs
&.o_field_x2many.o_field_x2many_list {
@include o-form-nosheet-negative-margin;
}
}
}
// Notebooks for small screen
@include media-breakpoint-down(md) {
.o_notebook .o_notebook_headers {
overflow-x: auto;
&::-webkit-scrollbar {
display: none;
}
.nav.nav-tabs {
flex-flow: row nowrap;
}
}
.o_cp_buttons {
width: 100%;
div, .o-kanban-button-new {
width: 100%;
}
}
}
// One2Many List views
.o_field_widget .o_list_table {
&.table-striped {
> tbody {
> tr:not(.o_data_row) > td {
border-top: none;
}
}
// Show "border" if tfoot has content only
> tfoot > tr {
box-shadow: inset 0 1px 0 map-get($grays, '300');
> td {
border:none;
&:empty {
padding: 0;
}
}
}
}
}
// Translate icon
.o_field_translate {
padding-right: 0;
}
// Specific style classes
.o_group.o_inner_group.oe_subtotal_footer {
border-top: 1px solid map-get($grays, '300');
.oe_subtotal_footer_separator {
border-top: 1px solid map-get($grays, '300');
}
}
// Status Bar
.o_form_statusbar {
> .o_statusbar_status > .o_arrow_button {
font-weight: $font-weight-bold;
// Last element at the right should respect overall padding
&:first-of-type {
padding-right: $o-horizontal-padding;
}
}
// Match the status bar button styles with rest of the buttons in touch device
@include media-breakpoint-down(md) {
> .o_statusbar_buttons > .btn {
margin: 0;
padding: $o-touch-btn-padding;
}
}
}
&.o_xxl_form_view {
flex-flow: row nowrap;
height: 100%;
> .o_form_sheet_bg {
flex: 1 1 auto; // Side chatter is disabled if this has to shrink but this was added for safety
width: $o-form-sheet-min-width + 2 * $o-horizontal-padding;
// max-width: map-get($container-max-widths, xl) + 2 * $o-horizontal-padding; // would be logical but breaks no-chatter form views
padding: 0 $o-horizontal-padding;
overflow: auto;
border-bottom: none;
> .o_form_statusbar, > .alert {
margin-left: -$o-horizontal-padding;
margin-right: -$o-horizontal-padding;
}
> .o_form_sheet {
width: 100%;
max-width: map-get($container-max-widths, xl);
}
}
}
}
// Overriden style when form view in modal
.modal .modal-dialog {
.o_legacy_form_view {
@include media-breakpoint-down(lg) {
.o_group.o_inner_group > tbody > tr > td.o_td_label {
min-width: 100px;
}
}
}
&.modal-sm .o_legacy_form_view {
.o_group {
@include form-break-table;
}
}
}
// XXS form view specific rules
.o_legacy_form_view.o_xxs_form_view {
.o_group {
// Target XXS form view on mobile devices in portrait mode
@include media-breakpoint-down(md) {
&.o_inner_group:not(.oe_subtotal_footer) > tbody > tr > td {
> .o_field_widget {
margin-bottom: $o-form-spacing-unit * 4;
}
}
}
.o_td_label .o_form_label {
font-weight: $font-weight-normal;
}
}
.o_form_label {
margin-top: 3px;
font-size: $o-label-font-size-factor * $o-font-size-base-touch;
font-weight: $font-weight-normal;
color: $o-brand-secondary;
}
}

View File

@@ -0,0 +1,87 @@
@include media-breakpoint-down(md) {
.o_legacy_form_view {
.o_form_sheet {
padding: 16px;
}
.o_form_sheet_bg > .o_form_sheet {
border: 0;
margin: 0 auto;
}
.o_form_label:not(.o_invisible_modifier) {
padding-bottom: 4px;
}
.o_group {
margin-top: 0;
.o_inner_group {
margin-bottom: 0 !important;
div[name="carrier_selection"] {
> div:not(.alert) {
display: inline-flex;
align-items: baseline;
justify-content: space-evenly;
&:first-child {
width: 100%;
.o_field_widget {
width: 100% !important;
}
.text-success {
margin: 2px 10px;
}
}
}
}
}
}
.o_notebook {
> .tab-content {
> .tab-pane.active {
.oe_subtotal_footer {
width: 100% !important;
tr {
> td {
width: 35% !important;
&:first-child {
width: 65% !important;
}
}
}
}
}
}
}
.btn-add-record {
margin-left: 0 !important;
margin-bottom: 10px !important;
}
button.btn.o_external_button.fa {
padding-right: 0 !important;
}
.oe_inline.o_field_widget.o_field_many2one {
width: 100% !important;
}
}
// change the dropdown ui for mobile website
.ui-autocomplete.ui-front.ui-menu.ui-widget.ui-widget-content {
width: 100% !important;
height: 80%;
max-height: fit-content;
left: 0 !important;
overflow-y: auto;
}
}

View File

@@ -0,0 +1,11 @@
.o_legacy_kanban_view {
.o_column_quick_create .o_kanban_quick_create {
input {
&, &:focus, &:hover {
background: transparent;
border-bottom: 1px solid map-get($grays, '600');
}
}
}
}

View File

@@ -0,0 +1,209 @@
// ------- Provide room for the caret -------
@mixin o-list-view-sortable-caret-padding($base-x: $table-cell-padding-x, $ratio: 1) {
> thead > tr > th.o_column_sortable:not(:empty) {
padding-right: ceil((($base-x * $ratio) / 1rem) * $o-root-font-size) + 5px; // FIXME
// Extra room when needed
&:last-child {
padding-right: ceil((($base-x * $ratio) / 1rem) * $o-root-font-size) + 5px + $o-horizontal-padding!important; // FIXME
}
}
}
// ------- Define paddings independently for each table component -------
@mixin o-list-view-full-width-padding($base-x: $table-cell-padding-x, $base-y: $table-cell-padding-y, $ratio: 1) {
$body-padding: floor((($base-y * $ratio * 0.7) / 1rem) * $o-root-font-size); // FIXME
> thead > tr > :not(:empty),
.o_optional_columns_dropdown_toggle {
padding: ceil((($base-y * $ratio) / 1rem) * $o-root-font-size + 4px) 4px; // FIXME
}
> tbody > tr:not(.o_list_table_grouped) > td {
padding: $body-padding 4px;
}
> tfoot > tr > :not(:empty) {
padding: ceil((($base-y * $ratio) / 1rem) * $o-root-font-size + 2px) 4px; // FIXME
}
@include o-list-view-sortable-caret-padding($base-x, $ratio);
// ------- Make full-width tables to fit odoo layout -------
> thead, > tbody, > tfoot {
> tr > * {
&:first-child {
padding-left: $o-horizontal-padding!important;
}
&:last-child {
padding-right: $o-horizontal-padding!important;
}
&.o_list_record_selector {
padding-right: 5px!important;
}
}
}
}
// Common style for list views (also inside form views)
.o_legacy_list_view .o_list_table {
// We need this to be collapse because we want to add a border on the rows
// for sale order/invoice lines of type section.
border-collapse: collapse;
.o_column_sortable:not(:empty)::after {
position: absolute;
}
@include o-list-view-sortable-caret-padding;
// ------- Force empty cells' padding -------
> thead, > tbody, > tfoot {
> tr > :empty {
padding: 0;
&::after {
// ... end hide caret icon
display: none;
}
}
}
// ------- Increase thead and tfoot vertical padding -------
> thead, > tfoot {
> tr > *,
+ .o_optional_columns_dropdown_toggle {
// List views always have the table-sm class, maybe we should
// remove it (and consider it does not exist) and change the default
// table paddings
padding-top: $table-cell-padding-y-sm * 1.25;
padding-bottom: $table-cell-padding-y-sm * 1.25;
}
}
// ------- Style thead -------
> thead {
background-color: white;
border: none;
th {
font-weight: $font-weight-bold;
}
> tr:first-child > th {
border: none;
}
}
> tfoot {
border-bottom: none;
border-top: none;
}
// ------- Decrease table's inner content "visual depth" -------
tbody:first-of-type > tr:first-child:not(.o_group_header) {
box-shadow: inset 0px 5px 10px -4px rgba(black, 0.1);
}
// ------- Force progress bars to respect table's layout -------
.o_progressbar_cell {
.o_progressbar {
display: table-row;
> div {
width: 100%;
display: table-cell;
}
.o_progressbar_value {
width: 45px;
min-width: 45px;
}
}
}
// ------- Grouped list views -------
&.o_list_table_grouped {
> tbody > tr.o_group_header {
background-color: rgba(map-get($grays, '200'), 0.9);
&.o_group_has_content {
@include o-hover-text-color($gray-600, map-get($theme-colors, 'primary'));
&.o_group_open {
font-weight: bold;
@include o-hover-text-color($headings-color, map-get($theme-colors, 'primary'));
}
}
&, &.o_group_has_content {
&:focus-within {
color: $o-brand-primary;
}
}
}
// Disable striped design for grouped content
&.table-striped > tbody + tbody > tr:not(.o_group_header):nth-of-type(odd) {
background-color: $table-bg;
}
}
.o_data_row:not(.o_selected_row) > .o_data_cell:not(.o_readonly_modifier):not(:last-child) {
border-right: 1px solid transparent;
}
.o_data_row.o_selected_row > .o_data_cell {
border-bottom: 1px solid $table-border-color;
&.o_required_modifier:not(.o_readonly_modifier) {
border-bottom: 1px solid black;
&.o_invalid_cell {
border-bottom: 1px solid red;
}
}
&:not(.o_readonly_modifier):not(:last-child) {
border-right: 1px solid $table-border-color;
}
}
.o_data_row.o_selected_row > .o_list_record_remove {
border-bottom: 1px solid $table-border-color;
}
}
// Standalone list views
.o_content > .o_legacy_list_view > .table-responsive > .table {
// List views always have the table-sm class, maybe we should remove
// it (and consider it does not exist) and change the default table paddings
@include o-list-view-full-width-padding($base-x: $table-cell-padding-x-sm, $base-y: $table-cell-padding-y-sm, $ratio: 2);
&:not(.o_list_table_grouped) {
@include media-breakpoint-up(xl) {
@include o-list-view-full-width-padding($base-x: $table-cell-padding-x-sm, $base-y: $table-cell-padding-y-sm, $ratio: 2.5);
}
}
}
// Restore on mobile the scroll on list view
@include media-breakpoint-down(md) {
.o_view_controller > .o_content > .o_legacy_list_view > .table-responsive {
overflow: auto;
.o_list_table tr:focus-within {
background-color: inherit;
}
}
.o_data_row_selected {
user-select: none; // Prevent text selection when editing
> td {
border-top-color: $primary;
border-bottom: 1px solid $primary;
background-color: rgba($primary, .1);
}
}
.o_web_client.o_touch_device .o_content table.o_list_table.table tr > {
.o_list_record_selector:first-child {
display: none;
// first-child will be hidden so add left padding to second child
& + * {
padding-left: $o-horizontal-padding!important;
}
}
}
}

View File

@@ -0,0 +1,61 @@
@include media-breakpoint-down(md) {
.modal {
.modal-content {
.modal-body.o_act_window .o_group.o_inner_group > tbody > tr > td:not(.o_td_label) {
width: 100% !important;
> * {
width: 100%;
}
}
.modal-footer {
.btn {
width: 45%;
text-overflow: ellipsis;
white-space: inherit;
}
}
}
&.o_modal_full {
.modal-content {
.modal-header {
align-items: center;
height: $o-navbar-height;
padding: 0 1rem;
.btn {
color: white;
background-color: transparent;
border-color: transparent;
&, .btn-sm {
padding: 14px; // according to the padding for touch device
}
&.fa {
padding: 10px; // according to the padding for touch device
}
}
}
.modal-body {
.o_form_view .o_form_sheet_bg {
&, & > .o_form_sheet {
border-bottom: 0;
border-top: 0;
}
}
}
.modal-footer {
@include o-webclient-padding($top: 1rem, $bottom: 0.5rem);
box-shadow: 0 1rem 2rem black;
z-index: 0;
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
.dropdown-item-studio {
@extend .dropdown-item;
text-align: left;
}
.dropdown-menu > hr {
margin-top: 6px;
margin-bottom: 6px;
}
.modal-studio {
.modal-title {
text-align: center;
color: #666666;
padding-bottom: .5rem;
}
.o_install_studio_btn {
display: flex;
align-items: center;
justify-content: center;
margin: 1rem 0 0 0;
button {
line-height: 1.5;
font-size: 1.5rem;
}
}
}

View File

@@ -0,0 +1,6 @@
// Disable the selection of element
// This allow to avoid the show "mobile menu" on long press to text on Mobile
// and so avoid JS preventDefault
.o_user_select_none {
user-select: none;
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="FieldMany2OneBarcode" t-extend="FieldMany2One">
<t t-jquery="button.o_external_button " t-operation="after">
<button
type="button"
class="btn o_barcode"
tabindex="-1"
draggable="false"
aria-label="Scan barcode"
title="Scan barcode"
/>
</t>
</t>
</templates>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="StatusbarButtonsDropdown">
<div class="o_statusbar_buttons">
<div t-attf-class="btn-group #{open ? 'show' : ''}">
<a role="button" class="btn btn-primary dropdown-toggle" href="#"
data-bs-toggle="dropdown" aria-expanded="false">Action</a>
<div t-attf-class="dropdown-menu #{open ? 'show' : ''}" role="menu"/>
</div>
</div>
</t>
<t t-name="FieldStatus.content.mobile">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown">
<t t-if="status" t-esc="status.display_name"/>
</button>
<div class="dropdown-menu" role="menu">
<t t-foreach="selection" t-as="i" t-key="i_index">
<t t-call="FieldStatus.content.button">
<t t-set="is_dropdown" t-value="true"/>
</t>
</t>
</div>
</t>
<t t-extend="ListView.selection">
<t t-jquery=".o_list_selection_box" t-operation="prepend">
<button class="btn btn-link py-0 o_discard_selection" t-if="isMobile">
<span class="fa-2x">&#215;</span>
</button>
</t>
</t>
<t t-inherit="web.Legacy.ActionMenus" t-inherit-mode="extension" owl="1" t-translation="off">
<xpath expr="//DropdownMenu[@items='printItems']" position="attributes">
<attribute name="closeOnSelected">env.device.isMobile</attribute>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_enterprise._ControlPanel" owl="1">
<div class="o_control_panel" t-on-switch-view="_onSwitchView" t-ref="controlPanel">
<!-- Mobile search view open -->
<t t-portal="'body'" t-if="state.showMobileSearch">
<div class="o_searchview o_mobile_search">
<div class="o_mobile_search_header">
<button type="button" class="o_mobile_search_button btn"
t-on-click="() => state.showMobileSearch = false"
>
<i class="fa fa-arrow-left"/>
<strong class="ml8">FILTER</strong>
</button>
<button type="button" class="o_mobile_search_button btn"
t-on-click="() => model.dispatch('clearQuery')"
>
<t>CLEAR</t>
</button>
</div>
<div class="o_mobile_search_content">
<SearchBar t-if="props.withSearchBar" fields="fields"/>
<!-- Additional content: searchview buttons(legacy) -->
<div class="o_mobile_search_filter o_search_options" t-ref="searchViewButtons">
<FilterMenu t-if="props.searchMenuTypes.includes('filter')"
fields="fields"
/>
<GroupByMenu t-if="props.searchMenuTypes.includes('groupBy')"
fields="fields"
/>
<ComparisonMenu t-if="props.searchMenuTypes.includes('comparison') and model.get('filters', f => f.type === 'comparison').length"/>
<FavoriteMenu t-if="props.searchMenuTypes.includes('favorite')"/>
</div>
</div>
<button type="button" class="btn btn-primary o_mobile_search_footer"
t-on-click="() => state.showMobileSearch = false"
>
<t>SEE RESULT</t>
</button>
</div>
</t>
<!-- Standard control panel display -->
<t t-else="">
<div class="o_cp_top">
<ol t-if="props.withBreadcrumbs and !state.showSearchBar" class="breadcrumb" role="navigation">
<t t-set="breadcrumb" t-value="props.breadcrumbs[props.breadcrumbs.length - 1]"/>
<li t-if="breadcrumb" class="breadcrumb-item o_back_button btn btn-secondary" accesskey="b"
t-on-click.prevent="() => this.trigger('breadcrumb_clicked', { controllerID: breadcrumb.controllerID })"
/>
<li class="breadcrumb-item active" t-esc="props.title"/>
</ol>
<!-- Additional content: searchview (legacy) -->
<div class="o_cp_searchview" t-att-class="{ o_searchview_quick: state.showSearchBar or !props.withBreadcrumbs }"
role="search" t-ref="searchView"
>
<div t-if="props.withSearchBar" class="o_searchview" role="search" aria-autocomplete="list">
<t t-if="!state.showMobileSearch">
<button type="button" class="o_enable_searchview btn btn-link"
t-if="props.withBreadcrumbs"
t-att-class="state.showSearchBar ? 'fa fa-arrow-left' : 'oi oi-search'"
t-on-click="() => state.showSearchBar = !state.showSearchBar"
/>
<t t-if="state.showSearchBar or !props.withBreadcrumbs">
<SearchBar t-if="props.withSearchBar" fields="fields"/>
<button type="button" class="o_toggle_searchview_full btn fa fa-filter"
t-on-click="() => state.showMobileSearch = !state.showMobileSearch"
/>
</t>
</t>
</div>
</div>
</div>
<div t-if="props.actionMenus and props.actionMenus.items and props.view and props.view.type == 'list'"
class="o_cp_bottom o_renderer_selection_banner align-items-center ml-n2">
<ActionMenus t-props="props.actionMenus" />
</div>
<div t-else="" class="o_cp_bottom">
<div class="o_cp_bottom_left">
<!-- Additional content: buttons (legacy) -->
<div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar" t-ref="buttons">
<t t-slot="buttons"/>
</div>
</div>
<div class="o_cp_bottom_right">
<!-- Show "searchViewButtons" when custom CP have search view buttons but no SearchBar -->
<div class="btn-group o_search_options position-static" role="search" t-if="!props.withSearchBar" t-ref="searchViewButtons"/>
<button t-if="props.view and props.view.type === 'calendar'"
class="o_cp_today_button btn btn-sm btn-link"
t-on-click="() => this.trigger('today-button-click')"
>
<span class="fa-stack o_calendar_button_today">
<i class="fa fa-calendar-o fa-stack-2x" role="img" aria-label="Today" title="Today"/>
<strong class="o_calendar_text fa-stack-1x" t-esc="_getToday()"/>
</span>
</button>
<!-- Additional content: pager (legacy) -->
<div t-else="" class="o_cp_pager" role="search" t-ref="pager">
<Pager t-if="props.pager and props.pager.limit" t-props="props.pager"/>
</div>
<nav t-if="props.views and props.views.length gt 1" class="btn-group o_cp_switch_buttons"
t-att-class="{ show: state.showViewSwitcher }" role="toolbar" aria-label="View switcher"
>
<button type="button" class="btn btn-link"
t-attf-aria-expanded="{{ state.showViewSwitcher ? 'true' : 'false' }}"
t-on-click="() => state.showViewSwitcher = !state.showViewSwitcher"
>
<span class="fa-lg" t-att-class="props.views.find(v => v.type === props.view.type).icon"/>
</button>
<ul t-if="state.showViewSwitcher" class="dropdown-menu dropdown-menu-end list-inline"
t-att-class="{ show: state.showViewSwitcher }"
>
<li t-foreach="props.views" t-as="view" t-key="view.type">
<t t-call="web.ViewSwitcherButton"/>
</li>
</ul>
</nav>
<ActionMenus t-if="props.actionMenus and props.actionMenus.items" t-props="props.actionMenus"/>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" ?>
<template>
<button t-name="web_enterprise.open_studio_button" type="button" class ="dropdown-item-studio">
<i class="fa fa-plus"></i>
<span>Add Custom Field</span>
</button>
<div t-name="web_enterprise.install_web_studio" class="modal-studio">
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" tabindex="-1"></button>
<h2 class="modal-title">Add new fields and much more with <b>Odoo Studio</b></h2>
<div class="o_video_embed">
<div class="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>
<div class="o_install_studio_btn">
<button class="btn btn-primary btn-block o_install_studio"><b>Install Odoo Studio</b></button>
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_enterprise.Legacy.SearchPanel.Mobile" owl="1">
<!-- Mobile search -->
<t t-portal="'body'" t-if="state.showMobileSearch">
<div class="o_search_panel o_searchview o_mobile_search">
<div class="o_mobile_search_header">
<button type="button" class="o_mobile_search_button btn"
t-on-click="() => this.state.showMobileSearch = false"
>
<i class="fa fa-arrow-left"/>
<strong class="ml8">FILTER</strong>
</button>
</div>
<div class="o_mobile_search_content">
<t t-call="web.Legacy.SearchPanel"/>
</div>
<button type="button" class="btn btn-primary o_mobile_search_footer"
t-on-click.stop="() => this.state.showMobileSearch = false"
>
<t>SEE RESULT</t>
</button>
</div>
</t>
<!-- Summary header -->
<button
t-else=""
class="o_search_panel o_search_panel_summary btn w-100 overflow-visible"
t-on-click="() => this.state.showMobileSearch = true"
>
<t t-set="categories" t-value="_getCategorySelection()"/>
<t t-set="filters" t-value="_getFilterSelection()"/>
<div class="d-flex align-items-center">
<i class="fa fa-fw fa-filter"/>
<div class="o_search_panel_current_selection text-truncate ms-2 me-auto">
<t t-if="!categories.length and !filters.length">All</t>
<t t-else="">
<span t-foreach="categories" t-as="category" t-key="category.id"
class="o_search_panel_category me-1"
>
<i t-if="category.icon"
t-attf-class="o_search_panel_section_icon fa {{ category.icon }} me-1"
t-att-style="category.color and ('color: ' + category.color)"
/>
<t t-esc="category.values.join('/')"/>
</span>
<span t-foreach="filters" t-as="filter" t-key="filter.id"
class="o_search_panel_filter me-1"
>
<i t-if="filter.icon"
t-attf-class="o_search_panel_section_icon fa {{ filter.icon }} me-1"
t-att-style="filter.color and ('color: ' + filter.color)"
/>
<t t-esc="filter.values.join(', ')"/>
</span>
</t>
</div>
</div>
</button>
</t>
</templates>

View File

@@ -0,0 +1,38 @@
<template>
<t t-extend="CalendarView.buttons">
<t t-jquery=".o_calendar_navigation_buttons" t-operation="replace">
<t t-if="!isMobile">
<span class="o_calendar_navigation_buttons">
<t t-call="CalendarView.navigation_buttons" />
</span>
</t>
</t>
</t>
<div t-name="CalendarView.OtherCalendarMobile" class="o_other_calendar_panel d-flex align-items-center">
<i class="fa fa-fw fa-filter me-3" />
<div class="o_filter me-auto d-flex overflow-auto">
<t t-foreach="filters" t-as="filter" t-if="filter.values.length > 0">
<span class="text-nowrap fw-bold text-uppercase me-1" t-esc="filter.label" />
<span t-foreach="filter.values" t-as="values" class="d-flex align-items-center text-nowrap ms-1 me-2">
<span t-attf-class="#{typeof values.color === 'number' ? _.str.sprintf('o_color_%s', values.color) : 'o_color_1'}"></span>
<span class="ms-1 fw-bold text-nowrap" t-esc="values.label" />
</span>
</t>
</div>
<i t-if="isSidePanelVisible" class="fa fa-fw fa-caret-down ms-2"/>
<i t-else="" class="fa fa-fw fa-caret-left ms-2"/>
</div>
<t t-extend="CalendarView">
<t t-jquery=".o_calendar_sidebar_container .o_calendar_sidebar" t-operation="append">
<div class="o_view_nocontent d-none">
<div class="o_nocontent_help">
<p class="o_view_nocontent_neutral_face">No filter available</p>
</div>
</div>
</t>
</t>
</template>

View File

@@ -0,0 +1,12 @@
/** @odoo-module **/
import { startWebClient } from "@web/start";
import { WebClientEnterprise } from "./webclient/webclient";
/**
* This file starts the enterprise webclient. In the manifest, it replaces
* the community main.js to load a different webclient class
* (WebClientEnterprise instead of WebClient)
*/
startWebClient(WebClientEnterprise);

View File

@@ -0,0 +1,160 @@
///
/// This file is a copy of the bootstrap _variables.scss file where all the
/// left-untouched variables definition have been removed.
///
// == Color system
// == Characters which are escaped by the escape-svg function
// == Options
// == Prefix for :root CSS variables
// == Gradient
// == Spacing
// == Position
// == Body
// == Links
$link-shade-percentage: 40% !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
// == 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;
// == Buttons + Forms
// == Buttons
// == Forms
$input-placeholder-color: $o-gray-500 !default;
$form-switch-color: rgba($o-black, .5) !default !default;
// == Form validation
// == Z-index master list
// == Navs
// == Navbar
// == Dropdowns
$dropdown-bg: $o-gray-300 !default;
// == Pagination
// == Placeholders
// == Cards
$card-cap-bg: $o-view-background-color !default;
$card-bg: $card-cap-bg !default;
// == Accordion
// == Tooltips
// == Form tooltips must come after regular tooltips
// == Popovers
$popover-bg: $o-gray-300 !default;
// == Toasts
// == Badges
// == Modals
// == Alerts
$alert-bg-scale: -75% !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,144 @@
///
/// This file is a copy of the bootstrap _variables.scss file where all the
/// left-untouched variables definition have been removed.
///
//
// Color system
//
$secondary: $o-gray-700 !default;
$dark: $o-gray-900 !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.
$btn-border-radius: 0 !default;
$btn-border-radius-sm: 0 !default;
$btn-border-radius-lg: 0 !default;
$input-border-radius: 0 !default;
$input-border-radius-sm: 0 !default;
$input-border-radius-lg: 0 !default;
$nav-tabs-border-radius: 0 !default;
$nav-pills-border-radius: 0 !default;
$dropdown-border-radius: 0 !default;
$pagination-border-radius: 0 !default;
$pagination-border-radius-sm: 0 !default;
$pagination-border-radius-lg: 0 !default;
$card-border-radius: 0 !default;
$accordion-border-radius: 0 !default;
$tooltip-border-radius: 0 !default;
$popover-border-radius: 0 !default;
$toast-border-radius: 0 !default;
$badge-border-radius: 0 !default;
$modal-content-border-radius: 0 !default;
$alert-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;
// Tables
//
// Customizes the `.table` component with basic values, each used across all table variations.
$table-striped-bg-factor: 0.01 !default;
$table-striped-order: odd !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;
// Dropdowns
//
// Dropdown menu container and contents.
$dropdown-divider-bg: $o-gray-200 !default;
// Forms
//
$input-bg: transparent !default;
$input-box-shadow: 0 !default;
$form-switch-checked-color: $o-white !default;
// Will affect bootstrap default forms only (eg. popover textarea)
$input-focus-box-shadow: 0 !default;
$input-focus-border-color: mix($o-brand-primary, $o-gray-300) !default;
$form-select-focus-box-shadow: 0 !default;
$form-range-track-box-shadow: 0 !default;
$custom-checkbox-indicator-border-radius: 1px !default; // Just a slight smoothing.
// 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;
// Alerts
//
// Define alert colors, border radius, and padding.
$alert-border-width: 0 !default;
// Progress bars
$progress-box-shadow: 0 !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,84 @@
///
/// Odoo Dark-Mode
///
///
// = Colors
// ============================================================================
$o-white: #000 !default;
$o-black: #FFF !default;
// Dark mode
$o-gray-100: #191c24 !default;
$o-gray-200: #242733 !default;
$o-gray-300: #3f4149 !default;
$o-gray-400: #5f6167 !default;
$o-gray-500: #797a80 !default;
$o-gray-600: #94959a !default;
$o-gray-700: #b0b0b4 !default;
$o-gray-800: #cccccf !default;
$o-gray-900: #e9e9eb !default;
$o-enterprise-color: #BB86FC !default;
$o-brand-primary: #00DAC5 !default;
$o-success: #28a745 !default;
$o-info: #74dcf3 !default;
$o-warning: #ff7b00 !default;
$o-danger: #ff0020 !default;
// = Text
// ============================================================================
$o-main-text-color: $o-gray-800 !default;
$o-main-link-color: $o-brand-primary !default;
$o-enterprise-color: $o-brand-odoo !default;
$o-main-favorite-color: #f3cc00 !default;
$o-main-code-color: #ff7dd9 !default;
// = Fine-tune contextual text colors.
$o-theme-text-colors: (
"success": #03E02A,
"info": #00C4FD,
"warning": #FFB100,
"danger": #FF4842,
"odoo": #c392ff,
) !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;
// = Button Secondary
$o-btn-secondary-bg: $o-view-background-color !default;
$o-btn-secondary-hover-bg: $o-gray-100 !default;
$o-btn-secondary-hover-border: $o-btn-secondary-hover-bg !default;
// = Button Light
$o-btn-light-bg: $o-gray-200 !default;
$o-btn-light-background-hover: $o-gray-300 !default;
// = Components
// ============================================================================
// = 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;
// = Navbar
$o-navbar-bg: $o-gray-200 !default;
$-backend-entry-hover-bg: $o-gray-100 !default; // <- TO DO: use a general variable rather than a scoped one
// = Modal
$modal-content-bg: $o-view-background-color !default;
$modal-backdrop-bg: $o-white !default;

View File

@@ -0,0 +1,43 @@
///
/// 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: #f6f7fa !default;
$o-gray-200: #E0E2E6 !default;
$o-gray-300: #C9CCD2 !default;
$o-gray-400: #9DA1AA !default;
$o-gray-500: #80848F !default;
$o-gray-600: #626774 !default;
$o-gray-700: #4A4F59 !default;
$o-gray-800: #353840 !default;
$o-gray-900: #212529 !default;
$o-enterprise-color: #714B67 !default;
$o-enterprise-primary-color: #017e84 !default;
$o-enterprise-primary-color-light: scale-color($o-enterprise-primary-color, $lightness: 6%) !default;
$o-brand-odoo: $o-enterprise-color !default;
$o-brand-primary: $o-enterprise-primary-color !default;
$o-brand-secondary: #8f8f8f !default;
$o-brand-lightsecondary: $o-gray-100 !default;
$o-list-group-header-color: $o-gray-900 !default;
$o-list-footer-color: $o-gray-900 !default;
$o-list-footer-bg-color: $o-white !default;
$o-list-footer-font-weight: 500 !default;
$o-dropdown-box-shadow: 0 12px 14px -10px rgba(black, 0.25) !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;

View File

@@ -0,0 +1,26 @@
$o-colors-original: lighten(#000, 46.7%), #F06050, #F4A460, #F7CD1F, #6CC1ED, #814968,
#EB7E7F, #2C8397, #475577, #D6145F, #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;
$o-colors-complete-first: saturate(mix(#134E5E , $o-black, 70%), 80%) !default;
@each $-color in $o-colors-secondary-original {
$-adjusted: saturate(mix($-color, $o-black, 70%), 80%);
$o-colors: append($o-colors, $-adjusted);
}
@each $-color in $o-colors-secondary-original {
$-adjusted: saturate(mix($-color, $o-black, 70%), 80%);
$o-colors-secondary: append($o-colors-secondary, $-adjusted);
}
// = ControlPanel
// TODO: move in 'control_panel.variables.scss' when it will exist
// in bright-mode
// ============================================================================
$o-control-panel-background-color: $o-gray-200 !default;

View File

@@ -0,0 +1,11 @@
$o-form-lightsecondary: $o-gray-200 !default;
$o-tooltip-background-color: $o-view-background-color !default;
$o-tooltip-color: $o-main-text-color !default;
$o-tooltip-arrow-color: $o-main-text-color !default;
// Needed for having no spacing between sheet and mail body in mass_mailing:
// Different required cancel paddings between web and web_enterprise
$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,8 @@
// = Mobile Search
// ============================================================================
// No CSS hacks, variables overrides only
.o_mobile_search {
--mobileSearch-bg: #{$o-gray-200};
--mobileSearch__header-bg: #{$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,15 @@
// = Gantt View
// ============================================================================
// No CSS hacks, variables overrides only
.o_web_client {
// Since the component's design is defined in assets_common, scope variables
// declarations within `o_web_client` selector.
.o_gantt_view {
--Gant__DayOff-background-color: rgba(255, 255, 255, .05);
// Mix between $gantt-highlight-today-bg and $o-view-background-color
// to simulate the superposition of these two colors
--Gant__DayOffToday-background-color: #553F3A;
}
}

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

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,9 @@
.o_list_renderer {
--ListRenderer-thead-bg-color: #{$o-view-background-color};
--ListRenderer-thead-border-end-color: transparent;
// Standalone list views
.o_list_view & {
--ListRenderer-thead-padding-v: 1rem;
}
}

View File

@@ -0,0 +1,38 @@
/** @odoo-module */
import { isMobileOS } from "@web/core/browser/feature_detection";
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 "@web_enterprise/webclient/promote_studio_dialog/promote_studio_dialog";
export const patchListRendererDesktop = {
setup() {
this._super(...arguments);
this.userService = useService("user");
const { actionId, actionType } = this.env.config;
const list = this.props.list;
this.isStudioEditable =
!isMobileOS() &&
this.userService.isSystem &&
actionId &&
actionType === "ir.actions.act_window" &&
list === list.model.root;
},
get displayOptionalFields() {
return this.isStudioEditable || this.getOptionalFields.length;
},
/**
* This function opens promote studio dialog
*
* @private
*/
onSelectedAddCustomField() {
this.env.services.dialog.add(PromoteStudioDialog, {});
},
};
patch(ListRenderer.prototype, "web_enterprise.ListRendererDesktop", 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" owl="1">
<xpath expr="//Dropdown" position="inside">
<t t-if="isStudioEditable">
<div t-if="getOptionalFields.length" class="dropdown-divider"/>
<DropdownItem parentClosingMode="'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,29 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { PivotRenderer } from "@web/views/pivot/pivot_renderer";
const { useEffect, useRef } = owl;
patch(PivotRenderer.prototype, "web_enterprise.PivotRendererMobile", {
setup() {
this._super();
this.root = useRef("root");
if (this.env.isSmall) {
useEffect(() => {
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 this._super(...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,20 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { getCookie, setCookie } from "web.utils.cookies";
export function switchColorSchemeItem(env) {
return {
type: "switch",
id: "color_scheme.switch_theme",
description: env._t("Dark Mode"),
callback: () => {
const cookie = getCookie("color_scheme");
const theme = cookie === "dark" ? "light" : "dark";
setCookie("color_scheme", theme);
browser.location.reload();
},
isChecked: getCookie("color_scheme") == "dark" ? true : false,
sequence: 30,
};
}

View File

@@ -0,0 +1,15 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { switchColorSchemeItem } from "./color_scheme_menu_items";
const serviceRegistry = registry.category("services");
const userMenuRegistry = registry.category("user_menuitems");
const colorThemeService = {
start() {
userMenuRegistry.add("color_scheme.switch", switchColorSchemeItem);
},
};
serviceRegistry.add("color_scheme", colorThemeService);

View File

@@ -0,0 +1,27 @@
// = DataRange Picker
// ============================================================================
// No CSS hacks, variables overrides only
.o_web_client {
// Since the component's design is defined in assets_common, scope variables
// declarations within `o_web_client` selector.
.daterangepicker {
--daterangepicker-border: #{$dropdown-border-width} solid #{$dropdown-border-color};
--daterangepicker-border-radius: #{$dropdown-border-radius};
--daterangepicker-bg: #{$table-bg};
--daterangepicker-color: #{$o-gray-900};
--daterangepicker-box-shadow: #{$dropdown-box-shadow};
--daterangepicker__table-bg: #{$table-bg};
--daterangepicker__thead-bg: #{$o-gray-100};
--daterangepicker__cell-border-color: #{$table-border-color};
--daterangepicker__cell-bg--hover: #{$o-gray-400};
--daterangepicker__cell-bg--off: #{$o-gray-400};
--daterangepicker__select-bg: #{$form-select-bg};
--daterangepicker__select-border-color: #{$input-border-color};
--daterangepicker__select-color: #{$form-select-color};
}
}

View File

@@ -0,0 +1,28 @@
// = Home Menu
// ============================================================================
// No CSS hacks, variables overrides only
.o_web_client {
// Since the component's design is defined in assets_common, scope variables
// declarations within `o_web_client` selector.
.o_datepicker, .datepicker, .bootstrap-datetimepicker-widget {
--bs-datetimepicker-btn-hover-bg: #{$o-gray-400};
}
.daterangepicker {
--daterangerpicker__arrow-background-color: #{$o-view-background-color};
--daterangerpicker__arrow-border-color: #{$border-color};
}
.bootstrap-datetimepicker-widget {
--bs-datetimepicker-secondary-border-color-rgba: #{$o-gray-300};
--bs-datetimepicker-primary-border-color: #{$o-gray-300};
--datepicker-border-color: #{$border-color};
}
.bootstrap-datetimepicker-widget .datepicker table {
--datepicker-color: #{map-get($grays, '900')};
--datepicker__thead-bg: #{map-get($grays, '100')};
}
}

View File

@@ -0,0 +1,7 @@
// = Domain Selector
// ============================================================================
// No CSS hacks, variables overrides only
.o_domain_debug_container {
--DomainDebugContainer-background-colomap-get: #{$o-gray-100};
}

View File

@@ -0,0 +1,191 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import { browser } from "@web/core/browser/browser";
import { sprintf } from "@web/core/utils/strings";
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";
const { DateTime } = luxon;
const { Component, xml, useState } = owl;
function daysUntil(datetime) {
const duration = datetime.diff(DateTime.utc(), "days");
return Math.round(duration.values.days);
}
export class SubscriptionManager {
constructor(env, { rpc, orm, cookie, notification }) {
this.env = env;
this.rpc = rpc;
this.orm = orm;
this.cookie = cookie;
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
// "notification_type" in session_info, as it is added in mail for internal users.
this.hasInstalledApps = "notification_type" in session;
// "user" or "admin"
this.warningType = session.warning;
this.lastRequestStatus = null;
this.isWarningHidden = this.cookie.current.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.
this.cookie.setCookie("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);
}
/**
* Save the registration code then triggers a ping to submit it.
*/
async submitCode(enterpriseCode) {
const [oldDate, , linkedSubscriptionUrl, linkedEmail] = 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",
enterpriseCode,
]),
// Aren't these a race condition ??? They depend on the upcoming ping...
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"]),
]);
await this.orm.call("publisher_warranty.contract", "update_notification", [[]]);
const expirationDate = await 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) {
const message = _t(
"Thank you, your registration was successful! Your database is valid until %s."
);
this.notification.add(sprintf(message, 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 this.rpc(sendUnlinkInstructionsUrl);
if (result) {
this.mailDeliveryStatus = "success";
} else {
this.mailDeliveryStatus = "fail";
this.mailDeliveryStatusError = reason;
}
}
async renew() {
const enterpriseCode = await this.orm.call("ir.config_parameter", "get_param", [
"database.enterprise_code",
]);
const contractQueryString = enterpriseCode ? `?contract=${enterpriseCode}` : "";
browser.location = `${url}${contractQueryString}`;
}
async upsell() {
const limitDate = serializeDate(DateTime.utc().minus({ days: 15 }));
const [enterpriseCode, 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 contractQueryString = enterpriseCode ? `&contract=${enterpriseCode}` : "";
browser.location = `${url}?num_users=${nbUsers}${contractQueryString}`;
}
}
class ExpiredSubscriptionBlockUI extends Component {
setup() {
this.subscription = useState(useService("enterprise_subscription"));
}
}
ExpiredSubscriptionBlockUI.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>`;
ExpiredSubscriptionBlockUI.components = { ExpirationPanel };
export const enterpriseSubscriptionService = {
name: "enterprise_subscription",
dependencies: ["orm", "rpc", "cookie", "notification"],
start(env, { rpc, orm, cookie, notification }) {
registry
.category("main_components")
.add("expired_subscription_block_ui", { Component: ExpiredSubscriptionBlockUI });
return new SubscriptionManager(env, { rpc, orm, cookie, notification });
},
};
registry.category("services").add("enterprise_subscription", enterpriseSubscriptionService);

View File

@@ -0,0 +1,77 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { Transition } from "@web/core/transition";
import { sprintf } from "@web/core/utils/strings";
const { Component, useState, useRef } = owl;
/**
* 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 {
setup() {
this.subscription = useState(useService("enterprise_subscription"));
this.cookie = useService("cookie");
this.state = useState({
displayRegisterForm: false,
});
this.inputRef = useRef("input");
}
get buttonText() {
return this.subscription.lastRequestStatus === "error" ? "Retry" : "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 { _t } = this.env;
const { daysLeft } = this.subscription;
if (daysLeft <= 0) {
return _t("This database has expired. ");
}
const delay = daysLeft === 30 ? _t("1 month") : sprintf(_t("%s days"), daysLeft);
if (this.subscription.expirationReason === "demo") {
return sprintf(_t("This demo database will expire in %s. "), delay);
}
return sprintf(_t("This database will expire in %s. "), delay);
}
showRegistrationForm() {
this.state.displayRegisterForm = !this.state.displayRegisterForm;
}
async onCodeSubmit() {
const enterpriseCode = this.inputRef.el.value;
if (!enterpriseCode) {
return;
}
await this.subscription.submitCode(enterpriseCode);
if (this.subscription.lastRequestStatus === "success") {
this.state.displayRegisterForm = false;
} else {
this.state.buttonText = "Retry";
}
}
}
ExpirationPanel.template = "DatabaseExpirationPanel";
ExpirationPanel.components = { Transition };

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,8 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="DatabaseExpirationPanel" owl="1">
</t>
</templates>

View File

@@ -0,0 +1,9 @@
// = Home Menu
// ============================================================================
// No CSS hacks, variables overrides only
.o_home_menu_background {
.o_menu_systray .badge {
--o-navbar-badge-bg: #{$o-brand-primary};
}
}

View File

@@ -0,0 +1,310 @@
/** @odoo-module **/
import { isIosApp, isMacOS } from "@web/core/browser/feature_detection";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { useService } from "@web/core/utils/hooks";
import { ExpirationPanel } from "./expiration_panel";
const {
Component,
useExternalListener,
onMounted,
onPatched,
onWillUpdateProps,
useState,
useRef,
} = owl;
class FooterComponent extends Component {
setup() {
this.controlKey = isMacOS() ? "COMMAND" : "CONTROL";
}
}
FooterComponent.template = "web_enterprise.HomeMenu.CommandPalette.Footer";
/**
* Home menu
*
* This component handles the display and navigation between the different
* available applications and menus.
* @extends Component
*/
export class HomeMenu extends Component {
/**
* @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
*/
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");
if (!this.env.isSmall) {
this._registerHotkeys();
}
onWillUpdateProps(() => {
// State is reset on each remount
this.state.focusedIndex = null;
});
onMounted(() => {
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();
}
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @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();
this.inputRef.el.value = "";
};
const searchValue = this.compositionStart ? "/" : `/${this.inputRef.el.value.trim()}`;
this.compositionStart = false;
this.command.openMainPalette({ searchValue, FooterComponent }, onClose);
}
_onInputBlur() {
// 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;
}
}
HomeMenu.components = { ExpirationPanel };
HomeMenu.props = {
apps: {
type: Array,
element: {
type: Object,
shape: {
actionID: Number,
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,
},
},
},
};
HomeMenu.template = "web_enterprise.HomeMenu";

View File

@@ -0,0 +1,114 @@
.o_home_menu_background {
// 'Home menu background' design is shared with enterprise login
// screens and it's located in './home_menu_background.scss'
// When applied on webclient (note: we do not specify the webclient class
// here to avoid breaking studio custom style)
&:not(.o_home_menu_background_custom) .o_main_navbar {
background: transparent;
border-bottom-color: transparent;
.dropdown-menu {
border-color: $dropdown-bg;
}
.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-home-menu-badge-bg);
}
}
.o_home_menu {
font-size: $o-home-menu-font-size-base;
.container {
@include media-breakpoint-up(md) {
max-width: $o-home-menu-container-size !important;
}
}
.o_app {
&,
.o_app_icon {
transition: all 0.3s ease 0s;
}
.o_app_icon {
width: $o-home-menu-app-icon-max-width;
height: $o-home-menu-app-icon-max-width;
background: {
size: cover;
repeat: no-repeat;
position: center;
}
.fa {
font-size: $o-home-menu-app-icon-max-width * 0.5;
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.23);
}
}
.o_caption {
color: $o-home-menu-caption-color;
text-shadow: $o-home-menu-caption-shadow;
}
&:focus,
&.o_focused,
&:hover {
.o_app_icon {
box-shadow: 0 8px 15px -10px black;
transform: translateY(-1px);
}
}
&:focus,
&.o_focused {
background-color: rgba(white, 0.05);
border-radius: $border-radius;
}
}
// 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 {
flex-direction: row !important;
justify-content: initial !important;
width: 100%;
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;
}
}
}
}

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: none !default;

View File

@@ -0,0 +1,6 @@
$o-home-menu-font-size-base: 1.25rem;
$o-home-menu-container-size: 850px;
$o-home-menu-app-icon-max-width: 70px;
$o-home-menu-caption-color: $o-white !default;
$o-home-menu-caption-shadow: 0 1px 1px rgba($o-black, 0.8) !default;

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_enterprise.HomeMenu" owl="1">
<div 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"/>
<!-- 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">
<a t-foreach="displayedApps" t-as="app"
t-att-id="'result_app_' + app_index"
role="option"
t-att-aria-selected="state.focusedIndex === app_index ? 'true' : 'false'"
class="o_app o_menuitem col-4 col-sm-3 col-md-2 p-2 mb-3 d-flex flex-column rounded justify-content-center align-items-center"
t-att-class="{o_focused: state.focusedIndex === app_index}"
t-att-data-menu-xmlid="app.xmlid"
t-att-href="app.href || ('#menu_id='+app.id+'&amp;action_id='+app.actionID)"
t-key="app.id"
t-on-click.prevent="() => this._onAppClick(app)"
>
<img t-if="app.webIconData" class="o_app_icon rounded"
t-attf-src="{{app.webIconData}}"
/>
<div t-else="" class="o_app_icon rounded d-flex p-2 justify-content-center align-items-center"
t-attf-style="background-color: {{app.webIcon.backgroundColor}};"
>
<i t-att-class="app.webIcon.iconClass" 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 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="web_enterprise.HomeMenu.CommandPalette.Footer" owl="1">
<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,13 @@
// = Home Menu Background
// ============================================================================
// No CSS hacks, variables overrides only
.o_home_menu_background {
--homeMenu-bg-color: #000511;
--homeMenu-bg-image:
radial-gradient(at 100% 0%, #{rgba($o-gray-100, 0.6)} 0px, #{rgba($o-gray-100, 0.1)} 50%, #{rgba($o-gray-100, 0)} 100%),
radial-gradient(at 7% 13%, #{rgba($o-gray-200, 0.6)} 0px, #{rgba($o-gray-100, 0.05)} 50%, #{rgba($o-gray-100, 0)} 100%),
radial-gradient(at 96% 94%, #{rgba($o-gray-100, 0.5)} 0px, #{rgba($o-gray-100, 0.08)} 50%, #{rgba($o-gray-100, 0)} 100%),
radial-gradient(at 3% 96%, #{rgba($o-gray-200, 0.6)} 0px, #{rgba($o-gray-100, 0.05)} 50%, #{rgba($o-gray-100, 0)} 100%),
url("/web_enterprise/static/img/home-menu-bg-overlay.svg");
}

View File

@@ -0,0 +1,15 @@
// 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, #917878);
image: var(--homeMenu-bg-image,
radial-gradient(at 100% 0%, hsla(289,17%,21%,0.6) 0px, transparent 50%),
radial-gradient(at 7% 13%, hsla(268,5%,47%,0.42) 0px, transparent 50%),
radial-gradient(at 96% 94%, hsla(267,5%,46%,0.51) 0px, transparent 50%),
radial-gradient(at 3% 96%, hsla(289,17%,21%,0.41) 0px, transparent 50%),
url("/web_enterprise/static/img/home-menu-bg-overlay.svg")
);
}
}

View File

@@ -0,0 +1,88 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Mutex } from "@web/core/utils/concurrency";
import { useService } from "@web/core/utils/hooks";
import { computeAppsAndMenuItems } from "@web/webclient/menus/menu_helpers";
import { ControllerNotFoundError } from "@web/webclient/actions/action_service";
import { HomeMenu } from "./home_menu";
const { Component, onMounted, onWillUnmount, xml } = owl;
export const homeMenuService = {
dependencies: ["action", "router"],
start(env) {
let hasHomeMenu = false; // true iff the HomeMenu is currently displayed
let hasBackgroundAction = false; // true iff there is an action behind the HomeMenu
const mutex = new Mutex(); // used to protect against concurrent toggling requests
class HomeMenuAction extends Component {
setup() {
this.router = useService("router");
this.menus = useService("menu");
this.homeMenuProps = {
apps: computeAppsAndMenuItems(this.menus.getMenuAsTree("root")).apps,
};
onMounted(() => this.onMounted());
onWillUnmount(this.onWillUnmount);
}
async onMounted() {
const { breadcrumbs } = this.env.config;
hasHomeMenu = true;
hasBackgroundAction = breadcrumbs.length > 0;
this.router.pushState({ menu_id: undefined }, { lock: false, replace: true });
this.env.bus.trigger("HOME-MENU:TOGGLED");
}
onWillUnmount() {
hasHomeMenu = false;
hasBackgroundAction = false;
const currentMenuId = this.menus.getCurrentApp();
if (currentMenuId) {
this.router.pushState({ menu_id: currentMenuId.id }, { lock: true });
}
this.env.bus.trigger("HOME-MENU:TOGGLED");
}
}
HomeMenuAction.components = { HomeMenu };
HomeMenuAction.target = "current";
HomeMenuAction.template = xml`<HomeMenu t-props="homeMenuProps"/>`;
registry.category("actions").add("menu", HomeMenuAction);
env.bus.on("HOME-MENU:TOGGLED", null, () => {
document.body.classList.toggle("o_home_menu_background", hasHomeMenu);
});
return {
get hasHomeMenu() {
return hasHomeMenu;
},
get hasBackgroundAction() {
return hasBackgroundAction;
},
async toggle(show) {
return mutex.exec(async () => {
show = show === undefined ? !hasHomeMenu : Boolean(show);
if (show !== 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));
});
},
};
},
};
registry.category("services").add("home_menu", homeMenuService);

View File

@@ -0,0 +1,9 @@
// = Navbar
// ============================================================================
// No CSS hacks, variables overrides only
.o_main_navbar {
--o-navbar-badge-bg: #{$primary};
--o-navbar-badge-color: #{$white};
--o-navbar-badge-text-shadow: none;
}

View File

@@ -0,0 +1,47 @@
/** @odoo-module **/
import { NavBar } from "@web/webclient/navbar/navbar";
import { useService, useBus } from "@web/core/utils/hooks";
const { useEffect, useRef } = owl;
export class EnterpriseNavBar extends NavBar {
setup() {
super.setup();
this.hm = useService("home_menu");
this.menuAppsRef = useRef("menuApps");
this.navRef = useRef("nav");
useBus(this.env.bus, "HOME-MENU:TOGGLED", () => this._updateMenuAppsIcon());
useEffect(() => this._updateMenuAppsIcon());
}
get hasBackgroundAction() {
return this.hm.hasBackgroundAction;
}
get isInApp() {
return !this.hm.hasHomeMenu;
}
_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
);
const { _t } = this.env;
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 appSubMenus = this.appSubMenus.el;
if (appSubMenus) {
appSubMenus.classList.toggle("o_hidden", !this.isInApp);
}
}
}
EnterpriseNavBar.template = "web_enterprise.EnterpriseNavBar";

View File

@@ -0,0 +1,63 @@
// = Main Navbar
// ============================================================================
.o_main_navbar {
.o_menu_toggle {
@extend %-main-navbar-entry-base;
@extend %-main-navbar-entry-spacing;
color: $o-navbar-entry-color;
rect, g {
transform-origin: 0 50%;
}
// Define a local mixin to handle the toggle state
// --------------------------------------------------------------------
@mixin o_main_navbar_toggler_toggled() {
rect {
width: 6px;
height: 3px;
&:first-child {
transform: translate(12%,0);
rx: 1;
}
}
#o_menu_toggle_row_0 {
transform: scale3d(.5, 1, 1) translate(0, 45%) skewY(-22deg);
+ g rect {
width: 0;
height: 0;
}
}
#o_menu_toggle_row_2 {
transform: scale3d(.5, 1, 1) translate(0, -37%) skewY(22deg);
}
}
&.o_menu_toggle_back {
@include o_main_navbar_toggler_toggled();
transform: scaleX(-1);
}
// Animate on large screen without 'reduced-motion' only.
// --------------------------------------------------------------------
@include media-breakpoint-up(lg) {
@media screen and (prefers-reduced-motion: no-preference) {
&:hover {
@include o_main_navbar_toggler_toggled();
}
&, g {
transition: all .3s;
}
rect {
transition: all .1s;
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
// = Navbar Variables
// ============================================================================
// No CSS hacks, variables overrides only
$o-navbar-border-bottom: 1px solid $o-border-color !default;
$o-navbar-background: $o-view-background-color!default;
$o-navbar-entry-color: $o-gray-900 !default;
$o-navbar-home-menu-badge-bg: $o-enterprise-primary-color !default;

View File

@@ -0,0 +1,9 @@
// = Enterprise Main Navbar Variables
// ============================================================================
$o-navbar-border-bottom: 0 !default;
$o-navbar-background: linear-gradient(45deg, $o-brand-odoo, adjust-color($o-brand-odoo, $saturation: -8%, $lightness: -4%)) !default;
$o-navbar-badge-size-adjust: 0 !default;
$o-navbar-badge-bg: $o-enterprise-primary-color-light !default;
$o-navbar-home-menu-badge-bg: $o-brand-primary !default;

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_enterprise.EnterpriseNavBar" 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 href="#" class="o_menu_toggle" accesskey="h" t-ref="menuApps" t-on-click.prevent="() => this.hm.toggle()">
<svg 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="4" height="4" t-att-x="X" t-att-y="Y" t-key="'o_menu_toggle_cell_' + X_index"/>
</g>
</svg>
</a>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,47 @@
/** @odoo-module */
import { browser } from "@web/core/browser/browser";
import { Dialog } from "@web/core/dialog/dialog";
import { useChildRef, useService } from "@web/core/utils/hooks";
const { Component, useExternalListener } = owl;
export class PromoteStudioDialog extends Component {
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();
}
}
}
PromoteStudioDialog.template = "web_enterprise.PromoteStudioDialog";
PromoteStudioDialog.components = { Dialog };

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<template>
<t t-name="web_enterprise.PromoteStudioDialog" owl="1">
<Dialog title="title" header="false" modalRef="modalRef">
<div class="modal-studio">
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" tabindex="-1" t-on-click="props.close"></button>
<h2 class="modal-title">Add new fields and much more with <b>Odoo Studio</b></h2>
<div class="o_video_embed">
<div class="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">
<div class="o_install_studio_btn">
<button class="btn btn-primary btn-block o_install_studio" t-on-click.stop="onClickInstallStudio"><b>Install Odoo Studio</b></button>
</div>
</t>
</div>
</Dialog>
</t>
</template>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.res_config_edition" t-inherit-mode="extension" owl="1">
<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://github.com/odoo/enterprise/blob/13.0/LICENSE" 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-settings-form-view .o_base_settings {
--settings__tab-bg: #{$o-gray-100};
--settings__tab-bg--active: #{$o-gray-300};
--settings__tab-color: #{$o-gray-700};
--settings__title-bg: #{$o-gray-100};
}

View File

@@ -0,0 +1,17 @@
/** @odoo-module **/
import { WebClient } from "@web/webclient/webclient";
import { useService } from "@web/core/utils/hooks";
import { EnterpriseNavBar } from "./navbar/navbar";
export class WebClientEnterprise extends WebClient {
setup() {
super.setup();
this.hm = useService("home_menu");
useService("enterprise_legacy_service_provider");
}
_loadDefaultApp() {
return this.hm.toggle(true);
}
}
WebClientEnterprise.components = { ...WebClient.components, NavBar: EnterpriseNavBar };

View File

@@ -0,0 +1,12 @@
/** @odoo-module */
import { createWebClient } from "@web/../tests/webclient/helpers";
import { registry } from "@web/core/registry";
import { legacyServiceProvider } from "@web_enterprise/legacy/legacy_service_provider";
import { WebClientEnterprise } from "@web_enterprise/webclient/webclient";
export function createEnterpriseWebClient(params) {
params.WebClientClass = WebClientEnterprise;
registry.category("services").add("enterprise_legacy_service_provider", legacyServiceProvider);
return createWebClient(params);
}

View File

@@ -0,0 +1,128 @@
/** @odoo-module **/
import { createWebClient, doAction, getActionManagerServerData, loadState } from "@web/../tests/webclient/helpers";
import { click, getFixture, legacyExtraNextTick } from "@web/../tests/helpers/utils";
let serverData;
let target;
QUnit.module('ActionManager', {
beforeEach() {
serverData = getActionManagerServerData();
target = getFixture();
Object.assign(serverData, {
actions: {
1: {
id: 1,
name: 'Partners Action 1',
res_model: 'partner',
type: 'ir.actions.act_window',
views: [[false, 'list'], [false, 'kanban'], [false, 'form']],
},
2: {
id: 2,
name: 'Partners Action 2',
res_model: 'partner',
type: 'ir.actions.act_window',
views: [[false, 'list'], [false, 'form']],
},
},
views: {
'partner,false,kanban': `
<kanban>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<field name="foo"/>
</div>
</t>
</templates>
</kanban>`,
'partner,false,list': '<tree><field name="foo"/></tree>',
'partner,false,form':
`<form>
<group>
<field name="display_name"/>
</group>
</form>`,
'partner,false,search': '<search><field name="foo" string="Foo"/></search>',
},
models: {
partner: {
fields: {
foo: { string: "Foo", type: "char" },
},
records: [
{ id: 1, display_name: "First record", foo: "yop" },
],
},
},
});
},
});
QUnit.test('uses a mobile-friendly view by default (if possible)', async function (assert) {
const webClient = await createWebClient({ serverData });
// should default on a mobile-friendly view (kanban) for action 1
await doAction(webClient, 1);
assert.containsNone(target, '.o_list_view');
assert.containsOnce(target, '.o_kanban_view');
// there is no mobile-friendly view for action 2, should use the first one (list)
await doAction(webClient, 2);
assert.containsOnce(target, '.o_list_view');
assert.containsNone(target, '.o_kanban_view');
});
QUnit.test('lazy load mobile-friendly view', async function (assert) {
const mockRPC = (route, args) => {
assert.step(args.method || route);
};
const webClient = await createWebClient({ serverData, mockRPC });
await loadState(webClient, {
action: 1,
view_type: 'form',
});
assert.containsNone(target, '.o_list_view');
assert.containsNone(target, '.o_kanban_view');
assert.containsOnce(target, '.o_form_view');
// go back to lazy loaded view
await click(target, '.o_control_panel .breadcrumb .o_back_button');
await legacyExtraNextTick();
assert.containsNone(target, '.o_form_view');
assert.containsNone(target, '.o_list_view');
assert.containsOnce(target, '.o_kanban_view');
assert.verifySteps([
'/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
]);
});
QUnit.test('view switcher button should be displayed in dropdown on mobile screens', async function (assert) {
// This test will spawn a kanban view (mobile friendly).
// so, the "legacy" code won't be tested here.
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
assert.containsOnce(target.querySelector('.o_control_panel'), '.o_cp_switch_buttons > .o-dropdown > button');
assert.containsNone(target.querySelector('.o_control_panel'), '.o_cp_switch_buttons .o-dropdown--menu .o_switch_view.o_kanban');
assert.containsNone(target.querySelector('.o_control_panel'), '.o_cp_switch_buttons .o-dropdown--menu button.o_switch_view');
assert.hasClass(target.querySelector('.o_control_panel .o_cp_switch_buttons > .o-dropdown > button > i'), 'oi-view-kanban');
await click(target, '.o_control_panel .o_cp_switch_buttons > .o-dropdown > button');
assert.hasClass(target.querySelector('.o_cp_switch_buttons .o-dropdown--menu button.o_switch_view.o_kanban'), 'active');
assert.doesNotHaveClass(target.querySelector('.o_cp_switch_buttons .o-dropdown--menu button.o_switch_view.o_list'), 'active');
assert.hasClass(target.querySelector('.o_cp_switch_buttons .o-dropdown--menu button.o_switch_view.o_kanban'), 'oi-view-kanban');
});

View File

@@ -0,0 +1,160 @@
odoo.define('web_mobile.barcode.tests', function (require) {
"use strict";
const fieldRegistry = require('web.field_registry');
const FormView = require('web.FormView');
const { FieldMany2One } = require('web.relational_fields');
const { createView, dom, mock } = require('web.test_utils');
const FieldMany2OneBarcode = require('web_mobile.barcode_fields');
const BarcodeScanner = require('@web/webclient/barcode/barcode_scanner');
const NAME_SEARCH = "name_search";
const PRODUCT_PRODUCT = 'product.product';
const SALE_ORDER_LINE = 'sale_order_line';
const PRODUCT_FIELD_NAME = 'product_id';
const ARCHS = {
'product.product,false,kanban': `
<kanban><templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click"><field name="display_name"/></div>
</t>
</templates></kanban>`,
'product.product,false,search': '<search></search>',
};
async function mockRPC(route, args) {
const result = await this._super(...arguments);
if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
const records = this.data[PRODUCT_PRODUCT].records
.filter((record) => record.barcode === args.kwargs.name)
.map((record) => [record.id, record.name]);
return records.concat(result);
}
return result;
}
QUnit.module('web_mobile', {
beforeEach() {
this.data = {
[PRODUCT_PRODUCT]: {
fields: {
id: {type: 'integer'},
name: {},
barcode: {},
},
records: [{
id: 111,
name: 'product_cable_management_box',
barcode: '601647855631',
}, {
id: 112,
name: 'product_n95_mask',
barcode: '601647855632',
}, {
id: 113,
name: 'product_surgical_mask',
barcode: '601647855633',
}],
},
[SALE_ORDER_LINE]: {
fields: {
id: {type: 'integer'},
[PRODUCT_FIELD_NAME]: {
string: PRODUCT_FIELD_NAME,
type: 'many2one',
relation: PRODUCT_PRODUCT
},
product_uom_qty: {type: 'integer'}
}
},
};
},
}, function () {
QUnit.test("web_mobile: barcode button in a mobile environment with single results", async function (assert) {
assert.expect(2);
// simulate a mobile environment
fieldRegistry.add('many2one_barcode', FieldMany2OneBarcode);
mock.patch(BarcodeScanner, {
isBarcodeScannerSupported: () => true,
scanBarcode: async () => this.data[PRODUCT_PRODUCT].records[0].barcode,
});
const form = await createView({
View: FormView,
arch: `
<form>
<sheet>
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
</sheet>
</form>`,
data: this.data,
model: SALE_ORDER_LINE,
archs: ARCHS,
mockRPC,
});
const $scanButton = form.$('.o_barcode');
assert.equal($scanButton.length, 1, "has scanner button");
await dom.click($scanButton);
const selectedId = form.renderer.state.data[PRODUCT_FIELD_NAME].res_id;
assert.equal(selectedId, this.data[PRODUCT_PRODUCT].records[0].id,
`product found and selected (${this.data[PRODUCT_PRODUCT].records[0].barcode})`);
form.destroy();
fieldRegistry.add('many2one_barcode', FieldMany2One);
mock.unpatch(BarcodeScanner);
});
QUnit.test("web_mobile: barcode button in a mobile environment with multiple results", async function (assert) {
assert.expect(4);
// simulate a mobile environment
fieldRegistry.add('many2one_barcode', FieldMany2OneBarcode);
mock.patch(BarcodeScanner, {
isBarcodeScannerSupported: () => true,
scanBarcode: async () => "mask",
});
const form = await createView({
View: FormView,
arch: `
<form>
<sheet>
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
</sheet>
</form>`,
data: this.data,
model: SALE_ORDER_LINE,
archs: ARCHS,
mockRPC,
});
const $scanButton = form.$('.o_barcode');
assert.equal($scanButton.length, 1, "has scanner button");
await dom.click($scanButton);
const $modal = $('.o_modal_full .modal-lg');
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
assert.equal($modal.find('.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 2,
'there should be 2 records displayed');
await dom.click($modal.find('.o_legacy_kanban_view .o_kanban_record:first'));
const selectedId = form.renderer.state.data[PRODUCT_FIELD_NAME].res_id;
assert.equal(selectedId, this.data[PRODUCT_PRODUCT].records[1].id,
`product found and selected (${this.data[PRODUCT_PRODUCT].records[1].barcode})`);
form.destroy();
fieldRegistry.add('many2one_barcode', FieldMany2One);
mock.unpatch(BarcodeScanner);
});
});
});

View File

@@ -0,0 +1,155 @@
odoo.define('web_mobile.barcode.tests', function (require) {
"use strict";
const fieldRegistry = require('web.field_registry');
const FormView = require('web.FormView');
const { FieldMany2One } = require('web.relational_fields');
const { createView, dom, mock } = require('web.test_utils');
const FieldMany2OneBarcode = require('web_mobile.barcode_fields');
const BarcodeScanner = require('@web/webclient/barcode/barcode_scanner');
const NAME_SEARCH = "name_search";
const PRODUCT_PRODUCT = 'product.product';
const SALE_ORDER_LINE = 'sale_order_line';
const PRODUCT_FIELD_NAME = 'product_id';
const ARCHS = {
'product.product,false,list': `<tree>
<field name="display_name"/>
</tree>`,
'product.product,false,search': '<search></search>',
};
async function mockRPC(route, args) {
const result = await this._super(...arguments);
if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
const records = this.data[PRODUCT_PRODUCT].records
.filter((record) => record.barcode === args.kwargs.name)
.map((record) => [record.id, record.name]);
return records.concat(result);
}
return result;
}
QUnit.module('web_mobile', {
beforeEach() {
this.data = {
[PRODUCT_PRODUCT]: {
fields: {
id: {type: 'integer'},
name: {},
barcode: {},
},
records: [{
id: 111,
name: 'product_cable_management_box',
barcode: '601647855631',
}, {
id: 112,
name: 'product_n95_mask',
barcode: '601647855632',
}, {
id: 113,
name: 'product_surgical_mask',
barcode: '601647855633',
}],
},
[SALE_ORDER_LINE]: {
fields: {
id: {type: 'integer'},
[PRODUCT_FIELD_NAME]: {
string: PRODUCT_FIELD_NAME,
type: 'many2one',
relation: PRODUCT_PRODUCT
},
}
},
};
},
}, function () {
QUnit.test("web_mobile: barcode button in a mobile environment with single results", async function (assert) {
assert.expect(2);
// simulate a mobile environment
fieldRegistry.add('many2one_barcode', FieldMany2OneBarcode);
mock.patch(BarcodeScanner, {
isBarcodeScannerSupported: () => true,
scanBarcode: async () => this.data[PRODUCT_PRODUCT].records[0].barcode,
});
const form = await createView({
View: FormView,
arch: `
<form>
<sheet>
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
</sheet>
</form>`,
data: this.data,
model: SALE_ORDER_LINE,
archs: ARCHS,
mockRPC,
});
const $scanButton = form.$('.o_barcode');
assert.containsOnce(form, $scanButton, "has scanner button");
await dom.click($scanButton);
const selectedId = form.renderer.state.data[PRODUCT_FIELD_NAME].res_id;
assert.equal(selectedId, this.data[PRODUCT_PRODUCT].records[0].id,
`product found and selected (${this.data[PRODUCT_PRODUCT].records[0].barcode})`);
form.destroy();
fieldRegistry.add('many2one_barcode', FieldMany2One);
mock.unpatch(BarcodeScanner);
});
QUnit.test("web_mobile: barcode button in a mobile environment with multiple results", async function (assert) {
// simulate a mobile environment
fieldRegistry.add('many2one_barcode', FieldMany2OneBarcode);
mock.patch(BarcodeScanner, {
isBarcodeScannerSupported: () => true,
scanBarcode: async () => "mask"
});
const form = await createView({
View: FormView,
arch: `
<form>
<sheet>
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
</sheet>
</form>`,
data: this.data,
model: SALE_ORDER_LINE,
archs: ARCHS,
mockRPC,
});
const $scanButton = form.$('.o_barcode');
assert.containsOnce(form, $scanButton, "has scanner button");
await dom.click($scanButton);
const $modal = $('.modal-dialog.modal-lg');
assert.containsOnce($('body'), $modal, 'there should be one modal opened in full screen');
assert.containsN($modal, '.o_legacy_list_view .o_data_row', 2,
'there should be 2 records displayed');
await dom.click($modal.find('.o_legacy_list_view .o_data_row:first'));
const selectedId = form.renderer.state.data[PRODUCT_FIELD_NAME].res_id;
assert.equal(selectedId, this.data[PRODUCT_PRODUCT].records[1].id,
`product found and selected (${this.data[PRODUCT_PRODUCT].records[1].barcode})`);
form.destroy();
fieldRegistry.add('many2one_barcode', FieldMany2One);
mock.unpatch(BarcodeScanner);
});
});
});

View File

@@ -0,0 +1,82 @@
odoo.define('web.action_menus_mobile_tests', function (require) {
"use strict";
const ActionMenus = require('web.ActionMenus');
const Registry = require('web.Registry');
const testUtils = require('web.test_utils');
const { createComponent } = testUtils;
QUnit.module('Components', {
beforeEach() {
this.action = {
res_model: 'hobbit',
};
this.view = {
type: 'form',
};
this.props = {
activeIds: [23],
context: {},
items: {
action: [
{ action: { id: 1 }, name: "What's taters, precious ?", id: 1 },
],
print: [
{ action: { id: 2 }, name: "Po-ta-toes", id: 2 },
],
},
};
// Patch the registry of the action menus
this.actionMenusRegistry = ActionMenus.registry;
ActionMenus.registry = new Registry();
},
afterEach() {
ActionMenus.registry = this.actionMenusRegistry;
},
}, function () {
QUnit.module('ActionMenus');
QUnit.test('Auto close the print dropdown after click inside an item', async function (assert) {
assert.expect(6);
const actionMenus = await createComponent(ActionMenus, {
env: {
device: {
isMobile: true
},
action: this.action,
view: this.view,
},
intercepts: {
'do-action': ev => assert.step('do-action'),
},
props: this.props,
async mockRPC(route, args) {
switch (route) {
case '/web/action/load':
const expectedContext = {
active_id: 23,
active_ids: [23],
active_model: 'hobbit',
};
assert.deepEqual(args.context, expectedContext);
assert.step('load-action');
return {context: {}, flags: {}};
default:
return this._super(...arguments);
}
},
});
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Print");
assert.containsOnce(actionMenus.el, '.dropdown-menu-start',
"should display the dropdown menu");
await testUtils.controlPanel.toggleMenuItem(actionMenus, "Po-ta-toes");
assert.containsNone(actionMenus.el, '.dropdown-menu-start',
"should not display the dropdown menu");
assert.verifySteps(['load-action', 'do-action']);
});
});
});

View File

@@ -0,0 +1,245 @@
odoo.define('web.control_panel_mobile_tests', function (require) {
"use strict";
const FormView = require('web.FormView');
const testUtils = require('web.test_utils');
const cpHelpers = require('@web/../tests/search/helpers');
const { browser } = require("@web/core/browser/browser");
const { patchWithCleanup, getFixture } = require("@web/../tests/helpers/utils");
const { createControlPanel, createView } = testUtils;
const { createWebClient, doAction, getActionManagerServerData } = require('@web/../tests/webclient/helpers');
let serverData;
let target;
QUnit.module('Control Panel', {
beforeEach: function () {
target = getFixture();
this.actions = [{
id: 1,
name: "Yes",
res_model: 'partner',
type: 'ir.actions.act_window',
views: [[false, 'list']],
}];
this.archs = {
'partner,false,list': '<tree><field name="foo"/></tree>',
'partner,false,search': `
<search>
<filter string="Active" name="my_projects" domain="[('boolean_field', '=', True)]"/>
<field name="foo" string="Foo"/>
</search>`,
};
this.data = {
partner: {
fields: {
foo: { string: "Foo", type: "char" },
boolean_field: { string: "I am a boolean", type: "boolean" },
},
records: [
{ id: 1, display_name: "First record", foo: "yop" },
],
},
};
const actions = {};
this.actions.forEach((act) => {
actions[act.xml_id || act.id] = act;
});
serverData = getActionManagerServerData();
Object.assign(serverData, { models: this.data, views: this.archs, actions });
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => {},
});
},
}, function () {
QUnit.test('basic rendering', async function (assert) {
assert.expect(2);
const webClient = await createWebClient({serverData});
await doAction(webClient, 1);
assert.containsNone(target, '.o_control_panel .o_mobile_search',
"search options are hidden by default");
assert.containsOnce(target, '.o_control_panel .o_enable_searchview',
"should display a button to toggle the searchview");
});
QUnit.test("control panel appears at top on scroll event", async function (assert) {
assert.expect(12);
const MAX_HEIGHT = 800;
const MIDDLE_HEIGHT = 400;
const DELTA_TEST = 20;
const form = await createView({
View: FormView,
arch:
'<form>' +
'<sheet>' +
`<div style="height: ${2 * MAX_HEIGHT}px"></div>` +
'</sheet>' +
'</form>',
data: this.data,
model: 'partner',
res_id: 1,
});
const controlPanelEl = document.querySelector('.o_control_panel');
const controlPanelHeight = controlPanelEl.offsetHeight;
// Force container to have a scrollbar
controlPanelEl.parentElement.style.maxHeight = `${MAX_HEIGHT}px`;
const scrollAndAssert = async (targetHeight, expectedTopValue, hasStickyClass) => {
if (targetHeight !== null) {
controlPanelEl.parentElement.scrollTo(0, targetHeight);
await testUtils.nextTick();
}
const expectedPixelValue = `${expectedTopValue}px`;
assert.strictEqual(controlPanelEl.style.top, expectedPixelValue,
`Top must be ${expectedPixelValue} (after scroll to ${targetHeight})`);
if (hasStickyClass) {
assert.hasClass(controlPanelEl, 'o_mobile_sticky');
} else {
assert.doesNotHaveClass(controlPanelEl, 'o_mobile_sticky');
}
}
// Initial position (scrollTop: 0)
await scrollAndAssert(null, 0, false);
// Scroll down 800px (scrollTop: 800)
await scrollAndAssert(MAX_HEIGHT, -controlPanelHeight, true);
// Scoll up 20px (scrollTop: 780)
await scrollAndAssert(MAX_HEIGHT - DELTA_TEST, -controlPanelHeight + DELTA_TEST, true);
// Scroll up 380px (scrollTop: 400)
await scrollAndAssert(MIDDLE_HEIGHT, 0, true);
// Scroll down 200px (scrollTop: 800)
await scrollAndAssert(MAX_HEIGHT, -controlPanelHeight, true);
// Scroll up 400px (scrollTop: 0)
await scrollAndAssert(0, -controlPanelHeight, false);
form.destroy();
});
QUnit.test("mobile search: basic display", async function (assert) {
assert.expect(4);
const fields = {
birthday: { string: "Birthday", type: "date", store: true, sortable: true },
};
const searchMenuTypes = ["filter", "groupBy", "comparison", "favorite"];
const params = {
cpModelConfig: {
arch: `
<search>
<filter name="birthday" date="birthday"/>
</search>`,
fields,
searchMenuTypes,
},
cpProps: { fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
// Toggle search bar controls
await testUtils.dom.click(controlPanel.el.querySelector("button.o_enable_searchview"));
// Open search view
await testUtils.dom.click(controlPanel.el.querySelector("button.o_toggle_searchview_full"));
// Toggle filter date
// Note: 'document.body' is used instead of 'controlPanel' because the
// search view is directly in the body.
await cpHelpers.toggleFilterMenu(document);
await cpHelpers.toggleMenuItem(document, "Birthday");
await cpHelpers.toggleMenuItemOption(document, "Birthday", 0);
assert.containsOnce(document.body, ".o_filter_menu");
assert.containsOnce(document.body, ".o_group_by_menu");
assert.containsOnce(document.body, ".o_comparison_menu");
assert.containsOnce(document.body, ".o_favorite_menu");
});
QUnit.test('mobile search: activate a filter through quick search', async function (assert) {
assert.expect(7);
let searchRPCFlag = false;
const mockRPC = (route, args) => {
if (searchRPCFlag && args.method === "web_search_read") {
assert.deepEqual(args.kwargs.domain, [['foo', 'ilike', 'A']],
"domain should have been properly transferred to list view");
}
};
const webClient = await createWebClient({serverData, mockRPC});
await doAction(webClient, 1);
assert.containsOnce(document.body, 'button.o_enable_searchview.oi-search',
"should display a button to open the searchview");
assert.containsNone(document.body, '.o_searchview_input_container',
"Quick search input should be hidden");
// open the search view
await testUtils.dom.click(document.querySelector('button.o_enable_searchview'));
assert.containsOnce(document.body, '.o_toggle_searchview_full',
"should display a button to expand the searchview");
assert.containsOnce(document.body, '.o_searchview_input_container',
"Quick search input should now be visible");
searchRPCFlag = true;
// use quick search input (search view is directly put in the body)
await cpHelpers.editSearch(document.body, "A");
await cpHelpers.validateSearch(document.body);
// close quick search
await testUtils.dom.click(document.querySelector('button.o_enable_searchview.fa-arrow-left'));
assert.containsNone(document.body, '.o_toggle_searchview_full',
"Expand icon shoud be hidden");
assert.containsNone(document.body, '.o_searchview_input_container',
"Quick search input should be hidden");
});
QUnit.test('mobile search: activate a filter in full screen search view', async function (assert) {
assert.expect(3);
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
assert.containsNone(document.body, '.o_mobile_search');
// open the search view
await testUtils.dom.click(target.querySelector('button.o_enable_searchview'));
// open it in full screen
await testUtils.dom.click(target.querySelector('.o_toggle_searchview_full'));
assert.containsOnce(document.body, '.o_mobile_search');
await cpHelpers.toggleFilterMenu(document.body);
await cpHelpers.toggleMenuItem(document.body, "Active");
// closing search view
await testUtils.dom.click(
[...document.querySelectorAll('.o_mobile_search_button')].find(
e => e.innerText.trim() === "FILTER"
)
);
assert.containsNone(document.body, '.o_mobile_search');
});
});
});

View File

@@ -0,0 +1,409 @@
odoo.define('web_enterprise.form_tests', function (require) {
"use strict";
var FormView = require('web.FormView');
var testUtils = require('web.test_utils');
var createView = testUtils.createView;
QUnit.module('web_enterprise', {
beforeEach: function () {
this.data = {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char" },
trululu: {string: "Trululu", type: "many2one", relation: 'partner'},
},
records: [{
id: 1,
display_name: "first record",
trululu: 4,
}, {
id: 2,
display_name: "second record",
trululu: 1,
}, {
id: 4,
display_name: "aaa",
}],
},
};
},
}, function () {
QUnit.module('Mobile FormView');
QUnit.test('statusbar buttons are correctly rendered in mobile', async function (assert) {
assert.expect(5);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<header>' +
'<button string="Confirm"/>' +
'<button string="Do it"/>' +
'</header>' +
'<sheet>' +
'<group>' +
'<button name="display_name"/>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 1,
});
assert.strictEqual(form.$('.o_statusbar_buttons a:contains(Action)').length, 1,
"statusbar should contain a button 'Action'");
assert.containsOnce(form, '.o_statusbar_buttons .dropdown-menu',
"statusbar should contain a dropdown");
assert.containsNone(form, '.o_statusbar_buttons .dropdown-menu:visible',
"dropdown should be hidden");
// open the dropdown
await testUtils.dom.click(form.$('.o_statusbar_buttons a'));
assert.containsOnce(form, '.o_statusbar_buttons .dropdown-menu:visible',
"dropdown should be visible");
assert.containsN(form, '.o_statusbar_buttons .dropdown-menu > button', 2,
"dropdown should contain 2 buttons");
form.destroy();
});
QUnit.test('statusbar "Action" button should be displayed only if there are multiple visible buttons', async function (assert) {
assert.expect(4);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<header>' +
'<button string="Confirm" attrs=\'{"invisible": [["display_name", "=", "first record"]]}\'/>' +
'<button string="Do it" attrs=\'{"invisible": [["display_name", "=", "first record"]]}\'/>' +
'</header>' +
'<sheet>' +
'<group>' +
'<field name="display_name"/>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 1,
});
// if all buttons are invisible then there should be no action button
assert.containsNone(form, '.o_statusbar_buttons > btn-group > .dropdown-toggle',
"'Action' dropdown is not displayed as there are no visible buttons");
// there should be two invisible buttons
assert.containsN(form, '.o_statusbar_buttons > button.o_invisible_modifier', 2,
"Status bar should have two buttons with 'o_invisible_modifier' class");
// change display_name to update buttons modifiers and make it visible
await testUtils.form.clickEdit(form);
await testUtils.fields.editInput(form.$('input[name=display_name]'), 'test');
await testUtils.form.clickSave(form);
assert.containsOnce(form, '.o_statusbar_buttons a:contains(Action)',
"statusbar should contain a button 'Action'");
assert.containsOnce(form, '.o_statusbar_buttons .dropdown-menu',
"statusbar should contain a dropdown");
form.destroy();
});
QUnit.test('statusbar "Action" button not displayed in edit mode with .oe_read_only button', async function (assert) {
assert.expect(2);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `
<form>
<header>
<button string="Share" type="action" class="oe_highlight oe_read_only"/>
<button string="Email" type="action" class="oe_highlight oe_read_only"/>
</header>
<sheet>
<group>
<field name="display_name"/>
</group>
</sheet>
</form>
`,
res_id: 1,
viewOptions: {
mode: 'edit',
},
});
assert.containsNone(form, '.o_statusbar_buttons a:contains(Action)',
"'Action' button should not be there");
await testUtils.form.clickSave(form);
assert.containsOnce(form, '.o_statusbar_buttons a:contains(Action)',
"'Action' button should be there");
form.destroy();
});
QUnit.test(`statusbar "Action" button shouldn't be displayed for only one visible button`, async function (assert) {
assert.expect(3);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `<form>
<header>
<button string="Hola" attrs='{"invisible": [["display_name", "=", "first record"]]}'/>
<button string="Ciao"/>
</header>
<sheet>
<group>
<field name="display_name"/>
</group>
</sheet>
</form>`,
res_id: 1,
viewOptions: {
mode: 'edit',
},
});
// There should be a simple statusbar button and no action dropdown
assert.containsNone(form, '.o_statusbar_buttons a:contains(Action)', "should have no 'Action' dropdown");
assert.containsOnce(form, '.o_statusbar_buttons > button > span:contains(Ciao)', "should have a simple statusbar button 'Ciao'");
// change display_name to update buttons modifiers and make both buttons visible
await testUtils.fields.editInput(form.$('input[name=display_name]'), 'test');
// Now there should an action dropdown, because there are two visible buttons
assert.containsOnce(form, '.o_statusbar_buttons a:contains(Action)', "should have no 'Action' dropdown");
form.destroy();
});
QUnit.test(`statusbar widgets should appear in the statusbar dropdown only if there are multiple items`, async function (assert) {
assert.expect(4);
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `
<form string="Partners">
<header>
<widget name="attach_document" string="Attach document"/>
<button string="Ciao" attrs='{"invisible": [["display_name", "=", "first record"]]}'/>
</header>
<sheet>
<group>
<field name="display_name"/>
</group>
</sheet>
</form>
`,
res_id: 2,
viewOptions: {
mode: 'edit',
},
});
const dropdownActionButton = '.o_statusbar_buttons a:contains(Action)';
// Now there should an action dropdown, because there are two visible buttons
assert.containsOnce(form, dropdownActionButton, "should have 'Action' dropdown");
assert.containsN(form, `.o_statusbar_buttons .dropdown-menu > button`,
2, "should have 2 buttons in the dropdown");
// change display_name to update buttons modifiers and make one button visible
await testUtils.fields.editInput(form.$('input[name=display_name]'), 'first record');
// There should be a simple statusbar button and no action dropdown
assert.containsNone(form, dropdownActionButton, "shouldn't have 'Action' dropdown");
assert.containsOnce(form, `.o_statusbar_buttons > button:visible`,
"should have 1 button visible in the statusbar");
form.destroy();
});
QUnit.test(`Quick Edition: quick edit many2one`, async function (assert) {
assert.expect(1);
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `
<form>
<sheet>
<group>
<field name="trululu" />
</group>
</sheet>
</form>
`,
archs: {
'partner,false,kanban': `
<kanban>
<templates><t t-name="kanban-box">
<div class="oe_kanban_global_click">
<field name="display_name"/>
</div>
</t></templates>
</kanban>
`,
'partner,false,search': '<search></search>',
},
res_id: 2,
});
await testUtils.dom.click(form.$('.o_form_label'));
await testUtils.nextTick(); // wait for quick edit
const $modal = $('.o_modal_full .modal-lg');
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
form.destroy();
});
QUnit.test('statusbar "Action" dropdown should keep its open/close state', async function (assert) {
assert.expect(5);
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `
<form>
<header>
<button string="Just more than one"/>
<button string="Confirm" attrs='{"invisible": [["display_name", "=", ""]]}'/>
<button string="Do it" attrs='{"invisible": [["display_name", "!=", ""]]}'/>
</header>
<sheet>
<field name="display_name"/>
</sheet>
</form>
`,
});
const dropdownMenuSelector = '.o_statusbar_buttons .dropdown-menu';
assert.containsOnce(form, dropdownMenuSelector,
"statusbar should contain a dropdown");
assert.doesNotHaveClass(form.el.querySelector(dropdownMenuSelector), 'show',
"dropdown should be closed");
// open the dropdown
await testUtils.dom.click(form.el.querySelector('.o_statusbar_buttons .dropdown-toggle'));
assert.hasClass(form.el.querySelector(dropdownMenuSelector), 'show',
"dropdown should be opened");
// change display_name to update buttons' modifiers
await testUtils.fields.editInput(form.el.querySelector('input[name="display_name"]'), 'test');
assert.containsOnce(form, dropdownMenuSelector,
"statusbar should contain a dropdown");
assert.hasClass(form.el.querySelector(dropdownMenuSelector), 'show',
"dropdown should still be opened");
form.destroy();
});
QUnit.test(`statusbar "Action" dropdown's open/close state shouldn't be modified after 'onchange'`, async function (assert) {
assert.expect(5);
let resolveOnchange;
const onchangePromise = new Promise(resolve => {
resolveOnchange = resolve;
});
this.data.partner.onchanges = {
display_name: async () => {},
};
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `
<form>
<header>
<button name="create" string="Create Invoice" type="action"/>
<button name="send" string="Send by Email" type="action"/>
</header>
<sheet>
<field name="display_name" />
</sheet>
</form>
`,
async mockRPC(route, { method, args: [, , changedField] }) {
return method === 'onchange' && changedField === 'display_name' ? onchangePromise : this._super(...arguments);
},
});
const dropdownMenuSelector = '.o_statusbar_buttons .dropdown-menu';
assert.containsOnce(form, dropdownMenuSelector,
"statusbar should contain a dropdown");
assert.doesNotHaveClass(form.el.querySelector(dropdownMenuSelector), 'show',
"dropdown should be closed");
await testUtils.fields.editInput(form.el.querySelector('input[name="display_name"]'), 'before onchange');
await testUtils.dom.click(form.el.querySelector('.o_statusbar_buttons .dropdown-toggle'));
assert.hasClass(form.el.querySelector(dropdownMenuSelector), 'show',
"dropdown should be opened");
resolveOnchange({ value: { display_name: 'after onchange' } });
await testUtils.nextTick();
assert.strictEqual(form.el.querySelector('input[name="display_name"]').value, 'after onchange');
assert.hasClass(form.el.querySelector(dropdownMenuSelector), 'show',
"dropdown should still be opened");
form.destroy();
});
QUnit.test("preserve current scroll position on form view while closing dialog", async function (assert) {
assert.expect(6);
const form = await createView({
View: FormView,
arch: `<form>
<sheet>
<p style='height:500px'></p>
<field name="trululu"/>
<p style='height:500px'></p>
</sheet>
</form>`,
archs: {
"partner,false,kanban": `<kanban>
<templates><t t-name="kanban-box">
<div class="oe_kanban_global_click"><field name="display_name"/></div>
</t></templates>
</kanban>`,
"partner,false,search": "<search></search>",
},
data: this.data,
model: "partner",
res_id: 2,
debug: true, // need to be in the viewport to check scrollPosition
viewOptions: { mode: "edit" },
});
const scrollPosition = { top: 265, left: 0 };
window.scrollTo(scrollPosition);
assert.strictEqual(window.scrollY, scrollPosition.top, "Should have scrolled 265 px vertically");
assert.strictEqual(window.scrollX, scrollPosition.left, "Should be 0 px from left as it is");
// click on m2o field
await testUtils.dom.click(form.$(".o_field_many2one input"));
assert.strictEqual(window.scrollY, 0, "Should have scrolled to top (0) px");
assert.containsOnce($("body"), ".modal.o_modal_full", "there should be a many2one modal opened in full screen");
// click on back button
await testUtils.dom.click($(".modal").find(".modal-header .fa-arrow-left"));
assert.strictEqual(window.scrollY, scrollPosition.top, "Should have scrolled back to 265 px vertically");
assert.strictEqual(window.scrollX, scrollPosition.left, "Should be 0 px from left as it is");
form.destroy();
});
});
});

View File

@@ -0,0 +1,498 @@
odoo.define('web_enterprise.relational_fields_mobile_tests', function (require) {
"use strict";
var FormView = require('web.FormView');
var testUtils = require('web.test_utils');
var createView = testUtils.createView;
QUnit.module('web_enterprise', {}, function () {
QUnit.module('relational_fields', {
beforeEach: function () {
this.data = {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char" },
trululu: {string: "Trululu", type: "many2one", relation: 'partner'},
sibling_ids: {string: "Sibling", type: "many2many", relation: 'partner'},
p: { string: "one2many field", type: "one2many", relation: 'partner', relation_field: 'trululu' },
},
records: [{
id: 1,
display_name: "first record",
trululu: 4,
}, {
id: 2,
display_name: "second record",
trululu: 1,
}, {
id: 4,
display_name: "aaa",
}],
},
};
}
}, function () {
QUnit.module('FieldStatus');
QUnit.test('statusbar is rendered correclty on small devices', async function (assert) {
assert.expect(7);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch:
'<form string="Partners">' +
'<header><field name="trululu" widget="statusbar"/></header>' +
'<field name="display_name"/>' +
'</form>',
res_id: 1,
});
assert.strictEqual(form.$('.o_statusbar_status > button:contains(aaa)').length, 1,
"should have only one visible status in mobile, the active one");
assert.containsOnce(form, '.o_statusbar_status .dropdown-menu',
"should have a dropdown containing all status");
assert.containsNone(form, '.o_statusbar_status .dropdown-menu:visible',
"dropdown should be hidden");
// open the dropdown
testUtils.dom.click(form.$('.o_statusbar_status > button'));
assert.containsOnce(form, '.o_statusbar_status .dropdown-menu:visible',
"dropdown should be visible");
assert.containsN(form, '.o_statusbar_status .dropdown-menu button', 3,
"should have 3 status");
assert.containsN(form, '.o_statusbar_status button:disabled', 3,
"all status should be disabled");
var $activeStatus = form.$('.o_statusbar_status .dropdown-menu button[data-value=4]');
assert.hasClass($activeStatus,'btn-primary', "active status should be btn-primary");
form.destroy();
});
QUnit.test('statusbar with no status on extra small screens', async function (assert) {
assert.expect(9);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<header><field name="trululu" widget="statusbar"/></header>' +
'</form>',
res_id: 4,
});
assert.hasClass(form.$('.o_statusbar_status'),'o_field_empty',
'statusbar widget should have class o_field_empty');
assert.strictEqual(form.$('.o_statusbar_status').children().length, 2,
'statusbar widget should have two children');
assert.containsOnce(form, '.o_statusbar_status button.dropdown-toggle',
'statusbar widget should have a button');
assert.strictEqual(form.$('.o_statusbar_status button.dropdown-toggle').text().trim(), '',
'statusbar button has no text'); // Behavior as of saas-15, might be improved
assert.containsOnce(form, '.o_statusbar_status .dropdown-menu',
'statusbar widget should have a dropdown menu');
assert.containsN(form, '.o_statusbar_status .dropdown-menu button', 3,
'statusbar widget dropdown menu should have 3 buttons');
assert.strictEqual(form.$('.o_statusbar_status .dropdown-menu button').eq(0).text().trim(), 'first record',
'statusbar widget dropdown first button should display the first record display_name');
assert.strictEqual(form.$('.o_statusbar_status .dropdown-menu button').eq(1).text().trim(), 'second record',
'statusbar widget dropdown second button should display the second record display_name');
assert.strictEqual(form.$('.o_statusbar_status .dropdown-menu button').eq(2).text().trim(), 'aaa',
'statusbar widget dropdown third button should display the third record display_name');
form.destroy();
});
QUnit.test('clickable statusbar widget on mobile view', async function (assert) {
assert.expect(5);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<header><field name="trululu" widget="statusbar" options=\'{"clickable": "1"}\'/></header>' +
'</form>',
res_id: 1,
});
var $selectedStatus = form.$('.o_statusbar_status button[data-value="4"]');
assert.hasClass($selectedStatus, 'btn-primary');
assert.hasClass($selectedStatus,'disabled');
var selector = '.o_statusbar_status button.btn-secondary:not(.dropdown-toggle):not(:disabled)';
assert.containsN(form, selector, 2, "other status should be btn-secondary and not disabled");
await testUtils.dom.click(form.$('.o_statusbar_status .dropdown-toggle'));
await testUtils.dom.clickFirst(form.$(selector));
var $status = form.$('.o_statusbar_status button[data-value="1"]');
assert.hasClass($status, 'btn-primary');
assert.hasClass($status, 'disabled');
form.destroy();
});
QUnit.module('FieldMany2One');
QUnit.test("many2one in a enterprise environment", async function (assert) {
assert.expect(7);
var form = await createView({
View: FormView,
arch:
'<form>' +
'<sheet>' +
'<field name="trululu"/>' +
'</sheet>' +
'</form>',
archs: {
'partner,false,kanban': '<kanban>' +
'<templates><t t-name="kanban-box">' +
'<div class="oe_kanban_global_click"><field name="display_name"/></div>' +
'</t></templates>' +
'</kanban>',
'partner,false,search': '<search></search>',
},
data: this.data,
model: 'partner',
res_id: 2,
viewOptions: {mode: 'edit'},
});
var $input = form.$('.o_field_many2one input');
assert.doesNotHaveClass($input, 'ui-autocomplete-input',
"autocomplete should not be visible in a mobile environment");
await testUtils.dom.click($input);
var $modal = $('.o_modal_full .modal-lg');
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
assert.containsOnce($modal, '.o_legacy_kanban_view',
'kanban view should be open in SelectCreateDialog');
assert.containsOnce($modal, '.o_cp_searchview',
'should have Search view inside SelectCreateDialog');
assert.containsNone($modal.find(".o_control_panel .o_cp_buttons"), '.o-kanban-button-new',
"kanban view in SelectCreateDialog should not have Create button");
assert.strictEqual($modal.find(".o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)").length, 3,
"popup should load 3 records in kanban");
await testUtils.dom.click($modal.find('.o_legacy_kanban_view .o_kanban_record:first'));
assert.strictEqual($input.val(), 'first record',
'clicking kanban card should select record for many2one field');
form.destroy();
});
QUnit.test("hide/show element using selection_mode in kanban view in a enterprise environment", async function (assert) {
assert.expect(5);
this.data.partner.fields.foo = {string: "Foo", type: "char", default: "My little Foo Value"};
var form = await createView({
View: FormView,
arch:
'<form>' +
'<sheet>' +
'<field name="trululu"/>' +
'</sheet>' +
'</form>',
archs: {
'partner,false,kanban': '<kanban>' +
'<templates><t t-name="kanban-box">' +
'<div class="oe_kanban_global_click">' +
'<field name="display_name"/>' +
'</div>' +
'<div class="o_sibling_tags" t-if="!selection_mode">' +
'<field name="sibling_ids"/>' +
'</div>' +
'<div class="o_foo" t-if="selection_mode">' +
'<field name="foo"/>' +
'</div>' +
'</t></templates>' +
'</kanban>',
'partner,false,search': '<search></search>',
},
data: this.data,
model: 'partner',
res_id: 2,
viewOptions: {mode: 'edit'},
});
var $input = form.$('.o_field_many2one input');
assert.doesNotHaveClass($input, 'ui-autocomplete-input',
"autocomplete should not be visible in a mobile environment");
await testUtils.dom.click($input);
var $modal = $('.o_modal_full .modal-lg');
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
assert.containsOnce($modal, '.o_legacy_kanban_view',
'kanban view should be open in SelectCreateDialog');
assert.containsNone($modal, '.o_legacy_kanban_view .o_sibling_tags',
'o_sibling_tags div should not be available as div have condition on selection_mode');
assert.containsN($modal, '.o_legacy_kanban_view .o_foo', 3,
'o_foo div should be available as div have condition on selection_mode');
form.destroy();
});
QUnit.test("kanban_view_ref attribute opens specific kanban view given as a reference in a mobile environment", async function (assert) {
assert.expect(5);
var form = await createView({
View: FormView,
arch:
'<form>' +
'<sheet>' +
'<field name="trululu" kanban_view_ref="2"/>' +
'</sheet>' +
'</form>',
archs: {
'partner,1,kanban': '<kanban class="kanban1">' +
'<templates><t t-name="kanban-box">' +
'<div class="oe_kanban_global_click">' +
'<field name="display_name"/>' +
'</div>' +
'</t></templates>' +
'</kanban>',
'partner,2,kanban': '<kanban class="kanban2">' +
'<templates><t t-name="kanban-box">' +
'<div class="oe_kanban_global_click">' +
'<div>' +
'<field name="display_name"/>' +
'</div>' +
'<div>' +
'<field name="trululu"/>' +
'</div>' +
'</div>' +
'</t></templates>' +
'</kanban>',
'partner,false,search': '<search></search>',
},
data: this.data,
model: 'partner',
res_id: 2,
viewOptions: {mode: 'edit'},
});
var $input = form.$('.o_field_many2one input');
assert.doesNotHaveClass($input, 'ui-autocomplete-input',
"autocomplete should not be visible in a mobile environment");
await testUtils.dom.click($input);
var $modal = $('.o_modal_full .modal-lg');
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
assert.containsOnce($modal, '.o_legacy_kanban_view',
'kanban view should be open in SelectCreateDialog');
assert.hasClass($modal.find('.o_legacy_kanban_view'), 'kanban2',
'kanban view with id 2 should be opened as it is given as kanban_view_ref');
assert.strictEqual($modal.find('.o_legacy_kanban_view .o_kanban_record:first').text(),
'first recordaaa',
'kanban with two fields should be opened');
form.destroy();
});
QUnit.test("many2one dialog on mobile: clear button header", async function (assert) {
assert.expect(7);
const form = await createView({
View: FormView,
arch: `
<form>
<sheet>
<field name="trululu"/>
</sheet>
</form>
`,
archs: {
'partner,false,kanban': `
<kanban>
<templates><t t-name="kanban-box">
<div class="oe_kanban_global_click"><field name="display_name"/></div>
</t></templates>
</kanban>
`,
'partner,false,search': '<search></search>',
},
data: this.data,
model: 'partner',
res_id: 2,
viewOptions: {mode: 'edit'},
});
let $input = form.$('.o_field_many2one input');
assert.doesNotHaveClass($input, 'ui-autocomplete-input',
"autocomplete should not be visible in a mobile environment");
await testUtils.dom.click($input);
assert.containsOnce($('body'), '.modal.o_modal_full',
"there should be a modal opened in full screen");
assert.containsN($('.modal'), '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3,
"popup should load 3 records in kanban");
await testUtils.dom.click($('.modal').find('.o_legacy_kanban_view .o_kanban_record:first'));
assert.strictEqual($input.val(), 'first record',
'clicking kanban card should select record for many2one field');
await testUtils.dom.click($input);
// clear button.
assert.containsOnce($('.modal').find('.modal-header'), '.o_clear_button',
"there should be a Clear button in the modal header");
await testUtils.dom.click($('.modal').find('.modal-header .o_clear_button'));
assert.containsNone($('body'), '.modal', "there should be no more modal");
$input = form.$('.o_field_many2one input');
assert.strictEqual($input.val(), "", "many2one should be cleared");
form.destroy();
});
QUnit.module('FieldMany2Many');
QUnit.test("many2many_tags in a mobile environment", async function (assert) {
assert.expect(10);
var rpcReadCount = 0;
var form = await createView({
View: FormView,
arch:
'<form>' +
'<sheet>' +
'<field name="sibling_ids" widget="many2many_tags"/>' +
'</sheet>' +
'</form>',
archs: {
'partner,false,kanban': '<kanban>' +
'<templates><t t-name="kanban-box">' +
'<div class="oe_kanban_global_click"><field name="display_name"/></div>' +
'</t></templates>' +
'</kanban>',
'partner,false,search': '<search></search>',
},
data: this.data,
model: 'partner',
res_id: 2,
viewOptions: {mode: 'edit'},
mockRPC: function (route, args) {
if (args.method === "read" && args.model === "partner") {
if (rpcReadCount === 0) {
assert.deepEqual(args.args[0], [2], "form should initially show partner 2");
} else if (rpcReadCount === 1) {
assert.deepEqual(args.args[0], [1], "partner with id 1 should be selected");
}
rpcReadCount++;
}
return this._super.apply(this, arguments);
},
});
var $input = form.$(".o_field_widget .o_input");
assert.strictEqual($input.find(".badge").length, 0,
"many2many_tags should have no tags");
await testUtils.dom.click($input);
var $modal = $('.o_modal_full .modal-lg');
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
assert.containsOnce($modal, '.o_legacy_kanban_view',
'kanban view should be open in SelectCreateDialog');
assert.containsOnce($modal, '.o_cp_searchview',
'should have Search view inside SelectCreateDialog');
assert.containsNone($modal.find(".o_control_panel .o_cp_buttons"), '.o-kanban-button-new',
"kanban view in SelectCreateDialog should not have Create button");
assert.strictEqual($modal.find(".o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)").length, 3,
"popup should load 3 records in kanban");
await testUtils.dom.click($modal.find('.o_legacy_kanban_view .o_kanban_record:first'));
assert.strictEqual(rpcReadCount, 2, "there should be a read for current form record and selected sibling");
assert.strictEqual(form.$(".o_field_widget.o_input .badge").length, 1,
"many2many_tags should have partner coucou3");
form.destroy();
});
QUnit.module('FieldOne2Many');
QUnit.test('one2many on mobile: remove header button', async function (assert) {
assert.expect(9);
this.data.partner.records[0].p = [1, 2, 4];
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `
<form string="Partners">
<field name="p"/>
</form>
`,
archs: {
'partner,false,form': `
<form string="Partner">
<field name="display_name"/>
</form>
`,
'partner,false,kanban': `
<kanban>
<templates><t t-name="kanban-box">
<div class="oe_kanban_global_click">
<field name="display_name"/>
</div>
</t></templates>
</kanban>
`,
},
res_id: 1,
mockRPC(route, args) {
if (route === '/web/dataset/call_kw/partner/write') {
const commands = args.args[1].p;
assert.strictEqual(commands.length, 3,
'should have generated three commands');
assert.ok(commands[0][0] === 4 && commands[0][1] === 2,
'should have generated the command 4 (LINK_TO) with id 2');
assert.ok(commands[1][0] === 4 && commands[1][1] === 4,
'should have generated the command 2 (LINK_TO) with id 1');
assert.ok(commands[2][0] === 2 && commands[2][1] === 1,
'should have generated the command 2 (DELETE) with id 2');
}
return this._super.apply(this, arguments);
},
});
await testUtils.form.clickEdit(form);
assert.containsN(form, '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3,
"should have 3 records in kanban");
await testUtils.dom.click(form.$('.o_legacy_kanban_view .o_kanban_record:first'));
assert.containsOnce($('body'), '.modal.o_modal_full',
"there should be a modal opened in full screen");
// remove button.
assert.containsOnce($('.modal').find('.modal-header'), '.o_btn_remove',
"there should be a 'Remove' button in the modal header");
await testUtils.dom.click($('.modal').find('.modal-header .o_btn_remove'));
assert.containsNone($('body'), '.modal', "there should be no more modal");
assert.containsN(form, '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2,
"should have 2 records in kanban");
// save and check that the correct command has been generated
await testUtils.form.clickSave(form);
form.destroy();
});
});
});
});

View File

@@ -0,0 +1,73 @@
odoo.define('web_enterprise.BasicRenderMobileTests', function (require) {
"use strict";
const BasicRenderer = require('web.BasicRenderer');
const FormView = require('web.FormView');
const testUtils = require('web.test_utils');
const createView = testUtils.createView;
QUnit.module('web_enterprise > basic > basic_render_mobile', {
beforeEach: function () {
this.data = {
partner: {
fields: {
display_name: {string: "Displayed name", type: "char", help: 'The name displayed'},
},
records: [
{
id: 1,
display_name: "first record",
},
],
},
};
}
}, function () {
QUnit.module('Basic Render Mobile');
QUnit.test(`field tooltip shouldn't remain displayed in mobile`, async function (assert) {
assert.expect(2);
testUtils.mock.patch(BasicRenderer, {
SHOW_AFTER_DELAY: 0,
_getTooltipOptions: function () {
return Object.assign({}, this._super(...arguments), {
animation: false,
});
},
});
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `
<form>
<sheet>
<group>
<field name="display_name"/>
</group>
</sheet>
</form>
`,
});
const label = form.el.querySelector('label.o_form_label');
await testUtils.dom.triggerEvent(label, 'touchstart');
assert.strictEqual(
document.querySelectorAll('.tooltip .oe_tooltip_string').length,
1, "should have a tooltip displayed"
);
await testUtils.dom.triggerEvent(label, 'touchend');
assert.strictEqual(
document.querySelectorAll('.tooltip .oe_tooltip_string').length,
0, "shouldn't have a tooltip displayed"
);
form.destroy();
testUtils.mock.unpatch(BasicRenderer);
});
});
});

View File

@@ -0,0 +1,216 @@
odoo.define('web_enterprise.kanban_mobile_tests', function (require) {
"use strict";
const KanbanView = require('web.KanbanView');
const { createView, dom} = require('web.test_utils');
QUnit.module('LegacyViews', {
beforeEach() {
this.data = {
partner: {
fields: {
foo: {string: "Foo", type: "char"},
bar: {string: "Bar", type: "boolean"},
int_field: {string: "int_field", type: "integer", sortable: true},
qux: {string: "my float", type: "float"},
product_id: {string: "something_id", type: "many2one", relation: "product"},
category_ids: { string: "categories", type: "many2many", relation: 'category'},
state: { string: "State", type: "selection", selection: [["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"]]},
date: {string: "Date Field", type: 'date'},
datetime: {string: "Datetime Field", type: 'datetime'},
},
records: [
{id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: []},
{id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6]},
{id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7]},
{id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: []},
{id: 5, bar: false, foo: "Hello \"World\"! #peace_n'_love", int_field: -9, qux: 10, state: "jkl", category_ids: []},
]
},
product: {
fields: {
id: {string: "ID", type: "integer"},
name: {string: "Display Name", type: "char"},
},
records: [
{id: 3, name: "hello"},
{id: 5, name: "xmo"},
]
},
category: {
fields: {
name: {string: "Category Name", type: "char"},
color: {string: "Color index", type: "integer"},
},
records: [
{id: 6, name: "gold", color: 2},
{id: 7, name: "silver", color: 5},
]
},
};
},
}, function () {
QUnit.module("KanbanView (legacy) - Mobile")
QUnit.test('kanban with searchpanel: rendering in mobile', async function (assert) {
assert.expect(34);
const kanban = await createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: `
<kanban>
<templates><t t-name="kanban-box">
<div>
<field name="foo"/>
</div>
</t></templates>
</kanban>
`,
archs: {
'partner,false,search': `
<search>
<searchpanel>
<field name="product_id" expand="1" enable_counters="1"/>
<field name="state" expand="1" select="multi" enable_counters="1"/>
</searchpanel>
</search>
`,
},
mockRPC(route, {method}) {
if (method && method.includes('search_panel_')) {
assert.step(method);
}
return this._super.apply(this, arguments);
},
});
let $sp = kanban.$(".o_search_panel");
assert.containsOnce(kanban, ".o_search_panel.o_search_panel_summary");
assert.containsNone(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
assert.verifySteps([
"search_panel_select_range",
"search_panel_select_multi_range",
]);
assert.containsOnce($sp, ".fa.fa-filter");
assert.containsOnce($sp, ".o_search_panel_current_selection:contains(All)");
// open the search panel
await dom.click($sp);
$sp = $(".o_search_panel");
assert.containsNone(kanban, ".o_search_panel.o_search_panel_summary");
assert.containsOnce(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
assert.containsOnce($sp, ".o_mobile_search_header > button:contains(FILTER)");
assert.containsOnce($sp, "button.o_mobile_search_footer:contains(SEE RESULT)");
assert.containsN($sp, ".o_search_panel_section", 2);
assert.containsOnce($sp, ".o_search_panel_section.o_search_panel_category");
assert.containsOnce($sp, ".o_search_panel_section.o_search_panel_filter");
assert.containsN($sp, ".o_search_panel_category_value", 3);
assert.containsOnce($sp, ".o_search_panel_category_value > header.active", 3);
assert.containsN($sp, ".o_search_panel_filter_value", 3);
// select category
await dom.click($sp.find(".o_search_panel_category_value:contains(hello) header"));
assert.verifySteps([
"search_panel_select_range",
"search_panel_select_multi_range",
]);
// select filter
await dom.click($sp.find(".o_search_panel_filter_value:contains(DEF) input"));
assert.verifySteps([
"search_panel_select_range",
"search_panel_select_multi_range",
]);
// close with back button
await dom.click($sp.find(".o_mobile_search_header button"));
$sp = $(".o_search_panel");
assert.containsOnce(kanban, ".o_search_panel.o_search_panel_summary");
assert.containsNone(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
// selection is kept when closed
assert.containsOnce($sp, ".o_search_panel_current_selection");
assert.containsOnce($sp, ".o_search_panel_category:contains(hello)");
assert.containsOnce($sp, ".o_search_panel_filter:contains(DEF)");
// open the search panel
await dom.click($sp);
$sp = $(".o_search_panel");
assert.containsOnce($sp, ".o_search_panel_category_value > header.active:contains(hello)");
assert.containsOnce($sp, ".o_search_panel_filter_value:contains(DEF) input:checked");
assert.containsNone(kanban, ".o_search_panel.o_search_panel_summary");
assert.containsOnce(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
// close with bottom button
await dom.click($sp.find("button.o_mobile_search_footer"));
assert.containsOnce(kanban, ".o_search_panel.o_search_panel_summary");
assert.containsNone(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
kanban.destroy();
});
QUnit.module('KanbanView Mobile');
QUnit.test('mobile no quick create column when grouping on non m2o field', async function (assert) {
assert.expect(2);
var kanban = await createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: '<kanban class="o_kanban_test o_kanban_small_column" on_create="quick_create">' +
'<templates><t t-name="kanban-box">' +
'<div><field name="foo"/></div>' +
'<div><field name="int_field"/></div>' +
'</t></templates>' +
'</kanban>',
groupBy: ['int_field'],
});
assert.containsNone(kanban, '.o_kanban_mobile_add_column', "should not have the add column button");
assert.containsNone(kanban.$('.o_column_quick_create'),
"should not have column quick create tab as we grouped records on integer field");
kanban.destroy();
});
QUnit.test("autofocus quick create form", async function (assert) {
assert.expect(2);
const kanban = await createView({
View: KanbanView,
model: "partner",
data: this.data,
arch: `<kanban on_create="quick_create">
<templates>
<t t-name="kanban-box">
<div><field name="foo"/></div>
</t>
</templates>
</kanban>`,
groupBy: ["product_id"],
});
// quick create in first column
await dom.click(kanban.$buttons.find(".o-kanban-button-new"));
assert.ok(kanban.$(".o_kanban_group:nth(0) > div:nth(1)").hasClass("o_kanban_quick_create"),
"clicking on create should open the quick_create in the first column");
assert.strictEqual(document.activeElement, kanban.$(".o_kanban_quick_create .o_input:first")[0],
"the first input field should get the focus when the quick_create is opened");
kanban.destroy();
});
});
});

View File

@@ -0,0 +1,229 @@
odoo.define('web_enterprise.list_mobile_tests', function (require) {
"use strict";
const ListRenderer = require('web.ListRenderer');
const ListView = require('web.ListView');
const testUtils = require('web.test_utils');
const { createView, dom, mock } = testUtils;
QUnit.module("LegacyViews", {
beforeEach() {
this.data = {
foo: {
fields: {
foo: { string: "Foo", type: "char" },
bar: { string: "Bar", type: "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" },
],
},
};
mock.patch(ListRenderer, {
init() {
this._super(...arguments);
this.LONG_TOUCH_THRESHOLD = 0;
}
});
},
afterEach() {
mock.unpatch(ListRenderer);
},
}, function () {
QUnit.module("ListView (legacy) - Mobile");
QUnit.test("selection is properly displayed (single page)", async function (assert) {
assert.expect(10);
const list = await createView({
touchScreen: true,
arch: `
<tree>
<field name="foo"/>
<field name="bar"/>
</tree>`,
data: this.data,
model: 'foo',
viewOptions: { hasActionMenus: true },
View: ListView,
});
assert.containsN(list, '.o_data_row', 4);
assert.containsNone(list, '.o_list_selection_box');
// select a record
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
assert.containsOnce(list, '.o_list_selection_box');
assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
assert.ok(list.$('.o_list_selection_box').text().includes("1 selected"))
// unselect a record
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
// select 2 records
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
await dom.triggerEvent(list.$('.o_data_row:eq(1)'), 'touchstart');
await dom.triggerEvent(list.$('.o_data_row:eq(1)'), 'touchend');
assert.ok(list.$('.o_list_selection_box').text().includes("2 selected"))
assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
await testUtils.controlPanel.toggleActionMenu(list);
assert.deepEqual(testUtils.controlPanel.getMenuItemTexts(list), ['Delete'],
'action menu should contain the Delete action');
// unselect all
await dom.click(list.$('.o_discard_selection'));
await testUtils.nextTick();
assert.containsNone(list, '.o_list_selection_box');
list.destroy();
});
QUnit.test("selection box is properly displayed (multi pages)", async function (assert) {
assert.expect(13);
const list = await createView({
touchScreen: true,
arch: `
<tree limit="3">
<field name="foo"/>
<field name="bar"/>
</tree>`,
data: this.data,
model: 'foo',
View: ListView,
viewOptions: { hasActionMenus: true },
});
assert.containsN(list, '.o_data_row', 3);
assert.containsNone(list, '.o_list_selection_box');
// select a record
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
assert.containsOnce(list, '.o_list_selection_box');
assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
assert.strictEqual(list.$('.o_list_selection_box').text().replace(/\s+/g, ' '),
" × 1 selected ");
assert.containsOnce(list, '.o_list_selection_box');
assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
await testUtils.controlPanel.toggleActionMenu(list);
assert.deepEqual(testUtils.controlPanel.getMenuItemTexts(list), ['Delete'],
'action menu should contain the Delete action');
// select all records of first page
await dom.triggerEvent(list.$('.o_data_row:eq(1)'), 'touchstart');
await dom.triggerEvent(list.$('.o_data_row:eq(1)'), 'touchend');
await dom.triggerEvent(list.$('.o_data_row:eq(2)'), 'touchstart');
await dom.triggerEvent(list.$('.o_data_row:eq(2)'), 'touchend');
assert.containsOnce(list, '.o_list_selection_box');
assert.containsOnce(list.$('.o_list_selection_box'), '.o_list_select_domain');
assert.strictEqual(list.$('.o_list_selection_box').text().replace(/\s+/g, ' ').trim(),
"× 3 selected Select all 4");
// select all domain
await dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
assert.containsOnce(list, '.o_list_selection_box');
assert.strictEqual(list.$('.o_list_selection_box').text().replace(/\s+/g, ' ').trim(),
"× All 4 selected");
list.destroy();
});
QUnit.test("export button is properly hidden", async function (assert) {
assert.expect(2);
const list = await createView({
touchScreen: true,
arch: `
<tree>
<field name="foo"/>
<field name="bar"/>
</tree>`,
data: this.data,
model: 'foo',
View: ListView,
session: {
async user_has_group(group) {
if (group === 'base.group_allow_export') {
return true;
}
return this._super(...arguments);
},
},
});
assert.containsN(list, '.o_data_row', 4);
assert.isNotVisible(list.$buttons.find('.o_list_export_xlsx'));
list.destroy();
});
QUnit.test('editable readonly list view is disabled', async function (assert) {
assert.expect(1);
const list = await createView({
touchScreen: true,
arch: `
<tree>
<field name="foo"/>
</tree>`,
data: this.data,
model: 'foo',
View: ListView,
});
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
assert.containsNone(list, '.o_selected_row .o_field_widget[name=foo]',
"The listview should not contains an edit field");
list.destroy();
});
QUnit.test("add custom field button not shown in mobile (with opt. col.)", async function (assert) {
assert.expect(3);
const list = await testUtils.createView({
arch: `
<tree>
<field name="foo"/>
<field name="bar" optional="hide"/>
</tree>`,
data: this.data,
model: 'foo',
touchScreen: true,
View: ListView,
});
assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle');
await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
const $dropdown = list.$('div.o_optional_columns');
assert.containsOnce($dropdown, 'div.dropdown-item');
assert.containsNone($dropdown, 'button.dropdown-item-studio');
list.destroy();
});
QUnit.test("add custom field button not shown to non-system users (wo opt. col.)", async function (assert) {
assert.expect(1);
const list = await testUtils.createView({
arch: `
<tree>
<field name="foo"/>
<field name="bar"/>
</tree>`,
data: this.data,
model: 'foo',
touchScreen: true,
View: ListView,
});
assert.containsNone(list.$('table'), '.o_optional_columns_dropdown_toggle');
list.destroy();
});
});
});

Some files were not shown because too many files have changed in this diff Show More