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

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

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>