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

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