合并企业版代码(未测试,先提交到测试分支)
This commit is contained in:
BIN
web_enterprise/static/img/default_icon_app.png
Normal file
BIN
web_enterprise/static/img/default_icon_app.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
35
web_enterprise/static/img/home-menu-bg-overlay.svg
Normal file
35
web_enterprise/static/img/home-menu-bg-overlay.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1540" height="1025" viewBox="0 0 1540 1025">
|
||||
<defs>
|
||||
<linearGradient id="a" x1="36.456%" x2="27.554%" y1="61.828%" y2="30.068%">
|
||||
<stop offset="0%" stop-color="#D5D5D5" stop-opacity="0" />
|
||||
<stop offset="100%" stop-color="#F7F7F7" stop-opacity=".192" />
|
||||
</linearGradient>
|
||||
<linearGradient id="b" x1="27.448%" x2="23.156%" y1="61.964%" y2="37.686%">
|
||||
<stop offset="0%" stop-color="#CECECE" stop-opacity="0" />
|
||||
<stop offset="100%" stop-color="#E4E4E4" stop-opacity=".125" />
|
||||
</linearGradient>
|
||||
<linearGradient id="c" x1="8.322%" x2="3.065%" y1="63.424%" y2="52.838%">
|
||||
<stop offset="0%" stop-color="#DCDCDC" stop-opacity=".126" />
|
||||
<stop offset="100%" stop-color="#E6E6E6" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="d" x1="21.074%" x2="23.138%" y1="65.575%" y2="56.383%">
|
||||
<stop offset="0%" stop-color="#FFF" stop-opacity=".05" />
|
||||
<stop offset="100%" stop-color="#FFF" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="strokeLarge" x1="50%" x2="50%" y1="0%" y2="100%">
|
||||
<stop offset="28%" stop-color="#FFF" stop-opacity="0" />
|
||||
<stop offset="35%" stop-color="#FFF" stop-opacity=".2" />
|
||||
<stop offset="100%" stop-color="#FFF" stop-opacity=".2" />
|
||||
</linearGradient>
|
||||
<linearGradient id="stroke" x1="50%" x2="50%" y1="0%" y2="100%">
|
||||
<stop offset="70%" stop-color="#FFF" stop-opacity="0" />
|
||||
<stop offset="72%" stop-color="#FFF" stop-opacity=".1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="none" fill-rule="evenodd" transform="translate(-145 -1542)">
|
||||
<ellipse cx="1213" cy="2184" fill="url(#a)" rx="1212" ry="1573" />
|
||||
<ellipse cx="1440" cy="2297" fill="url(#b)" stroke="url(#strokeLarge)" rx="1313" ry="1473" />
|
||||
<ellipse cx="2477" cy="1573" fill="url(#c)" stroke="url(#stroke)" rx="2158" ry="1573" />
|
||||
<ellipse cx="2336" cy="1667" fill="url(#d)" stroke="url(#stroke)" rx="1807" ry="1103"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
12
web_enterprise/static/src/core/notebook/notebook.scss
Normal file
12
web_enterprise/static/src/core/notebook/notebook.scss
Normal 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};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
$o-notification-shadow: 0 12px 14px -10px rgba(0, 0, 0, .25) !default;
|
||||
11
web_enterprise/static/src/legacy/js/apps.js
Normal file
11
web_enterprise/static/src/legacy/js/apps.js
Normal 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() {},
|
||||
});
|
||||
|
||||
});
|
||||
145
web_enterprise/static/src/legacy/js/control_panel.js
Normal file
145
web_enterprise/static/src/legacy/js/control_panel.js
Normal 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',
|
||||
});
|
||||
});
|
||||
75
web_enterprise/static/src/legacy/js/core/dialog.js
Normal file
75
web_enterprise/static/src/legacy/js/core/dialog.js
Normal 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);
|
||||
},
|
||||
});
|
||||
});
|
||||
104
web_enterprise/static/src/legacy/js/search_panel_mobile.js
Normal file
104
web_enterprise/static/src/legacy/js/search_panel_mobile.js
Normal 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",
|
||||
});
|
||||
});
|
||||
121
web_enterprise/static/src/legacy/js/views/barcode_fields.js
Normal file
121
web_enterprise/static/src/legacy/js/views/barcode_fields.js
Normal 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;
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
84
web_enterprise/static/src/legacy/js/views/form_renderer.js
Normal file
84
web_enterprise/static/src/legacy/js/views/form_renderer.js
Normal 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));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
40
web_enterprise/static/src/legacy/js/views/form_view.js
Normal file
40
web_enterprise/static/src/legacy/js/views/form_view.js
Normal 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;
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
},
|
||||
|
||||
});
|
||||
});
|
||||
125
web_enterprise/static/src/legacy/js/views/relational_fields.js
Normal file
125
web_enterprise/static/src/legacy/js/views/relational_fields.js
Normal 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;
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
67
web_enterprise/static/src/legacy/js/views/view_dialogs.js
Normal file
67
web_enterprise/static/src/legacy/js/views/view_dialogs.js
Normal 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'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
});
|
||||
15
web_enterprise/static/src/legacy/legacy_service_provider.js
Normal file
15
web_enterprise/static/src/legacy/legacy_service_provider.js
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
web_enterprise/static/src/legacy/scss/dropdown.scss
Normal file
36
web_enterprise/static/src/legacy/scss/dropdown.scss
Normal 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;
|
||||
}
|
||||
85
web_enterprise/static/src/legacy/scss/fields.scss
Normal file
85
web_enterprise/static/src/legacy/scss/fields.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
361
web_enterprise/static/src/legacy/scss/form_view.scss
Normal file
361
web_enterprise/static/src/legacy/scss/form_view.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
87
web_enterprise/static/src/legacy/scss/form_view_mobile.scss
Normal file
87
web_enterprise/static/src/legacy/scss/form_view_mobile.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
11
web_enterprise/static/src/legacy/scss/kanban_view.scss
Normal file
11
web_enterprise/static/src/legacy/scss/kanban_view.scss
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
209
web_enterprise/static/src/legacy/scss/list_view.scss
Normal file
209
web_enterprise/static/src/legacy/scss/list_view.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
web_enterprise/static/src/legacy/scss/modal_mobile.scss
Normal file
61
web_enterprise/static/src/legacy/scss/modal_mobile.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
web_enterprise/static/src/legacy/scss/promote_studio.scss
Normal file
29
web_enterprise/static/src/legacy/scss/promote_studio.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
web_enterprise/static/src/legacy/scss/touch_device.scss
Normal file
6
web_enterprise/static/src/legacy/scss/touch_device.scss
Normal 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;
|
||||
}
|
||||
17
web_enterprise/static/src/legacy/xml/barcode_fields.xml
Normal file
17
web_enterprise/static/src/legacy/xml/barcode_fields.xml
Normal 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>
|
||||
41
web_enterprise/static/src/legacy/xml/base_mobile.xml
Normal file
41
web_enterprise/static/src/legacy/xml/base_mobile.xml
Normal 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">×</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>
|
||||
125
web_enterprise/static/src/legacy/xml/control_panel.xml
Normal file
125
web_enterprise/static/src/legacy/xml/control_panel.xml
Normal 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>
|
||||
20
web_enterprise/static/src/legacy/xml/promote_studio.xml
Normal file
20
web_enterprise/static/src/legacy/xml/promote_studio.xml
Normal 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>
|
||||
63
web_enterprise/static/src/legacy/xml/search_panel.xml
Normal file
63
web_enterprise/static/src/legacy/xml/search_panel.xml
Normal 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>
|
||||
38
web_enterprise/static/src/legacy/xml/web_calendar.xml
Normal file
38
web_enterprise/static/src/legacy/xml/web_calendar.xml
Normal 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>
|
||||
12
web_enterprise/static/src/main.js
Normal file
12
web_enterprise/static/src/main.js
Normal 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);
|
||||
160
web_enterprise/static/src/scss/bootstrap_overridden.dark.scss
vendored
Normal file
160
web_enterprise/static/src/scss/bootstrap_overridden.dark.scss
vendored
Normal 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;
|
||||
144
web_enterprise/static/src/scss/bootstrap_overridden.scss
vendored
Normal file
144
web_enterprise/static/src/scss/bootstrap_overridden.scss
vendored
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
84
web_enterprise/static/src/scss/primary_variables.dark.scss
Normal file
84
web_enterprise/static/src/scss/primary_variables.dark.scss
Normal 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;
|
||||
43
web_enterprise/static/src/scss/primary_variables.scss
Normal file
43
web_enterprise/static/src/scss/primary_variables.scss
Normal 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;
|
||||
26
web_enterprise/static/src/scss/secondary_variables.dark.scss
Normal file
26
web_enterprise/static/src/scss/secondary_variables.dark.scss
Normal 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;
|
||||
11
web_enterprise/static/src/scss/secondary_variables.scss
Normal file
11
web_enterprise/static/src/scss/secondary_variables.scss
Normal 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
// = Search Bar
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_searchview_facet {
|
||||
--SearchBar-facet-background: #{$o-black};
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// = Dashboard View
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_dashboard_view {
|
||||
--DashboardView-background-color: #{$o-gray-100};
|
||||
--DashboardView__controlPanel-background-color: transparent;
|
||||
--DashboardView__pieChart-background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// = Image Field
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_field_image {
|
||||
--ImageField-background-color: #{$o-gray-900};
|
||||
}
|
||||
15
web_enterprise/static/src/views/gantt/web_gantt.dark.scss
Normal file
15
web_enterprise/static/src/views/gantt/web_gantt.dark.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// = Gantt View Variables
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
$gantt-highlight-today-border: rgba($o-warning, 0.5) !default;
|
||||
$gantt-highlight-today-bg: rgba($o-warning, 0.15)!default;
|
||||
$gantt-highlight-hover-row: rgba($o-brand-primary, .1) !default;
|
||||
$gantt-row-open-bg: $o-gray-100 !default;
|
||||
$gantt-unavailability-bg: $o-gray-200 !default;
|
||||
@@ -0,0 +1,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};
|
||||
}
|
||||
14
web_enterprise/static/src/views/kanban/kanban_view.scss
Normal file
14
web_enterprise/static/src/views/kanban/kanban_view.scss
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
29
web_enterprise/static/src/views/pivot/pivot_renderer.js
Normal file
29
web_enterprise/static/src/views/pivot/pivot_renderer.js
Normal 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);
|
||||
},
|
||||
});
|
||||
27
web_enterprise/static/src/views/pivot/pivot_renderer.scss
Normal file
27
web_enterprise/static/src/views/pivot/pivot_renderer.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
web_enterprise/static/src/views/pivot/pivot_renderer.xml
Normal file
10
web_enterprise/static/src/views/pivot/pivot_renderer.xml
Normal 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>
|
||||
@@ -0,0 +1,21 @@
|
||||
/** @odoo-module **/
|
||||
import { BurgerMenu } from "@web/webclient/burger_menu/burger_menu";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class EnterpriseBurgerMenu extends BurgerMenu {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.hm = useService("home_menu");
|
||||
}
|
||||
|
||||
get currentApp() {
|
||||
return !this.hm.hasHomeMenu && super.currentApp;
|
||||
}
|
||||
}
|
||||
|
||||
const systrayItem = {
|
||||
Component: EnterpriseBurgerMenu,
|
||||
};
|
||||
|
||||
registry.category("systray").add("burger_menu", systrayItem, { sequence: 0, force: true });
|
||||
@@ -0,0 +1,6 @@
|
||||
// = Burger Menu Variables
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
$o-burger-topbar-bg: $o-gray-100 !default;
|
||||
$o-burger-topbar-color: $o-gray-900 !default;
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
@@ -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')};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// = Domain Selector
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_domain_debug_container {
|
||||
--DomainDebugContainer-background-colomap-get: #{$o-gray-100};
|
||||
}
|
||||
@@ -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 <= 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);
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,8 @@
|
||||
.database_expiration_panel .oe_instance_register_form {
|
||||
max-height: 0;
|
||||
transition: max-height 0.4s;
|
||||
|
||||
&.o-vertical-slide-enter-active {
|
||||
max-height: 10rem; // fixed value is required to properly trigger transition
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="DatabaseExpirationPanel" owl="1">
|
||||
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
310
web_enterprise/static/src/webclient/home_menu/home_menu.js
Normal file
310
web_enterprise/static/src/webclient/home_menu/home_menu.js
Normal 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";
|
||||
114
web_enterprise/static/src/webclient/home_menu/home_menu.scss
Normal file
114
web_enterprise/static/src/webclient/home_menu/home_menu.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
46
web_enterprise/static/src/webclient/home_menu/home_menu.xml
Normal file
46
web_enterprise/static/src/webclient/home_menu/home_menu.xml
Normal 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 <= 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+'&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>
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
47
web_enterprise/static/src/webclient/navbar/navbar.js
Normal file
47
web_enterprise/static/src/webclient/navbar/navbar.js
Normal 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";
|
||||
63
web_enterprise/static/src/webclient/navbar/navbar.scss
Normal file
63
web_enterprise/static/src/webclient/navbar/navbar.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
19
web_enterprise/static/src/webclient/navbar/navbar.xml
Normal file
19
web_enterprise/static/src/webclient/navbar/navbar.xml
Normal 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>
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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};
|
||||
}
|
||||
17
web_enterprise/static/src/webclient/webclient.js
Normal file
17
web_enterprise/static/src/webclient/webclient.js
Normal 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 };
|
||||
12
web_enterprise/static/tests/helpers.js
Normal file
12
web_enterprise/static/tests/helpers.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { createWebClient } from "@web/../tests/webclient/helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { legacyServiceProvider } from "@web_enterprise/legacy/legacy_service_provider";
|
||||
import { WebClientEnterprise } from "@web_enterprise/webclient/webclient";
|
||||
|
||||
export function createEnterpriseWebClient(params) {
|
||||
params.WebClientClass = WebClientEnterprise;
|
||||
registry.category("services").add("enterprise_legacy_service_provider", legacyServiceProvider);
|
||||
return createWebClient(params);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { createWebClient, doAction, getActionManagerServerData, loadState } from "@web/../tests/webclient/helpers";
|
||||
import { click, getFixture, legacyExtraNextTick } from "@web/../tests/helpers/utils";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module('ActionManager', {
|
||||
beforeEach() {
|
||||
serverData = getActionManagerServerData();
|
||||
target = getFixture();
|
||||
Object.assign(serverData, {
|
||||
actions: {
|
||||
1: {
|
||||
id: 1,
|
||||
name: 'Partners Action 1',
|
||||
res_model: 'partner',
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'list'], [false, 'kanban'], [false, 'form']],
|
||||
},
|
||||
2: {
|
||||
id: 2,
|
||||
name: 'Partners Action 2',
|
||||
res_model: 'partner',
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'list'], [false, 'form']],
|
||||
},
|
||||
},
|
||||
views: {
|
||||
'partner,false,kanban': `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click">
|
||||
<field name="foo"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
'partner,false,list': '<tree><field name="foo"/></tree>',
|
||||
'partner,false,form':
|
||||
`<form>
|
||||
<group>
|
||||
<field name="display_name"/>
|
||||
</group>
|
||||
</form>`,
|
||||
'partner,false,search': '<search><field name="foo" string="Foo"/></search>',
|
||||
},
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, display_name: "First record", foo: "yop" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test('uses a mobile-friendly view by default (if possible)', async function (assert) {
|
||||
const webClient = await createWebClient({ serverData });
|
||||
// should default on a mobile-friendly view (kanban) for action 1
|
||||
await doAction(webClient, 1);
|
||||
|
||||
assert.containsNone(target, '.o_list_view');
|
||||
assert.containsOnce(target, '.o_kanban_view');
|
||||
|
||||
// there is no mobile-friendly view for action 2, should use the first one (list)
|
||||
await doAction(webClient, 2);
|
||||
|
||||
assert.containsOnce(target, '.o_list_view');
|
||||
assert.containsNone(target, '.o_kanban_view');
|
||||
});
|
||||
|
||||
QUnit.test('lazy load mobile-friendly view', async function (assert) {
|
||||
const mockRPC = (route, args) => {
|
||||
assert.step(args.method || route);
|
||||
};
|
||||
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
|
||||
await loadState(webClient, {
|
||||
action: 1,
|
||||
view_type: 'form',
|
||||
});
|
||||
|
||||
assert.containsNone(target, '.o_list_view');
|
||||
assert.containsNone(target, '.o_kanban_view');
|
||||
assert.containsOnce(target, '.o_form_view');
|
||||
|
||||
// go back to lazy loaded view
|
||||
await click(target, '.o_control_panel .breadcrumb .o_back_button');
|
||||
await legacyExtraNextTick();
|
||||
assert.containsNone(target, '.o_form_view');
|
||||
assert.containsNone(target, '.o_list_view');
|
||||
assert.containsOnce(target, '.o_kanban_view');
|
||||
|
||||
assert.verifySteps([
|
||||
'/web/webclient/load_menus',
|
||||
'/web/action/load',
|
||||
'get_views',
|
||||
'onchange', // default_get/onchange to open form view
|
||||
'web_search_read', // web search read when coming back to Kanban
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('view switcher button should be displayed in dropdown on mobile screens', async function (assert) {
|
||||
// This test will spawn a kanban view (mobile friendly).
|
||||
// so, the "legacy" code won't be tested here.
|
||||
const webClient = await createWebClient({ serverData });
|
||||
|
||||
await doAction(webClient, 1);
|
||||
|
||||
assert.containsOnce(target.querySelector('.o_control_panel'), '.o_cp_switch_buttons > .o-dropdown > button');
|
||||
assert.containsNone(target.querySelector('.o_control_panel'), '.o_cp_switch_buttons .o-dropdown--menu .o_switch_view.o_kanban');
|
||||
assert.containsNone(target.querySelector('.o_control_panel'), '.o_cp_switch_buttons .o-dropdown--menu button.o_switch_view');
|
||||
|
||||
assert.hasClass(target.querySelector('.o_control_panel .o_cp_switch_buttons > .o-dropdown > button > i'), 'oi-view-kanban');
|
||||
await click(target, '.o_control_panel .o_cp_switch_buttons > .o-dropdown > button');
|
||||
|
||||
assert.hasClass(target.querySelector('.o_cp_switch_buttons .o-dropdown--menu button.o_switch_view.o_kanban'), 'active');
|
||||
assert.doesNotHaveClass(target.querySelector('.o_cp_switch_buttons .o-dropdown--menu button.o_switch_view.o_list'), 'active');
|
||||
assert.hasClass(target.querySelector('.o_cp_switch_buttons .o-dropdown--menu button.o_switch_view.o_kanban'), 'oi-view-kanban');
|
||||
});
|
||||
160
web_enterprise/static/tests/legacy/barcodes_mobile_tests.js
Normal file
160
web_enterprise/static/tests/legacy/barcodes_mobile_tests.js
Normal file
@@ -0,0 +1,160 @@
|
||||
odoo.define('web_mobile.barcode.tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const fieldRegistry = require('web.field_registry');
|
||||
const FormView = require('web.FormView');
|
||||
const { FieldMany2One } = require('web.relational_fields');
|
||||
const { createView, dom, mock } = require('web.test_utils');
|
||||
|
||||
const FieldMany2OneBarcode = require('web_mobile.barcode_fields');
|
||||
const BarcodeScanner = require('@web/webclient/barcode/barcode_scanner');
|
||||
|
||||
const NAME_SEARCH = "name_search";
|
||||
const PRODUCT_PRODUCT = 'product.product';
|
||||
const SALE_ORDER_LINE = 'sale_order_line';
|
||||
const PRODUCT_FIELD_NAME = 'product_id';
|
||||
const ARCHS = {
|
||||
'product.product,false,kanban': `
|
||||
<kanban><templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click"><field name="display_name"/></div>
|
||||
</t>
|
||||
</templates></kanban>`,
|
||||
'product.product,false,search': '<search></search>',
|
||||
};
|
||||
|
||||
async function mockRPC(route, args) {
|
||||
const result = await this._super(...arguments);
|
||||
if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
|
||||
const records = this.data[PRODUCT_PRODUCT].records
|
||||
.filter((record) => record.barcode === args.kwargs.name)
|
||||
.map((record) => [record.id, record.name]);
|
||||
return records.concat(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QUnit.module('web_mobile', {
|
||||
beforeEach() {
|
||||
this.data = {
|
||||
[PRODUCT_PRODUCT]: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
name: {},
|
||||
barcode: {},
|
||||
},
|
||||
records: [{
|
||||
id: 111,
|
||||
name: 'product_cable_management_box',
|
||||
barcode: '601647855631',
|
||||
}, {
|
||||
id: 112,
|
||||
name: 'product_n95_mask',
|
||||
barcode: '601647855632',
|
||||
}, {
|
||||
id: 113,
|
||||
name: 'product_surgical_mask',
|
||||
barcode: '601647855633',
|
||||
}],
|
||||
},
|
||||
[SALE_ORDER_LINE]: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
[PRODUCT_FIELD_NAME]: {
|
||||
string: PRODUCT_FIELD_NAME,
|
||||
type: 'many2one',
|
||||
relation: PRODUCT_PRODUCT
|
||||
},
|
||||
product_uom_qty: {type: 'integer'}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
QUnit.test("web_mobile: barcode button in a mobile environment with single results", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
// simulate a mobile environment
|
||||
fieldRegistry.add('many2one_barcode', FieldMany2OneBarcode);
|
||||
mock.patch(BarcodeScanner, {
|
||||
isBarcodeScannerSupported: () => true,
|
||||
scanBarcode: async () => this.data[PRODUCT_PRODUCT].records[0].barcode,
|
||||
});
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
|
||||
</sheet>
|
||||
</form>`,
|
||||
data: this.data,
|
||||
model: SALE_ORDER_LINE,
|
||||
archs: ARCHS,
|
||||
mockRPC,
|
||||
});
|
||||
|
||||
const $scanButton = form.$('.o_barcode');
|
||||
|
||||
assert.equal($scanButton.length, 1, "has scanner button");
|
||||
|
||||
await dom.click($scanButton);
|
||||
|
||||
const selectedId = form.renderer.state.data[PRODUCT_FIELD_NAME].res_id;
|
||||
assert.equal(selectedId, this.data[PRODUCT_PRODUCT].records[0].id,
|
||||
`product found and selected (${this.data[PRODUCT_PRODUCT].records[0].barcode})`);
|
||||
|
||||
form.destroy();
|
||||
fieldRegistry.add('many2one_barcode', FieldMany2One);
|
||||
mock.unpatch(BarcodeScanner);
|
||||
});
|
||||
|
||||
QUnit.test("web_mobile: barcode button in a mobile environment with multiple results", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
// simulate a mobile environment
|
||||
fieldRegistry.add('many2one_barcode', FieldMany2OneBarcode);
|
||||
mock.patch(BarcodeScanner, {
|
||||
isBarcodeScannerSupported: () => true,
|
||||
scanBarcode: async () => "mask",
|
||||
});
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
|
||||
</sheet>
|
||||
</form>`,
|
||||
data: this.data,
|
||||
model: SALE_ORDER_LINE,
|
||||
archs: ARCHS,
|
||||
mockRPC,
|
||||
});
|
||||
|
||||
const $scanButton = form.$('.o_barcode');
|
||||
|
||||
assert.equal($scanButton.length, 1, "has scanner button");
|
||||
|
||||
await dom.click($scanButton);
|
||||
|
||||
const $modal = $('.o_modal_full .modal-lg');
|
||||
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
|
||||
|
||||
assert.equal($modal.find('.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 2,
|
||||
'there should be 2 records displayed');
|
||||
|
||||
await dom.click($modal.find('.o_legacy_kanban_view .o_kanban_record:first'));
|
||||
|
||||
const selectedId = form.renderer.state.data[PRODUCT_FIELD_NAME].res_id;
|
||||
assert.equal(selectedId, this.data[PRODUCT_PRODUCT].records[1].id,
|
||||
`product found and selected (${this.data[PRODUCT_PRODUCT].records[1].barcode})`);
|
||||
|
||||
form.destroy();
|
||||
fieldRegistry.add('many2one_barcode', FieldMany2One);
|
||||
mock.unpatch(BarcodeScanner);
|
||||
});
|
||||
});
|
||||
});
|
||||
155
web_enterprise/static/tests/legacy/barcodes_tests.js
Normal file
155
web_enterprise/static/tests/legacy/barcodes_tests.js
Normal file
@@ -0,0 +1,155 @@
|
||||
odoo.define('web_mobile.barcode.tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const fieldRegistry = require('web.field_registry');
|
||||
const FormView = require('web.FormView');
|
||||
const { FieldMany2One } = require('web.relational_fields');
|
||||
const { createView, dom, mock } = require('web.test_utils');
|
||||
|
||||
const FieldMany2OneBarcode = require('web_mobile.barcode_fields');
|
||||
const BarcodeScanner = require('@web/webclient/barcode/barcode_scanner');
|
||||
|
||||
const NAME_SEARCH = "name_search";
|
||||
const PRODUCT_PRODUCT = 'product.product';
|
||||
const SALE_ORDER_LINE = 'sale_order_line';
|
||||
const PRODUCT_FIELD_NAME = 'product_id';
|
||||
const ARCHS = {
|
||||
'product.product,false,list': `<tree>
|
||||
<field name="display_name"/>
|
||||
</tree>`,
|
||||
'product.product,false,search': '<search></search>',
|
||||
};
|
||||
|
||||
async function mockRPC(route, args) {
|
||||
const result = await this._super(...arguments);
|
||||
if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
|
||||
const records = this.data[PRODUCT_PRODUCT].records
|
||||
.filter((record) => record.barcode === args.kwargs.name)
|
||||
.map((record) => [record.id, record.name]);
|
||||
return records.concat(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QUnit.module('web_mobile', {
|
||||
beforeEach() {
|
||||
this.data = {
|
||||
[PRODUCT_PRODUCT]: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
name: {},
|
||||
barcode: {},
|
||||
},
|
||||
records: [{
|
||||
id: 111,
|
||||
name: 'product_cable_management_box',
|
||||
barcode: '601647855631',
|
||||
}, {
|
||||
id: 112,
|
||||
name: 'product_n95_mask',
|
||||
barcode: '601647855632',
|
||||
}, {
|
||||
id: 113,
|
||||
name: 'product_surgical_mask',
|
||||
barcode: '601647855633',
|
||||
}],
|
||||
},
|
||||
[SALE_ORDER_LINE]: {
|
||||
fields: {
|
||||
id: {type: 'integer'},
|
||||
[PRODUCT_FIELD_NAME]: {
|
||||
string: PRODUCT_FIELD_NAME,
|
||||
type: 'many2one',
|
||||
relation: PRODUCT_PRODUCT
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.test("web_mobile: barcode button in a mobile environment with single results", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
// simulate a mobile environment
|
||||
fieldRegistry.add('many2one_barcode', FieldMany2OneBarcode);
|
||||
mock.patch(BarcodeScanner, {
|
||||
isBarcodeScannerSupported: () => true,
|
||||
scanBarcode: async () => this.data[PRODUCT_PRODUCT].records[0].barcode,
|
||||
});
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
|
||||
</sheet>
|
||||
</form>`,
|
||||
data: this.data,
|
||||
model: SALE_ORDER_LINE,
|
||||
archs: ARCHS,
|
||||
mockRPC,
|
||||
});
|
||||
|
||||
const $scanButton = form.$('.o_barcode');
|
||||
|
||||
assert.containsOnce(form, $scanButton, "has scanner button");
|
||||
|
||||
await dom.click($scanButton);
|
||||
|
||||
const selectedId = form.renderer.state.data[PRODUCT_FIELD_NAME].res_id;
|
||||
assert.equal(selectedId, this.data[PRODUCT_PRODUCT].records[0].id,
|
||||
`product found and selected (${this.data[PRODUCT_PRODUCT].records[0].barcode})`);
|
||||
|
||||
form.destroy();
|
||||
fieldRegistry.add('many2one_barcode', FieldMany2One);
|
||||
mock.unpatch(BarcodeScanner);
|
||||
});
|
||||
|
||||
QUnit.test("web_mobile: barcode button in a mobile environment with multiple results", async function (assert) {
|
||||
// simulate a mobile environment
|
||||
fieldRegistry.add('many2one_barcode', FieldMany2OneBarcode);
|
||||
mock.patch(BarcodeScanner, {
|
||||
isBarcodeScannerSupported: () => true,
|
||||
scanBarcode: async () => "mask"
|
||||
});
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
|
||||
</sheet>
|
||||
</form>`,
|
||||
data: this.data,
|
||||
model: SALE_ORDER_LINE,
|
||||
archs: ARCHS,
|
||||
mockRPC,
|
||||
});
|
||||
|
||||
const $scanButton = form.$('.o_barcode');
|
||||
|
||||
assert.containsOnce(form, $scanButton, "has scanner button");
|
||||
|
||||
await dom.click($scanButton);
|
||||
|
||||
const $modal = $('.modal-dialog.modal-lg');
|
||||
assert.containsOnce($('body'), $modal, 'there should be one modal opened in full screen');
|
||||
|
||||
assert.containsN($modal, '.o_legacy_list_view .o_data_row', 2,
|
||||
'there should be 2 records displayed');
|
||||
|
||||
await dom.click($modal.find('.o_legacy_list_view .o_data_row:first'));
|
||||
|
||||
const selectedId = form.renderer.state.data[PRODUCT_FIELD_NAME].res_id;
|
||||
assert.equal(selectedId, this.data[PRODUCT_PRODUCT].records[1].id,
|
||||
`product found and selected (${this.data[PRODUCT_PRODUCT].records[1].barcode})`);
|
||||
|
||||
form.destroy();
|
||||
fieldRegistry.add('many2one_barcode', FieldMany2One);
|
||||
mock.unpatch(BarcodeScanner);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
odoo.define('web.action_menus_mobile_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const ActionMenus = require('web.ActionMenus');
|
||||
const Registry = require('web.Registry');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
const { createComponent } = testUtils;
|
||||
|
||||
QUnit.module('Components', {
|
||||
beforeEach() {
|
||||
this.action = {
|
||||
res_model: 'hobbit',
|
||||
};
|
||||
this.view = {
|
||||
type: 'form',
|
||||
};
|
||||
this.props = {
|
||||
activeIds: [23],
|
||||
context: {},
|
||||
items: {
|
||||
action: [
|
||||
{ action: { id: 1 }, name: "What's taters, precious ?", id: 1 },
|
||||
],
|
||||
print: [
|
||||
{ action: { id: 2 }, name: "Po-ta-toes", id: 2 },
|
||||
],
|
||||
},
|
||||
};
|
||||
// Patch the registry of the action menus
|
||||
this.actionMenusRegistry = ActionMenus.registry;
|
||||
ActionMenus.registry = new Registry();
|
||||
},
|
||||
afterEach() {
|
||||
ActionMenus.registry = this.actionMenusRegistry;
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.module('ActionMenus');
|
||||
|
||||
QUnit.test('Auto close the print dropdown after click inside an item', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const actionMenus = await createComponent(ActionMenus, {
|
||||
env: {
|
||||
device: {
|
||||
isMobile: true
|
||||
},
|
||||
action: this.action,
|
||||
view: this.view,
|
||||
},
|
||||
intercepts: {
|
||||
'do-action': ev => assert.step('do-action'),
|
||||
},
|
||||
props: this.props,
|
||||
async mockRPC(route, args) {
|
||||
switch (route) {
|
||||
case '/web/action/load':
|
||||
const expectedContext = {
|
||||
active_id: 23,
|
||||
active_ids: [23],
|
||||
active_model: 'hobbit',
|
||||
};
|
||||
assert.deepEqual(args.context, expectedContext);
|
||||
assert.step('load-action');
|
||||
return {context: {}, flags: {}};
|
||||
default:
|
||||
return this._super(...arguments);
|
||||
|
||||
}
|
||||
},
|
||||
});
|
||||
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Print");
|
||||
assert.containsOnce(actionMenus.el, '.dropdown-menu-start',
|
||||
"should display the dropdown menu");
|
||||
await testUtils.controlPanel.toggleMenuItem(actionMenus, "Po-ta-toes");
|
||||
assert.containsNone(actionMenus.el, '.dropdown-menu-start',
|
||||
"should not display the dropdown menu");
|
||||
assert.verifySteps(['load-action', 'do-action']);
|
||||
});
|
||||
});
|
||||
});
|
||||
245
web_enterprise/static/tests/legacy/control_panel_mobile_tests.js
Normal file
245
web_enterprise/static/tests/legacy/control_panel_mobile_tests.js
Normal file
@@ -0,0 +1,245 @@
|
||||
odoo.define('web.control_panel_mobile_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const FormView = require('web.FormView');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
const cpHelpers = require('@web/../tests/search/helpers');
|
||||
const { browser } = require("@web/core/browser/browser");
|
||||
const { patchWithCleanup, getFixture } = require("@web/../tests/helpers/utils");
|
||||
const { createControlPanel, createView } = testUtils;
|
||||
|
||||
const { createWebClient, doAction, getActionManagerServerData } = require('@web/../tests/webclient/helpers');
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module('Control Panel', {
|
||||
beforeEach: function () {
|
||||
target = getFixture();
|
||||
this.actions = [{
|
||||
id: 1,
|
||||
name: "Yes",
|
||||
res_model: 'partner',
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'list']],
|
||||
}];
|
||||
this.archs = {
|
||||
'partner,false,list': '<tree><field name="foo"/></tree>',
|
||||
'partner,false,search': `
|
||||
<search>
|
||||
<filter string="Active" name="my_projects" domain="[('boolean_field', '=', True)]"/>
|
||||
<field name="foo" string="Foo"/>
|
||||
</search>`,
|
||||
};
|
||||
this.data = {
|
||||
partner: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char" },
|
||||
boolean_field: { string: "I am a boolean", type: "boolean" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, display_name: "First record", foo: "yop" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const actions = {};
|
||||
this.actions.forEach((act) => {
|
||||
actions[act.xml_id || act.id] = act;
|
||||
});
|
||||
serverData = getActionManagerServerData();
|
||||
Object.assign(serverData, { models: this.data, views: this.archs, actions });
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (fn) => fn(),
|
||||
clearTimeout: () => {},
|
||||
});
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.test('basic rendering', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const webClient = await createWebClient({serverData});
|
||||
|
||||
await doAction(webClient, 1);
|
||||
|
||||
assert.containsNone(target, '.o_control_panel .o_mobile_search',
|
||||
"search options are hidden by default");
|
||||
assert.containsOnce(target, '.o_control_panel .o_enable_searchview',
|
||||
"should display a button to toggle the searchview");
|
||||
});
|
||||
|
||||
QUnit.test("control panel appears at top on scroll event", async function (assert) {
|
||||
assert.expect(12);
|
||||
|
||||
const MAX_HEIGHT = 800;
|
||||
const MIDDLE_HEIGHT = 400;
|
||||
const DELTA_TEST = 20;
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
arch:
|
||||
'<form>' +
|
||||
'<sheet>' +
|
||||
`<div style="height: ${2 * MAX_HEIGHT}px"></div>` +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
data: this.data,
|
||||
model: 'partner',
|
||||
res_id: 1,
|
||||
});
|
||||
|
||||
const controlPanelEl = document.querySelector('.o_control_panel');
|
||||
const controlPanelHeight = controlPanelEl.offsetHeight;
|
||||
|
||||
// Force container to have a scrollbar
|
||||
controlPanelEl.parentElement.style.maxHeight = `${MAX_HEIGHT}px`;
|
||||
|
||||
const scrollAndAssert = async (targetHeight, expectedTopValue, hasStickyClass) => {
|
||||
if (targetHeight !== null) {
|
||||
controlPanelEl.parentElement.scrollTo(0, targetHeight);
|
||||
await testUtils.nextTick();
|
||||
}
|
||||
const expectedPixelValue = `${expectedTopValue}px`;
|
||||
assert.strictEqual(controlPanelEl.style.top, expectedPixelValue,
|
||||
`Top must be ${expectedPixelValue} (after scroll to ${targetHeight})`);
|
||||
|
||||
if (hasStickyClass) {
|
||||
assert.hasClass(controlPanelEl, 'o_mobile_sticky');
|
||||
} else {
|
||||
assert.doesNotHaveClass(controlPanelEl, 'o_mobile_sticky');
|
||||
}
|
||||
}
|
||||
|
||||
// Initial position (scrollTop: 0)
|
||||
await scrollAndAssert(null, 0, false);
|
||||
|
||||
// Scroll down 800px (scrollTop: 800)
|
||||
await scrollAndAssert(MAX_HEIGHT, -controlPanelHeight, true);
|
||||
|
||||
// Scoll up 20px (scrollTop: 780)
|
||||
await scrollAndAssert(MAX_HEIGHT - DELTA_TEST, -controlPanelHeight + DELTA_TEST, true);
|
||||
|
||||
// Scroll up 380px (scrollTop: 400)
|
||||
await scrollAndAssert(MIDDLE_HEIGHT, 0, true);
|
||||
|
||||
// Scroll down 200px (scrollTop: 800)
|
||||
await scrollAndAssert(MAX_HEIGHT, -controlPanelHeight, true);
|
||||
|
||||
// Scroll up 400px (scrollTop: 0)
|
||||
await scrollAndAssert(0, -controlPanelHeight, false);
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("mobile search: basic display", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const fields = {
|
||||
birthday: { string: "Birthday", type: "date", store: true, sortable: true },
|
||||
};
|
||||
const searchMenuTypes = ["filter", "groupBy", "comparison", "favorite"];
|
||||
const params = {
|
||||
cpModelConfig: {
|
||||
arch: `
|
||||
<search>
|
||||
<filter name="birthday" date="birthday"/>
|
||||
</search>`,
|
||||
fields,
|
||||
searchMenuTypes,
|
||||
},
|
||||
cpProps: { fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
|
||||
// Toggle search bar controls
|
||||
await testUtils.dom.click(controlPanel.el.querySelector("button.o_enable_searchview"));
|
||||
// Open search view
|
||||
await testUtils.dom.click(controlPanel.el.querySelector("button.o_toggle_searchview_full"));
|
||||
|
||||
// Toggle filter date
|
||||
// Note: 'document.body' is used instead of 'controlPanel' because the
|
||||
// search view is directly in the body.
|
||||
await cpHelpers.toggleFilterMenu(document);
|
||||
await cpHelpers.toggleMenuItem(document, "Birthday");
|
||||
await cpHelpers.toggleMenuItemOption(document, "Birthday", 0);
|
||||
|
||||
assert.containsOnce(document.body, ".o_filter_menu");
|
||||
assert.containsOnce(document.body, ".o_group_by_menu");
|
||||
assert.containsOnce(document.body, ".o_comparison_menu");
|
||||
assert.containsOnce(document.body, ".o_favorite_menu");
|
||||
});
|
||||
|
||||
QUnit.test('mobile search: activate a filter through quick search', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
let searchRPCFlag = false;
|
||||
|
||||
const mockRPC = (route, args) => {
|
||||
if (searchRPCFlag && args.method === "web_search_read") {
|
||||
assert.deepEqual(args.kwargs.domain, [['foo', 'ilike', 'A']],
|
||||
"domain should have been properly transferred to list view");
|
||||
}
|
||||
};
|
||||
|
||||
const webClient = await createWebClient({serverData, mockRPC});
|
||||
|
||||
await doAction(webClient, 1);
|
||||
|
||||
assert.containsOnce(document.body, 'button.o_enable_searchview.oi-search',
|
||||
"should display a button to open the searchview");
|
||||
assert.containsNone(document.body, '.o_searchview_input_container',
|
||||
"Quick search input should be hidden");
|
||||
|
||||
// open the search view
|
||||
await testUtils.dom.click(document.querySelector('button.o_enable_searchview'));
|
||||
|
||||
assert.containsOnce(document.body, '.o_toggle_searchview_full',
|
||||
"should display a button to expand the searchview");
|
||||
assert.containsOnce(document.body, '.o_searchview_input_container',
|
||||
"Quick search input should now be visible");
|
||||
|
||||
searchRPCFlag = true;
|
||||
|
||||
// use quick search input (search view is directly put in the body)
|
||||
await cpHelpers.editSearch(document.body, "A");
|
||||
await cpHelpers.validateSearch(document.body);
|
||||
|
||||
// close quick search
|
||||
await testUtils.dom.click(document.querySelector('button.o_enable_searchview.fa-arrow-left'));
|
||||
|
||||
assert.containsNone(document.body, '.o_toggle_searchview_full',
|
||||
"Expand icon shoud be hidden");
|
||||
assert.containsNone(document.body, '.o_searchview_input_container',
|
||||
"Quick search input should be hidden");
|
||||
});
|
||||
|
||||
QUnit.test('mobile search: activate a filter in full screen search view', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const webClient = await createWebClient({ serverData });
|
||||
|
||||
await doAction(webClient, 1);
|
||||
|
||||
assert.containsNone(document.body, '.o_mobile_search');
|
||||
|
||||
// open the search view
|
||||
await testUtils.dom.click(target.querySelector('button.o_enable_searchview'));
|
||||
// open it in full screen
|
||||
await testUtils.dom.click(target.querySelector('.o_toggle_searchview_full'));
|
||||
|
||||
assert.containsOnce(document.body, '.o_mobile_search');
|
||||
|
||||
await cpHelpers.toggleFilterMenu(document.body);
|
||||
await cpHelpers.toggleMenuItem(document.body, "Active");
|
||||
|
||||
// closing search view
|
||||
await testUtils.dom.click(
|
||||
[...document.querySelectorAll('.o_mobile_search_button')].find(
|
||||
e => e.innerText.trim() === "FILTER"
|
||||
)
|
||||
);
|
||||
assert.containsNone(document.body, '.o_mobile_search');
|
||||
});
|
||||
});
|
||||
});
|
||||
409
web_enterprise/static/tests/legacy/form_mobile_tests.js
Normal file
409
web_enterprise/static/tests/legacy/form_mobile_tests.js
Normal file
@@ -0,0 +1,409 @@
|
||||
odoo.define('web_enterprise.form_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var FormView = require('web.FormView');
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
var createView = testUtils.createView;
|
||||
|
||||
QUnit.module('web_enterprise', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: { string: "Displayed name", type: "char" },
|
||||
trululu: {string: "Trululu", type: "many2one", relation: 'partner'},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
display_name: "first record",
|
||||
trululu: 4,
|
||||
}, {
|
||||
id: 2,
|
||||
display_name: "second record",
|
||||
trululu: 1,
|
||||
}, {
|
||||
id: 4,
|
||||
display_name: "aaa",
|
||||
}],
|
||||
},
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.module('Mobile FormView');
|
||||
|
||||
QUnit.test('statusbar buttons are correctly rendered in mobile', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form string="Partners">' +
|
||||
'<header>' +
|
||||
'<button string="Confirm"/>' +
|
||||
'<button string="Do it"/>' +
|
||||
'</header>' +
|
||||
'<sheet>' +
|
||||
'<group>' +
|
||||
'<button name="display_name"/>' +
|
||||
'</group>' +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
});
|
||||
|
||||
assert.strictEqual(form.$('.o_statusbar_buttons a:contains(Action)').length, 1,
|
||||
"statusbar should contain a button 'Action'");
|
||||
assert.containsOnce(form, '.o_statusbar_buttons .dropdown-menu',
|
||||
"statusbar should contain a dropdown");
|
||||
assert.containsNone(form, '.o_statusbar_buttons .dropdown-menu:visible',
|
||||
"dropdown should be hidden");
|
||||
|
||||
// open the dropdown
|
||||
await testUtils.dom.click(form.$('.o_statusbar_buttons a'));
|
||||
assert.containsOnce(form, '.o_statusbar_buttons .dropdown-menu:visible',
|
||||
"dropdown should be visible");
|
||||
assert.containsN(form, '.o_statusbar_buttons .dropdown-menu > button', 2,
|
||||
"dropdown should contain 2 buttons");
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('statusbar "Action" button should be displayed only if there are multiple visible buttons', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<header>' +
|
||||
'<button string="Confirm" attrs=\'{"invisible": [["display_name", "=", "first record"]]}\'/>' +
|
||||
'<button string="Do it" attrs=\'{"invisible": [["display_name", "=", "first record"]]}\'/>' +
|
||||
'</header>' +
|
||||
'<sheet>' +
|
||||
'<group>' +
|
||||
'<field name="display_name"/>' +
|
||||
'</group>' +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
});
|
||||
|
||||
// if all buttons are invisible then there should be no action button
|
||||
assert.containsNone(form, '.o_statusbar_buttons > btn-group > .dropdown-toggle',
|
||||
"'Action' dropdown is not displayed as there are no visible buttons");
|
||||
// there should be two invisible buttons
|
||||
assert.containsN(form, '.o_statusbar_buttons > button.o_invisible_modifier', 2,
|
||||
"Status bar should have two buttons with 'o_invisible_modifier' class");
|
||||
|
||||
// change display_name to update buttons modifiers and make it visible
|
||||
await testUtils.form.clickEdit(form);
|
||||
await testUtils.fields.editInput(form.$('input[name=display_name]'), 'test');
|
||||
await testUtils.form.clickSave(form);
|
||||
assert.containsOnce(form, '.o_statusbar_buttons a:contains(Action)',
|
||||
"statusbar should contain a button 'Action'");
|
||||
assert.containsOnce(form, '.o_statusbar_buttons .dropdown-menu',
|
||||
"statusbar should contain a dropdown");
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('statusbar "Action" button not displayed in edit mode with .oe_read_only button', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<button string="Share" type="action" class="oe_highlight oe_read_only"/>
|
||||
<button string="Email" type="action" class="oe_highlight oe_read_only"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="display_name"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsNone(form, '.o_statusbar_buttons a:contains(Action)',
|
||||
"'Action' button should not be there");
|
||||
await testUtils.form.clickSave(form);
|
||||
assert.containsOnce(form, '.o_statusbar_buttons a:contains(Action)',
|
||||
"'Action' button should be there");
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test(`statusbar "Action" button shouldn't be displayed for only one visible button`, async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `<form>
|
||||
<header>
|
||||
<button string="Hola" attrs='{"invisible": [["display_name", "=", "first record"]]}'/>
|
||||
<button string="Ciao"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="display_name"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>`,
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
// There should be a simple statusbar button and no action dropdown
|
||||
assert.containsNone(form, '.o_statusbar_buttons a:contains(Action)', "should have no 'Action' dropdown");
|
||||
assert.containsOnce(form, '.o_statusbar_buttons > button > span:contains(Ciao)', "should have a simple statusbar button 'Ciao'");
|
||||
|
||||
// change display_name to update buttons modifiers and make both buttons visible
|
||||
await testUtils.fields.editInput(form.$('input[name=display_name]'), 'test');
|
||||
|
||||
// Now there should an action dropdown, because there are two visible buttons
|
||||
assert.containsOnce(form, '.o_statusbar_buttons a:contains(Action)', "should have no 'Action' dropdown");
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test(`statusbar widgets should appear in the statusbar dropdown only if there are multiple items`, async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form string="Partners">
|
||||
<header>
|
||||
<widget name="attach_document" string="Attach document"/>
|
||||
<button string="Ciao" attrs='{"invisible": [["display_name", "=", "first record"]]}'/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="display_name"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
res_id: 2,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
const dropdownActionButton = '.o_statusbar_buttons a:contains(Action)';
|
||||
|
||||
// Now there should an action dropdown, because there are two visible buttons
|
||||
assert.containsOnce(form, dropdownActionButton, "should have 'Action' dropdown");
|
||||
|
||||
assert.containsN(form, `.o_statusbar_buttons .dropdown-menu > button`,
|
||||
2, "should have 2 buttons in the dropdown");
|
||||
|
||||
// change display_name to update buttons modifiers and make one button visible
|
||||
await testUtils.fields.editInput(form.$('input[name=display_name]'), 'first record');
|
||||
|
||||
// There should be a simple statusbar button and no action dropdown
|
||||
assert.containsNone(form, dropdownActionButton, "shouldn't have 'Action' dropdown");
|
||||
assert.containsOnce(form, `.o_statusbar_buttons > button:visible`,
|
||||
"should have 1 button visible in the statusbar");
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test(`Quick Edition: quick edit many2one`, async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="trululu" />
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
archs: {
|
||||
'partner,false,kanban': `
|
||||
<kanban>
|
||||
<templates><t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click">
|
||||
<field name="display_name"/>
|
||||
</div>
|
||||
</t></templates>
|
||||
</kanban>
|
||||
`,
|
||||
'partner,false,search': '<search></search>',
|
||||
},
|
||||
res_id: 2,
|
||||
});
|
||||
|
||||
await testUtils.dom.click(form.$('.o_form_label'));
|
||||
await testUtils.nextTick(); // wait for quick edit
|
||||
|
||||
const $modal = $('.o_modal_full .modal-lg');
|
||||
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('statusbar "Action" dropdown should keep its open/close state', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<button string="Just more than one"/>
|
||||
<button string="Confirm" attrs='{"invisible": [["display_name", "=", ""]]}'/>
|
||||
<button string="Do it" attrs='{"invisible": [["display_name", "!=", ""]]}'/>
|
||||
</header>
|
||||
<sheet>
|
||||
<field name="display_name"/>
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
const dropdownMenuSelector = '.o_statusbar_buttons .dropdown-menu';
|
||||
assert.containsOnce(form, dropdownMenuSelector,
|
||||
"statusbar should contain a dropdown");
|
||||
assert.doesNotHaveClass(form.el.querySelector(dropdownMenuSelector), 'show',
|
||||
"dropdown should be closed");
|
||||
|
||||
// open the dropdown
|
||||
await testUtils.dom.click(form.el.querySelector('.o_statusbar_buttons .dropdown-toggle'));
|
||||
assert.hasClass(form.el.querySelector(dropdownMenuSelector), 'show',
|
||||
"dropdown should be opened");
|
||||
|
||||
// change display_name to update buttons' modifiers
|
||||
await testUtils.fields.editInput(form.el.querySelector('input[name="display_name"]'), 'test');
|
||||
assert.containsOnce(form, dropdownMenuSelector,
|
||||
"statusbar should contain a dropdown");
|
||||
assert.hasClass(form.el.querySelector(dropdownMenuSelector), 'show',
|
||||
"dropdown should still be opened");
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test(`statusbar "Action" dropdown's open/close state shouldn't be modified after 'onchange'`, async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
let resolveOnchange;
|
||||
const onchangePromise = new Promise(resolve => {
|
||||
resolveOnchange = resolve;
|
||||
});
|
||||
|
||||
this.data.partner.onchanges = {
|
||||
display_name: async () => {},
|
||||
};
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<button name="create" string="Create Invoice" type="action"/>
|
||||
<button name="send" string="Send by Email" type="action"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<field name="display_name" />
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
async mockRPC(route, { method, args: [, , changedField] }) {
|
||||
return method === 'onchange' && changedField === 'display_name' ? onchangePromise : this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
const dropdownMenuSelector = '.o_statusbar_buttons .dropdown-menu';
|
||||
assert.containsOnce(form, dropdownMenuSelector,
|
||||
"statusbar should contain a dropdown");
|
||||
assert.doesNotHaveClass(form.el.querySelector(dropdownMenuSelector), 'show',
|
||||
"dropdown should be closed");
|
||||
|
||||
await testUtils.fields.editInput(form.el.querySelector('input[name="display_name"]'), 'before onchange');
|
||||
await testUtils.dom.click(form.el.querySelector('.o_statusbar_buttons .dropdown-toggle'));
|
||||
assert.hasClass(form.el.querySelector(dropdownMenuSelector), 'show',
|
||||
"dropdown should be opened");
|
||||
resolveOnchange({ value: { display_name: 'after onchange' } });
|
||||
await testUtils.nextTick();
|
||||
assert.strictEqual(form.el.querySelector('input[name="display_name"]').value, 'after onchange');
|
||||
assert.hasClass(form.el.querySelector(dropdownMenuSelector), 'show',
|
||||
"dropdown should still be opened");
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("preserve current scroll position on form view while closing dialog", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
arch: `<form>
|
||||
<sheet>
|
||||
<p style='height:500px'></p>
|
||||
<field name="trululu"/>
|
||||
<p style='height:500px'></p>
|
||||
</sheet>
|
||||
</form>`,
|
||||
archs: {
|
||||
"partner,false,kanban": `<kanban>
|
||||
<templates><t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click"><field name="display_name"/></div>
|
||||
</t></templates>
|
||||
</kanban>`,
|
||||
"partner,false,search": "<search></search>",
|
||||
},
|
||||
data: this.data,
|
||||
model: "partner",
|
||||
res_id: 2,
|
||||
debug: true, // need to be in the viewport to check scrollPosition
|
||||
viewOptions: { mode: "edit" },
|
||||
});
|
||||
|
||||
const scrollPosition = { top: 265, left: 0 };
|
||||
window.scrollTo(scrollPosition);
|
||||
|
||||
assert.strictEqual(window.scrollY, scrollPosition.top, "Should have scrolled 265 px vertically");
|
||||
assert.strictEqual(window.scrollX, scrollPosition.left, "Should be 0 px from left as it is");
|
||||
// click on m2o field
|
||||
await testUtils.dom.click(form.$(".o_field_many2one input"));
|
||||
assert.strictEqual(window.scrollY, 0, "Should have scrolled to top (0) px");
|
||||
assert.containsOnce($("body"), ".modal.o_modal_full", "there should be a many2one modal opened in full screen");
|
||||
// click on back button
|
||||
await testUtils.dom.click($(".modal").find(".modal-header .fa-arrow-left"));
|
||||
assert.strictEqual(window.scrollY, scrollPosition.top, "Should have scrolled back to 265 px vertically");
|
||||
assert.strictEqual(window.scrollX, scrollPosition.left, "Should be 0 px from left as it is");
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
odoo.define('web_enterprise.relational_fields_mobile_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var FormView = require('web.FormView');
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
var createView = testUtils.createView;
|
||||
|
||||
QUnit.module('web_enterprise', {}, function () {
|
||||
|
||||
QUnit.module('relational_fields', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: { string: "Displayed name", type: "char" },
|
||||
trululu: {string: "Trululu", type: "many2one", relation: 'partner'},
|
||||
sibling_ids: {string: "Sibling", type: "many2many", relation: 'partner'},
|
||||
p: { string: "one2many field", type: "one2many", relation: 'partner', relation_field: 'trululu' },
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
display_name: "first record",
|
||||
trululu: 4,
|
||||
}, {
|
||||
id: 2,
|
||||
display_name: "second record",
|
||||
trululu: 1,
|
||||
}, {
|
||||
id: 4,
|
||||
display_name: "aaa",
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
}, function () {
|
||||
|
||||
QUnit.module('FieldStatus');
|
||||
|
||||
QUnit.test('statusbar is rendered correclty on small devices', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch:
|
||||
'<form string="Partners">' +
|
||||
'<header><field name="trululu" widget="statusbar"/></header>' +
|
||||
'<field name="display_name"/>' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
});
|
||||
|
||||
assert.strictEqual(form.$('.o_statusbar_status > button:contains(aaa)').length, 1,
|
||||
"should have only one visible status in mobile, the active one");
|
||||
assert.containsOnce(form, '.o_statusbar_status .dropdown-menu',
|
||||
"should have a dropdown containing all status");
|
||||
assert.containsNone(form, '.o_statusbar_status .dropdown-menu:visible',
|
||||
"dropdown should be hidden");
|
||||
|
||||
// open the dropdown
|
||||
testUtils.dom.click(form.$('.o_statusbar_status > button'));
|
||||
assert.containsOnce(form, '.o_statusbar_status .dropdown-menu:visible',
|
||||
"dropdown should be visible");
|
||||
assert.containsN(form, '.o_statusbar_status .dropdown-menu button', 3,
|
||||
"should have 3 status");
|
||||
assert.containsN(form, '.o_statusbar_status button:disabled', 3,
|
||||
"all status should be disabled");
|
||||
var $activeStatus = form.$('.o_statusbar_status .dropdown-menu button[data-value=4]');
|
||||
assert.hasClass($activeStatus,'btn-primary', "active status should be btn-primary");
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('statusbar with no status on extra small screens', async function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch:'<form string="Partners">' +
|
||||
'<header><field name="trululu" widget="statusbar"/></header>' +
|
||||
'</form>',
|
||||
res_id: 4,
|
||||
});
|
||||
|
||||
assert.hasClass(form.$('.o_statusbar_status'),'o_field_empty',
|
||||
'statusbar widget should have class o_field_empty');
|
||||
assert.strictEqual(form.$('.o_statusbar_status').children().length, 2,
|
||||
'statusbar widget should have two children');
|
||||
assert.containsOnce(form, '.o_statusbar_status button.dropdown-toggle',
|
||||
'statusbar widget should have a button');
|
||||
assert.strictEqual(form.$('.o_statusbar_status button.dropdown-toggle').text().trim(), '',
|
||||
'statusbar button has no text'); // Behavior as of saas-15, might be improved
|
||||
assert.containsOnce(form, '.o_statusbar_status .dropdown-menu',
|
||||
'statusbar widget should have a dropdown menu');
|
||||
assert.containsN(form, '.o_statusbar_status .dropdown-menu button', 3,
|
||||
'statusbar widget dropdown menu should have 3 buttons');
|
||||
assert.strictEqual(form.$('.o_statusbar_status .dropdown-menu button').eq(0).text().trim(), 'first record',
|
||||
'statusbar widget dropdown first button should display the first record display_name');
|
||||
assert.strictEqual(form.$('.o_statusbar_status .dropdown-menu button').eq(1).text().trim(), 'second record',
|
||||
'statusbar widget dropdown second button should display the second record display_name');
|
||||
assert.strictEqual(form.$('.o_statusbar_status .dropdown-menu button').eq(2).text().trim(), 'aaa',
|
||||
'statusbar widget dropdown third button should display the third record display_name');
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('clickable statusbar widget on mobile view', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch:'<form string="Partners">' +
|
||||
'<header><field name="trululu" widget="statusbar" options=\'{"clickable": "1"}\'/></header>' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
});
|
||||
|
||||
var $selectedStatus = form.$('.o_statusbar_status button[data-value="4"]');
|
||||
assert.hasClass($selectedStatus, 'btn-primary');
|
||||
assert.hasClass($selectedStatus,'disabled');
|
||||
var selector = '.o_statusbar_status button.btn-secondary:not(.dropdown-toggle):not(:disabled)';
|
||||
assert.containsN(form, selector, 2, "other status should be btn-secondary and not disabled");
|
||||
|
||||
await testUtils.dom.click(form.$('.o_statusbar_status .dropdown-toggle'));
|
||||
await testUtils.dom.clickFirst(form.$(selector));
|
||||
|
||||
var $status = form.$('.o_statusbar_status button[data-value="1"]');
|
||||
assert.hasClass($status, 'btn-primary');
|
||||
assert.hasClass($status, 'disabled');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.module('FieldMany2One');
|
||||
|
||||
QUnit.test("many2one in a enterprise environment", async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
arch:
|
||||
'<form>' +
|
||||
'<sheet>' +
|
||||
'<field name="trululu"/>' +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
archs: {
|
||||
'partner,false,kanban': '<kanban>' +
|
||||
'<templates><t t-name="kanban-box">' +
|
||||
'<div class="oe_kanban_global_click"><field name="display_name"/></div>' +
|
||||
'</t></templates>' +
|
||||
'</kanban>',
|
||||
'partner,false,search': '<search></search>',
|
||||
},
|
||||
data: this.data,
|
||||
model: 'partner',
|
||||
res_id: 2,
|
||||
viewOptions: {mode: 'edit'},
|
||||
});
|
||||
|
||||
var $input = form.$('.o_field_many2one input');
|
||||
|
||||
assert.doesNotHaveClass($input, 'ui-autocomplete-input',
|
||||
"autocomplete should not be visible in a mobile environment");
|
||||
|
||||
await testUtils.dom.click($input);
|
||||
|
||||
var $modal = $('.o_modal_full .modal-lg');
|
||||
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
|
||||
assert.containsOnce($modal, '.o_legacy_kanban_view',
|
||||
'kanban view should be open in SelectCreateDialog');
|
||||
assert.containsOnce($modal, '.o_cp_searchview',
|
||||
'should have Search view inside SelectCreateDialog');
|
||||
assert.containsNone($modal.find(".o_control_panel .o_cp_buttons"), '.o-kanban-button-new',
|
||||
"kanban view in SelectCreateDialog should not have Create button");
|
||||
assert.strictEqual($modal.find(".o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)").length, 3,
|
||||
"popup should load 3 records in kanban");
|
||||
|
||||
await testUtils.dom.click($modal.find('.o_legacy_kanban_view .o_kanban_record:first'));
|
||||
|
||||
assert.strictEqual($input.val(), 'first record',
|
||||
'clicking kanban card should select record for many2one field');
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("hide/show element using selection_mode in kanban view in a enterprise environment", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
this.data.partner.fields.foo = {string: "Foo", type: "char", default: "My little Foo Value"};
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
arch:
|
||||
'<form>' +
|
||||
'<sheet>' +
|
||||
'<field name="trululu"/>' +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
archs: {
|
||||
'partner,false,kanban': '<kanban>' +
|
||||
'<templates><t t-name="kanban-box">' +
|
||||
'<div class="oe_kanban_global_click">' +
|
||||
'<field name="display_name"/>' +
|
||||
'</div>' +
|
||||
'<div class="o_sibling_tags" t-if="!selection_mode">' +
|
||||
'<field name="sibling_ids"/>' +
|
||||
'</div>' +
|
||||
'<div class="o_foo" t-if="selection_mode">' +
|
||||
'<field name="foo"/>' +
|
||||
'</div>' +
|
||||
'</t></templates>' +
|
||||
'</kanban>',
|
||||
'partner,false,search': '<search></search>',
|
||||
},
|
||||
data: this.data,
|
||||
model: 'partner',
|
||||
res_id: 2,
|
||||
viewOptions: {mode: 'edit'},
|
||||
});
|
||||
|
||||
var $input = form.$('.o_field_many2one input');
|
||||
|
||||
assert.doesNotHaveClass($input, 'ui-autocomplete-input',
|
||||
"autocomplete should not be visible in a mobile environment");
|
||||
|
||||
await testUtils.dom.click($input);
|
||||
|
||||
var $modal = $('.o_modal_full .modal-lg');
|
||||
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
|
||||
assert.containsOnce($modal, '.o_legacy_kanban_view',
|
||||
'kanban view should be open in SelectCreateDialog');
|
||||
assert.containsNone($modal, '.o_legacy_kanban_view .o_sibling_tags',
|
||||
'o_sibling_tags div should not be available as div have condition on selection_mode');
|
||||
assert.containsN($modal, '.o_legacy_kanban_view .o_foo', 3,
|
||||
'o_foo div should be available as div have condition on selection_mode');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("kanban_view_ref attribute opens specific kanban view given as a reference in a mobile environment", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
arch:
|
||||
'<form>' +
|
||||
'<sheet>' +
|
||||
'<field name="trululu" kanban_view_ref="2"/>' +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
archs: {
|
||||
'partner,1,kanban': '<kanban class="kanban1">' +
|
||||
'<templates><t t-name="kanban-box">' +
|
||||
'<div class="oe_kanban_global_click">' +
|
||||
'<field name="display_name"/>' +
|
||||
'</div>' +
|
||||
'</t></templates>' +
|
||||
'</kanban>',
|
||||
'partner,2,kanban': '<kanban class="kanban2">' +
|
||||
'<templates><t t-name="kanban-box">' +
|
||||
'<div class="oe_kanban_global_click">' +
|
||||
'<div>' +
|
||||
'<field name="display_name"/>' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<field name="trululu"/>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</t></templates>' +
|
||||
'</kanban>',
|
||||
'partner,false,search': '<search></search>',
|
||||
},
|
||||
data: this.data,
|
||||
model: 'partner',
|
||||
res_id: 2,
|
||||
viewOptions: {mode: 'edit'},
|
||||
});
|
||||
|
||||
var $input = form.$('.o_field_many2one input');
|
||||
|
||||
assert.doesNotHaveClass($input, 'ui-autocomplete-input',
|
||||
"autocomplete should not be visible in a mobile environment");
|
||||
|
||||
await testUtils.dom.click($input);
|
||||
|
||||
var $modal = $('.o_modal_full .modal-lg');
|
||||
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
|
||||
assert.containsOnce($modal, '.o_legacy_kanban_view',
|
||||
'kanban view should be open in SelectCreateDialog');
|
||||
assert.hasClass($modal.find('.o_legacy_kanban_view'), 'kanban2',
|
||||
'kanban view with id 2 should be opened as it is given as kanban_view_ref');
|
||||
assert.strictEqual($modal.find('.o_legacy_kanban_view .o_kanban_record:first').text(),
|
||||
'first recordaaa',
|
||||
'kanban with two fields should be opened');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("many2one dialog on mobile: clear button header", async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="trululu"/>
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
archs: {
|
||||
'partner,false,kanban': `
|
||||
<kanban>
|
||||
<templates><t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click"><field name="display_name"/></div>
|
||||
</t></templates>
|
||||
</kanban>
|
||||
`,
|
||||
'partner,false,search': '<search></search>',
|
||||
},
|
||||
data: this.data,
|
||||
model: 'partner',
|
||||
res_id: 2,
|
||||
viewOptions: {mode: 'edit'},
|
||||
});
|
||||
|
||||
let $input = form.$('.o_field_many2one input');
|
||||
|
||||
assert.doesNotHaveClass($input, 'ui-autocomplete-input',
|
||||
"autocomplete should not be visible in a mobile environment");
|
||||
|
||||
await testUtils.dom.click($input);
|
||||
assert.containsOnce($('body'), '.modal.o_modal_full',
|
||||
"there should be a modal opened in full screen");
|
||||
assert.containsN($('.modal'), '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3,
|
||||
"popup should load 3 records in kanban");
|
||||
|
||||
await testUtils.dom.click($('.modal').find('.o_legacy_kanban_view .o_kanban_record:first'));
|
||||
assert.strictEqual($input.val(), 'first record',
|
||||
'clicking kanban card should select record for many2one field');
|
||||
|
||||
await testUtils.dom.click($input);
|
||||
// clear button.
|
||||
assert.containsOnce($('.modal').find('.modal-header'), '.o_clear_button',
|
||||
"there should be a Clear button in the modal header");
|
||||
|
||||
await testUtils.dom.click($('.modal').find('.modal-header .o_clear_button'));
|
||||
assert.containsNone($('body'), '.modal', "there should be no more modal");
|
||||
|
||||
$input = form.$('.o_field_many2one input');
|
||||
assert.strictEqual($input.val(), "", "many2one should be cleared");
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.module('FieldMany2Many');
|
||||
|
||||
QUnit.test("many2many_tags in a mobile environment", async function (assert) {
|
||||
assert.expect(10);
|
||||
|
||||
var rpcReadCount = 0;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
arch:
|
||||
'<form>' +
|
||||
'<sheet>' +
|
||||
'<field name="sibling_ids" widget="many2many_tags"/>' +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
archs: {
|
||||
'partner,false,kanban': '<kanban>' +
|
||||
'<templates><t t-name="kanban-box">' +
|
||||
'<div class="oe_kanban_global_click"><field name="display_name"/></div>' +
|
||||
'</t></templates>' +
|
||||
'</kanban>',
|
||||
'partner,false,search': '<search></search>',
|
||||
},
|
||||
data: this.data,
|
||||
model: 'partner',
|
||||
res_id: 2,
|
||||
viewOptions: {mode: 'edit'},
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === "read" && args.model === "partner") {
|
||||
if (rpcReadCount === 0) {
|
||||
assert.deepEqual(args.args[0], [2], "form should initially show partner 2");
|
||||
} else if (rpcReadCount === 1) {
|
||||
assert.deepEqual(args.args[0], [1], "partner with id 1 should be selected");
|
||||
}
|
||||
rpcReadCount++;
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
var $input = form.$(".o_field_widget .o_input");
|
||||
|
||||
assert.strictEqual($input.find(".badge").length, 0,
|
||||
"many2many_tags should have no tags");
|
||||
|
||||
await testUtils.dom.click($input);
|
||||
|
||||
var $modal = $('.o_modal_full .modal-lg');
|
||||
assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
|
||||
assert.containsOnce($modal, '.o_legacy_kanban_view',
|
||||
'kanban view should be open in SelectCreateDialog');
|
||||
assert.containsOnce($modal, '.o_cp_searchview',
|
||||
'should have Search view inside SelectCreateDialog');
|
||||
assert.containsNone($modal.find(".o_control_panel .o_cp_buttons"), '.o-kanban-button-new',
|
||||
"kanban view in SelectCreateDialog should not have Create button");
|
||||
assert.strictEqual($modal.find(".o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)").length, 3,
|
||||
"popup should load 3 records in kanban");
|
||||
|
||||
await testUtils.dom.click($modal.find('.o_legacy_kanban_view .o_kanban_record:first'));
|
||||
|
||||
assert.strictEqual(rpcReadCount, 2, "there should be a read for current form record and selected sibling");
|
||||
assert.strictEqual(form.$(".o_field_widget.o_input .badge").length, 1,
|
||||
"many2many_tags should have partner coucou3");
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.module('FieldOne2Many');
|
||||
|
||||
QUnit.test('one2many on mobile: remove header button', async function (assert) {
|
||||
assert.expect(9);
|
||||
this.data.partner.records[0].p = [1, 2, 4];
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form string="Partners">
|
||||
<field name="p"/>
|
||||
</form>
|
||||
`,
|
||||
archs: {
|
||||
'partner,false,form': `
|
||||
<form string="Partner">
|
||||
<field name="display_name"/>
|
||||
</form>
|
||||
`,
|
||||
'partner,false,kanban': `
|
||||
<kanban>
|
||||
<templates><t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click">
|
||||
<field name="display_name"/>
|
||||
</div>
|
||||
</t></templates>
|
||||
</kanban>
|
||||
`,
|
||||
},
|
||||
res_id: 1,
|
||||
mockRPC(route, args) {
|
||||
if (route === '/web/dataset/call_kw/partner/write') {
|
||||
const commands = args.args[1].p;
|
||||
assert.strictEqual(commands.length, 3,
|
||||
'should have generated three commands');
|
||||
assert.ok(commands[0][0] === 4 && commands[0][1] === 2,
|
||||
'should have generated the command 4 (LINK_TO) with id 2');
|
||||
assert.ok(commands[1][0] === 4 && commands[1][1] === 4,
|
||||
'should have generated the command 2 (LINK_TO) with id 1');
|
||||
assert.ok(commands[2][0] === 2 && commands[2][1] === 1,
|
||||
'should have generated the command 2 (DELETE) with id 2');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.form.clickEdit(form);
|
||||
assert.containsN(form, '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3,
|
||||
"should have 3 records in kanban");
|
||||
|
||||
await testUtils.dom.click(form.$('.o_legacy_kanban_view .o_kanban_record:first'));
|
||||
assert.containsOnce($('body'), '.modal.o_modal_full',
|
||||
"there should be a modal opened in full screen");
|
||||
|
||||
// remove button.
|
||||
assert.containsOnce($('.modal').find('.modal-header'), '.o_btn_remove',
|
||||
"there should be a 'Remove' button in the modal header");
|
||||
|
||||
await testUtils.dom.click($('.modal').find('.modal-header .o_btn_remove'));
|
||||
assert.containsNone($('body'), '.modal', "there should be no more modal");
|
||||
assert.containsN(form, '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2,
|
||||
"should have 2 records in kanban");
|
||||
|
||||
// save and check that the correct command has been generated
|
||||
await testUtils.form.clickSave(form);
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
odoo.define('web_enterprise.BasicRenderMobileTests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const BasicRenderer = require('web.BasicRenderer');
|
||||
const FormView = require('web.FormView');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
const createView = testUtils.createView;
|
||||
|
||||
QUnit.module('web_enterprise > basic > basic_render_mobile', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: {string: "Displayed name", type: "char", help: 'The name displayed'},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "first record",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}, function () {
|
||||
QUnit.module('Basic Render Mobile');
|
||||
|
||||
QUnit.test(`field tooltip shouldn't remain displayed in mobile`, async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
testUtils.mock.patch(BasicRenderer, {
|
||||
SHOW_AFTER_DELAY: 0,
|
||||
_getTooltipOptions: function () {
|
||||
return Object.assign({}, this._super(...arguments), {
|
||||
animation: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="display_name"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
const label = form.el.querySelector('label.o_form_label');
|
||||
await testUtils.dom.triggerEvent(label, 'touchstart');
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.tooltip .oe_tooltip_string').length,
|
||||
1, "should have a tooltip displayed"
|
||||
);
|
||||
await testUtils.dom.triggerEvent(label, 'touchend');
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.tooltip .oe_tooltip_string').length,
|
||||
0, "shouldn't have a tooltip displayed"
|
||||
);
|
||||
form.destroy();
|
||||
|
||||
testUtils.mock.unpatch(BasicRenderer);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
216
web_enterprise/static/tests/legacy/views/kanban_mobile_tests.js
Normal file
216
web_enterprise/static/tests/legacy/views/kanban_mobile_tests.js
Normal file
@@ -0,0 +1,216 @@
|
||||
odoo.define('web_enterprise.kanban_mobile_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const KanbanView = require('web.KanbanView');
|
||||
const { createView, dom} = require('web.test_utils');
|
||||
|
||||
QUnit.module('LegacyViews', {
|
||||
beforeEach() {
|
||||
this.data = {
|
||||
partner: {
|
||||
fields: {
|
||||
foo: {string: "Foo", type: "char"},
|
||||
bar: {string: "Bar", type: "boolean"},
|
||||
int_field: {string: "int_field", type: "integer", sortable: true},
|
||||
qux: {string: "my float", type: "float"},
|
||||
product_id: {string: "something_id", type: "many2one", relation: "product"},
|
||||
category_ids: { string: "categories", type: "many2many", relation: 'category'},
|
||||
state: { string: "State", type: "selection", selection: [["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"]]},
|
||||
date: {string: "Date Field", type: 'date'},
|
||||
datetime: {string: "Datetime Field", type: 'datetime'},
|
||||
},
|
||||
records: [
|
||||
{id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: []},
|
||||
{id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6]},
|
||||
{id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7]},
|
||||
{id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: []},
|
||||
{id: 5, bar: false, foo: "Hello \"World\"! #peace_n'_love", int_field: -9, qux: 10, state: "jkl", category_ids: []},
|
||||
]
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
id: {string: "ID", type: "integer"},
|
||||
name: {string: "Display Name", type: "char"},
|
||||
},
|
||||
records: [
|
||||
{id: 3, name: "hello"},
|
||||
{id: 5, name: "xmo"},
|
||||
]
|
||||
},
|
||||
category: {
|
||||
fields: {
|
||||
name: {string: "Category Name", type: "char"},
|
||||
color: {string: "Color index", type: "integer"},
|
||||
},
|
||||
records: [
|
||||
{id: 6, name: "gold", color: 2},
|
||||
{id: 7, name: "silver", color: 5},
|
||||
]
|
||||
},
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
QUnit.module("KanbanView (legacy) - Mobile")
|
||||
QUnit.test('kanban with searchpanel: rendering in mobile', async function (assert) {
|
||||
assert.expect(34);
|
||||
|
||||
const kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<kanban>
|
||||
<templates><t t-name="kanban-box">
|
||||
<div>
|
||||
<field name="foo"/>
|
||||
</div>
|
||||
</t></templates>
|
||||
</kanban>
|
||||
`,
|
||||
archs: {
|
||||
'partner,false,search': `
|
||||
<search>
|
||||
<searchpanel>
|
||||
<field name="product_id" expand="1" enable_counters="1"/>
|
||||
<field name="state" expand="1" select="multi" enable_counters="1"/>
|
||||
</searchpanel>
|
||||
</search>
|
||||
`,
|
||||
},
|
||||
mockRPC(route, {method}) {
|
||||
if (method && method.includes('search_panel_')) {
|
||||
assert.step(method);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
let $sp = kanban.$(".o_search_panel");
|
||||
|
||||
assert.containsOnce(kanban, ".o_search_panel.o_search_panel_summary");
|
||||
assert.containsNone(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
|
||||
assert.verifySteps([
|
||||
"search_panel_select_range",
|
||||
"search_panel_select_multi_range",
|
||||
]);
|
||||
|
||||
assert.containsOnce($sp, ".fa.fa-filter");
|
||||
assert.containsOnce($sp, ".o_search_panel_current_selection:contains(All)");
|
||||
|
||||
// open the search panel
|
||||
await dom.click($sp);
|
||||
$sp = $(".o_search_panel");
|
||||
|
||||
assert.containsNone(kanban, ".o_search_panel.o_search_panel_summary");
|
||||
assert.containsOnce(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
|
||||
|
||||
assert.containsOnce($sp, ".o_mobile_search_header > button:contains(FILTER)");
|
||||
assert.containsOnce($sp, "button.o_mobile_search_footer:contains(SEE RESULT)");
|
||||
assert.containsN($sp, ".o_search_panel_section", 2);
|
||||
assert.containsOnce($sp, ".o_search_panel_section.o_search_panel_category");
|
||||
assert.containsOnce($sp, ".o_search_panel_section.o_search_panel_filter");
|
||||
assert.containsN($sp, ".o_search_panel_category_value", 3);
|
||||
assert.containsOnce($sp, ".o_search_panel_category_value > header.active", 3);
|
||||
assert.containsN($sp, ".o_search_panel_filter_value", 3);
|
||||
|
||||
// select category
|
||||
await dom.click($sp.find(".o_search_panel_category_value:contains(hello) header"));
|
||||
|
||||
assert.verifySteps([
|
||||
"search_panel_select_range",
|
||||
"search_panel_select_multi_range",
|
||||
]);
|
||||
|
||||
// select filter
|
||||
await dom.click($sp.find(".o_search_panel_filter_value:contains(DEF) input"));
|
||||
|
||||
assert.verifySteps([
|
||||
"search_panel_select_range",
|
||||
"search_panel_select_multi_range",
|
||||
]);
|
||||
|
||||
// close with back button
|
||||
await dom.click($sp.find(".o_mobile_search_header button"));
|
||||
$sp = $(".o_search_panel");
|
||||
|
||||
assert.containsOnce(kanban, ".o_search_panel.o_search_panel_summary");
|
||||
assert.containsNone(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
|
||||
|
||||
// selection is kept when closed
|
||||
|
||||
assert.containsOnce($sp, ".o_search_panel_current_selection");
|
||||
assert.containsOnce($sp, ".o_search_panel_category:contains(hello)");
|
||||
assert.containsOnce($sp, ".o_search_panel_filter:contains(DEF)");
|
||||
|
||||
// open the search panel
|
||||
await dom.click($sp);
|
||||
$sp = $(".o_search_panel");
|
||||
|
||||
assert.containsOnce($sp, ".o_search_panel_category_value > header.active:contains(hello)");
|
||||
assert.containsOnce($sp, ".o_search_panel_filter_value:contains(DEF) input:checked");
|
||||
|
||||
assert.containsNone(kanban, ".o_search_panel.o_search_panel_summary");
|
||||
assert.containsOnce(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
|
||||
|
||||
// close with bottom button
|
||||
await dom.click($sp.find("button.o_mobile_search_footer"));
|
||||
|
||||
assert.containsOnce(kanban, ".o_search_panel.o_search_panel_summary");
|
||||
assert.containsNone(document.body, "div.o_search_panel.o_searchview.o_mobile_search");
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
|
||||
QUnit.module('KanbanView Mobile');
|
||||
|
||||
QUnit.test('mobile no quick create column when grouping on non m2o field', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<kanban class="o_kanban_test o_kanban_small_column" on_create="quick_create">' +
|
||||
'<templates><t t-name="kanban-box">' +
|
||||
'<div><field name="foo"/></div>' +
|
||||
'<div><field name="int_field"/></div>' +
|
||||
'</t></templates>' +
|
||||
'</kanban>',
|
||||
groupBy: ['int_field'],
|
||||
});
|
||||
|
||||
assert.containsNone(kanban, '.o_kanban_mobile_add_column', "should not have the add column button");
|
||||
assert.containsNone(kanban.$('.o_column_quick_create'),
|
||||
"should not have column quick create tab as we grouped records on integer field");
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("autofocus quick create form", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: "partner",
|
||||
data: this.data,
|
||||
arch: `<kanban on_create="quick_create">
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="foo"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
groupBy: ["product_id"],
|
||||
});
|
||||
|
||||
// quick create in first column
|
||||
await dom.click(kanban.$buttons.find(".o-kanban-button-new"));
|
||||
assert.ok(kanban.$(".o_kanban_group:nth(0) > div:nth(1)").hasClass("o_kanban_quick_create"),
|
||||
"clicking on create should open the quick_create in the first column");
|
||||
assert.strictEqual(document.activeElement, kanban.$(".o_kanban_quick_create .o_input:first")[0],
|
||||
"the first input field should get the focus when the quick_create is opened");
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
229
web_enterprise/static/tests/legacy/views/list_mobile_tests.js
Normal file
229
web_enterprise/static/tests/legacy/views/list_mobile_tests.js
Normal file
@@ -0,0 +1,229 @@
|
||||
odoo.define('web_enterprise.list_mobile_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const ListRenderer = require('web.ListRenderer');
|
||||
const ListView = require('web.ListView');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
const { createView, dom, mock } = testUtils;
|
||||
|
||||
QUnit.module("LegacyViews", {
|
||||
beforeEach() {
|
||||
this.data = {
|
||||
foo: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char" },
|
||||
bar: { string: "Bar", type: "boolean" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, bar: true, foo: "yop" },
|
||||
{ id: 2, bar: true, foo: "blip" },
|
||||
{ id: 3, bar: true, foo: "gnap" },
|
||||
{ id: 4, bar: false, foo: "blip" },
|
||||
],
|
||||
},
|
||||
};
|
||||
mock.patch(ListRenderer, {
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.LONG_TOUCH_THRESHOLD = 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
afterEach() {
|
||||
mock.unpatch(ListRenderer);
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.module("ListView (legacy) - Mobile");
|
||||
|
||||
QUnit.test("selection is properly displayed (single page)", async function (assert) {
|
||||
assert.expect(10);
|
||||
|
||||
const list = await createView({
|
||||
touchScreen: true,
|
||||
arch: `
|
||||
<tree>
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
model: 'foo',
|
||||
viewOptions: { hasActionMenus: true },
|
||||
View: ListView,
|
||||
});
|
||||
|
||||
assert.containsN(list, '.o_data_row', 4);
|
||||
assert.containsNone(list, '.o_list_selection_box');
|
||||
|
||||
// select a record
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
|
||||
assert.containsOnce(list, '.o_list_selection_box');
|
||||
assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
|
||||
assert.ok(list.$('.o_list_selection_box').text().includes("1 selected"))
|
||||
// unselect a record
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
|
||||
assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
|
||||
|
||||
// select 2 records
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(1)'), 'touchstart');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(1)'), 'touchend');
|
||||
assert.ok(list.$('.o_list_selection_box').text().includes("2 selected"))
|
||||
assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
|
||||
await testUtils.controlPanel.toggleActionMenu(list);
|
||||
assert.deepEqual(testUtils.controlPanel.getMenuItemTexts(list), ['Delete'],
|
||||
'action menu should contain the Delete action');
|
||||
// unselect all
|
||||
await dom.click(list.$('.o_discard_selection'));
|
||||
await testUtils.nextTick();
|
||||
assert.containsNone(list, '.o_list_selection_box');
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("selection box is properly displayed (multi pages)", async function (assert) {
|
||||
assert.expect(13);
|
||||
const list = await createView({
|
||||
touchScreen: true,
|
||||
arch: `
|
||||
<tree limit="3">
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
model: 'foo',
|
||||
View: ListView,
|
||||
viewOptions: { hasActionMenus: true },
|
||||
});
|
||||
|
||||
assert.containsN(list, '.o_data_row', 3);
|
||||
assert.containsNone(list, '.o_list_selection_box');
|
||||
|
||||
// select a record
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
|
||||
|
||||
assert.containsOnce(list, '.o_list_selection_box');
|
||||
assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
|
||||
assert.strictEqual(list.$('.o_list_selection_box').text().replace(/\s+/g, ' '),
|
||||
" × 1 selected ");
|
||||
assert.containsOnce(list, '.o_list_selection_box');
|
||||
assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
|
||||
await testUtils.controlPanel.toggleActionMenu(list);
|
||||
assert.deepEqual(testUtils.controlPanel.getMenuItemTexts(list), ['Delete'],
|
||||
'action menu should contain the Delete action');
|
||||
// select all records of first page
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(1)'), 'touchstart');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(1)'), 'touchend');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(2)'), 'touchstart');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(2)'), 'touchend');
|
||||
assert.containsOnce(list, '.o_list_selection_box');
|
||||
assert.containsOnce(list.$('.o_list_selection_box'), '.o_list_select_domain');
|
||||
assert.strictEqual(list.$('.o_list_selection_box').text().replace(/\s+/g, ' ').trim(),
|
||||
"× 3 selected Select all 4");
|
||||
|
||||
// select all domain
|
||||
await dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
|
||||
|
||||
assert.containsOnce(list, '.o_list_selection_box');
|
||||
assert.strictEqual(list.$('.o_list_selection_box').text().replace(/\s+/g, ' ').trim(),
|
||||
"× All 4 selected");
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("export button is properly hidden", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const list = await createView({
|
||||
touchScreen: true,
|
||||
arch: `
|
||||
<tree>
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
model: 'foo',
|
||||
View: ListView,
|
||||
session: {
|
||||
async user_has_group(group) {
|
||||
if (group === 'base.group_allow_export') {
|
||||
return true;
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(list, '.o_data_row', 4);
|
||||
assert.isNotVisible(list.$buttons.find('.o_list_export_xlsx'));
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('editable readonly list view is disabled', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const list = await createView({
|
||||
touchScreen: true,
|
||||
arch: `
|
||||
<tree>
|
||||
<field name="foo"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
model: 'foo',
|
||||
View: ListView,
|
||||
});
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchstart');
|
||||
await dom.triggerEvent(list.$('.o_data_row:eq(0)'), 'touchend');
|
||||
await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
|
||||
assert.containsNone(list, '.o_selected_row .o_field_widget[name=foo]',
|
||||
"The listview should not contains an edit field");
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("add custom field button not shown in mobile (with opt. col.)", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const list = await testUtils.createView({
|
||||
arch: `
|
||||
<tree>
|
||||
<field name="foo"/>
|
||||
<field name="bar" optional="hide"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
model: 'foo',
|
||||
touchScreen: true,
|
||||
View: ListView,
|
||||
});
|
||||
assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle');
|
||||
await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
|
||||
const $dropdown = list.$('div.o_optional_columns');
|
||||
assert.containsOnce($dropdown, 'div.dropdown-item');
|
||||
assert.containsNone($dropdown, 'button.dropdown-item-studio');
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("add custom field button not shown to non-system users (wo opt. col.)", async function (assert) {
|
||||
assert.expect(1);
|
||||
const list = await testUtils.createView({
|
||||
arch: `
|
||||
<tree>
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
model: 'foo',
|
||||
touchScreen: true,
|
||||
View: ListView,
|
||||
});
|
||||
assert.containsNone(list.$('table'), '.o_optional_columns_dropdown_toggle');
|
||||
list.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user