Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,29 @@
/** @odoo-module **/
import { ActionDialog } from "./action_dialog";
import { Component, xml, onWillDestroy } from "@odoo/owl";
// -----------------------------------------------------------------------------
// ActionContainer (Component)
// -----------------------------------------------------------------------------
export class ActionContainer extends Component {
setup() {
this.info = {};
this.onActionManagerUpdate = ({ detail: info }) => {
this.info = info;
this.render();
};
this.env.bus.addEventListener("ACTION_MANAGER:UPDATE", this.onActionManagerUpdate);
onWillDestroy(() => {
this.env.bus.removeEventListener("ACTION_MANAGER:UPDATE", this.onActionManagerUpdate);
});
}
}
ActionContainer.components = { ActionDialog };
ActionContainer.template = xml`
<t t-name="web.ActionContainer">
<div class="o_action_manager">
<t t-if="info.Component" t-component="info.Component" className="'o_action'" t-props="info.componentProps" t-key="info.id"/>
</div>
</t>`;

View file

@ -0,0 +1,83 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { DebugMenu } from "@web/core/debug/debug_menu";
import { useOwnDebugContext } from "@web/core/debug/debug_context";
import { useLegacyRefs } from "@web/legacy/utils";
import { useEffect } from "@odoo/owl";
const LEGACY_SIZE_CLASSES = {
"extra-large": "xl",
large: "lg",
medium: "md",
small: "sm",
};
// -----------------------------------------------------------------------------
// Action Dialog (Component)
// -----------------------------------------------------------------------------
class ActionDialog extends Dialog {
setup() {
super.setup();
useOwnDebugContext();
useEffect(
() => {
if (this.modalRef.el.querySelector(".modal-footer").childElementCount > 1) {
const defaultButton = this.modalRef.el.querySelector(
".modal-footer button.o-default-button"
);
defaultButton.classList.add("d-none");
}
},
() => []
);
}
}
ActionDialog.components = { ...Dialog.components, DebugMenu };
ActionDialog.template = "web.ActionDialog";
ActionDialog.props = {
...Dialog.props,
close: Function,
slots: { optional: true },
ActionComponent: { optional: true },
actionProps: { optional: true },
actionType: { optional: true },
title: { optional: true },
};
ActionDialog.defaultProps = {
...Dialog.defaultProps,
withBodyPadding: false,
};
/**
* This LegacyAdaptedActionDialog class will disappear when legacy code will be entirely rewritten.
* The "ActionDialog" class should get exported from this file when the cleaning will occur, and it
* should stop extending Dialog and use it normally instead at that point.
*/
class LegacyAdaptedActionDialog extends ActionDialog {
setup() {
super.setup();
const actionProps = this.props && this.props.actionProps;
const actionContext = actionProps && actionProps.context;
const actionDialogSize = actionContext && actionContext.dialog_size;
this.props.size = LEGACY_SIZE_CLASSES[actionDialogSize] || Dialog.defaultProps.size;
const ControllerComponent = this.props && this.props.ActionComponent;
const Controller = ControllerComponent && ControllerComponent.Component;
this.isLegacy = Controller && Controller.isLegacy;
const legacyRefs = useLegacyRefs();
useEffect(
() => {
if (this.isLegacy) {
// Render legacy footer buttons
const footer = this.modalRef.el.querySelector("footer");
legacyRefs.widget.renderButtons($(footer));
}
},
() => []
);
}
}
LegacyAdaptedActionDialog.template = "web.LegacyAdaptedActionDialog";
export { LegacyAdaptedActionDialog as ActionDialog };

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ActionDialog.header" t-inherit="web.Dialog.header" t-inherit-mode="primary" owl="1">
<xpath expr="//h4[contains(concat(' ',normalize-space(@class),' '),' modal-title ')]" position="before">
<DebugMenu t-if="env.debug" />
</xpath>
</t>
<t t-name="web.ActionDialog" t-inherit="web.Dialog" t-inherit-mode="primary" owl="1">
<xpath expr="//main[hasclass('modal-body')]" position="attributes">
<attribute name="t-att-class">
{"o_act_window": props.actionType === "ir.actions.act_window"}
</attribute>
</xpath>
<xpath expr="//t[@t-slot='default']" position="replace">
<t t-if="props.ActionComponent" t-component="props.ActionComponent" t-props="props.actionProps"/>
</xpath>
<xpath expr="//t[@t-slot='header']" position="replace">
<t t-call="web.ActionDialog.header">
<t t-set="close" t-value="props.close"/>
<t t-set="fullscreen" t-value="props.isFullscreen"/>
</t>
</xpath>
</t>
<t t-name="web.LegacyAdaptedActionDialog" t-inherit="web.ActionDialog" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-slot='footer']" position="replace">
<t t-if="!isLegacy">
<t t-slot="buttons">
<button class="btn btn-primary o-default-button" t-on-click="data.close">Ok</button>
</t>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,159 @@
/** @odoo-module **/
import { onMounted, useComponent, useEffect, useExternalListener } from "@odoo/owl";
const scrollSymbol = Symbol("scroll");
export class CallbackRecorder {
constructor() {
this.setup();
}
setup() {
this._callbacks = [];
}
/**
* @returns {Function[]}
*/
get callbacks() {
return this._callbacks.map(({ callback }) => callback);
}
/**
* @param {any} owner
* @param {Function} callback
*/
add(owner, callback) {
if (!callback) {
throw new Error("Missing callback");
}
this._callbacks.push({ owner, callback });
}
/**
* @param {any} owner
*/
remove(owner) {
this._callbacks = this._callbacks.filter((s) => s.owner !== owner);
}
}
/**
* @param {CallbackRecorder} callbackRecorder
* @param {Function} callback
*/
export function useCallbackRecorder(callbackRecorder, callback) {
const component = useComponent();
useEffect(
() => {
callbackRecorder.add(component, callback);
return () => callbackRecorder.remove(component);
},
() => []
);
}
/**
*/
export function useSetupAction(params = {}) {
const component = useComponent();
const {
__beforeLeave__,
__getGlobalState__,
__getLocalState__,
__getContext__,
__getOrderBy__,
} = component.env;
const { beforeUnload, beforeLeave, getGlobalState, getLocalState, rootRef } = params;
if (beforeUnload) {
useExternalListener(window, "beforeunload", beforeUnload);
}
if (__beforeLeave__ && beforeLeave) {
useCallbackRecorder(__beforeLeave__, beforeLeave);
}
if (__getGlobalState__ && (getGlobalState || rootRef)) {
useCallbackRecorder(__getGlobalState__, () => {
const state = {};
if (getGlobalState) {
Object.assign(state, getGlobalState());
}
if (rootRef) {
const searchPanelEl = rootRef.el.querySelector(".o_content .o_search_panel");
if (searchPanelEl) {
state[scrollSymbol] = {
searchPanel: {
left: searchPanelEl.scrollLeft,
top: searchPanelEl.scrollTop,
},
};
}
}
return state;
});
if (rootRef) {
onMounted(() => {
const { globalState } = component.props;
const scrolling = globalState && globalState[scrollSymbol];
if (scrolling) {
const searchPanelEl = rootRef.el.querySelector(".o_content .o_search_panel");
if (searchPanelEl) {
searchPanelEl.scrollLeft =
(scrolling.searchPanel && scrolling.searchPanel.left) || 0;
searchPanelEl.scrollTop =
(scrolling.searchPanel && scrolling.searchPanel.top) || 0;
}
}
});
}
}
if (__getLocalState__ && (getLocalState || rootRef)) {
useCallbackRecorder(__getLocalState__, () => {
const state = {};
if (getLocalState) {
Object.assign(state, getLocalState());
}
if (rootRef) {
if (component.env.isSmall) {
state[scrollSymbol] = {
root: { left: rootRef.el.scrollLeft, top: rootRef.el.scrollTop },
};
} else {
const contentEl = rootRef.el.querySelector(".o_component_with_search_panel > .o_renderer_with_searchpanel,"
+ ".o_component_with_search_panel > .o_renderer") || rootRef.el.querySelector(".o_content");
if (contentEl) {
state[scrollSymbol] = {
content: { left: contentEl.scrollLeft, top: contentEl.scrollTop },
};
}
}
}
return state;
});
if (rootRef) {
onMounted(() => {
const { state } = component.props;
const scrolling = state && state[scrollSymbol];
if (scrolling) {
if (component.env.isSmall) {
rootRef.el.scrollTop = (scrolling.root && scrolling.root.top) || 0;
rootRef.el.scrollLeft = (scrolling.root && scrolling.root.left) || 0;
} else if (scrolling.content) {
const contentEl = rootRef.el.querySelector(".o_component_with_search_panel > .o_renderer_with_searchpanel,"
+ ".o_component_with_search_panel > .o_renderer") || rootRef.el.querySelector(".o_content");
if (contentEl) {
contentEl.scrollTop = scrolling.content.top || 0;
contentEl.scrollLeft = scrolling.content.left || 0;
}
}
}
});
}
}
if (__getContext__ && params.getContext) {
useCallbackRecorder(__getContext__, params.getContext);
}
if (__getOrderBy__ && params.getOrderBy) {
useCallbackRecorder(__getOrderBy__, params.getOrderBy);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,56 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { escape, sprintf } from "@web/core/utils/strings";
import { Component, onMounted, xml } from "@odoo/owl";
export function displayNotificationAction(env, action) {
const params = action.params || {};
const options = {
className: params.className || "",
sticky: params.sticky || false,
title: params.title,
type: params.type || "info",
};
const links = (params.links || []).map((link) => {
return `<a href="${escape(link.url)}" target="_blank">${escape(link.label)}</a>`;
});
const message = owl.markup(sprintf(escape(params.message), ...links));
env.services.notification.add(message, options);
return params.next;
}
registry.category("actions").add("display_notification", displayNotificationAction);
class InvalidAction extends Component {
setup() {
this.notification = useService("notification");
onMounted(this.onMounted);
}
onMounted() {
const message = sprintf(
this.env._t("No action with id '%s' could be found"),
this.props.actionId
);
this.notification.add(message, { type: "danger" });
}
}
InvalidAction.template = xml`<div class="o_invalid_action"></div>`;
registry.category("actions").add("invalid_action", InvalidAction);
/**
* Client action to restore the current controller
* Serves as a trigger to reload the interface without a full browser reload
*/
async function softReload(env, action) {
const controller = env.services.action.currentController;
if (controller) {
env.services.action.restore(controller.jsId);
}
}
registry.category("actions").add("soft_reload", softReload);

View file

@ -0,0 +1,176 @@
/** @odoo-module **/
import { editModelDebug } from "@web/core/debug/debug_utils";
import { registry } from "@web/core/registry";
const debugRegistry = registry.category("debug");
function actionSeparator({ action }) {
if (!action.id || !action.res_model) {
return null;
}
return {
type: "separator",
sequence: 100,
};
}
function accessSeparator({ accessRights, action }) {
const { canSeeModelAccess, canSeeRecordRules } = accessRights;
if (!action.res_model || (!canSeeModelAccess && !canSeeRecordRules)) {
return null;
}
return {
type: "separator",
sequence: 200,
};
}
function editAction({ action, env }) {
if (!action.id) {
return null;
}
const description = env._t("Edit Action");
return {
type: "item",
description,
callback: () => {
editModelDebug(env, description, action.type, action.id);
},
sequence: 110,
};
}
function viewFields({ action, env }) {
if (!action.res_model) {
return null;
}
const description = env._t("View Fields");
return {
type: "item",
description,
callback: async () => {
const modelId = (
await env.services.orm.search("ir.model", [["model", "=", action.res_model]], {
limit: 1,
})
)[0];
env.services.action.doAction({
res_model: "ir.model.fields",
name: description,
views: [
[false, "list"],
[false, "form"],
],
domain: [["model_id", "=", modelId]],
type: "ir.actions.act_window",
context: {
default_model_id: modelId,
},
});
},
sequence: 120,
};
}
function manageFilters({ action, env }) {
if (!action.res_model) {
return null;
}
const description = env._t("Manage Filters");
return {
type: "item",
description,
callback: () => {
// manage_filters
env.services.action.doAction({
res_model: "ir.filters",
name: description,
views: [
[false, "list"],
[false, "form"],
],
type: "ir.actions.act_window",
context: {
search_default_my_filters: true,
search_default_model_id: action.res_model,
},
});
},
sequence: 130,
};
}
function viewAccessRights({ accessRights, action, env }) {
if (!action.res_model || !accessRights.canSeeModelAccess) {
return null;
}
const description = env._t("View Access Rights");
return {
type: "item",
description,
callback: async () => {
const modelId = (
await env.services.orm.search("ir.model", [["model", "=", action.res_model]], {
limit: 1,
})
)[0];
env.services.action.doAction({
res_model: "ir.model.access",
name: description,
views: [
[false, "list"],
[false, "form"],
],
domain: [["model_id", "=", modelId]],
type: "ir.actions.act_window",
context: {
default_model_id: modelId,
},
});
},
sequence: 210,
};
}
function viewRecordRules({ accessRights, action, env }) {
if (!action.res_model || !accessRights.canSeeRecordRules) {
return null;
}
const description = env._t("Model Record Rules");
return {
type: "item",
description: env._t("View Record Rules"),
callback: async () => {
const modelId = (
await env.services.orm.search("ir.model", [["model", "=", action.res_model]], {
limit: 1,
})
)[0];
env.services.action.doAction({
res_model: "ir.rule",
name: description,
views: [
[false, "list"],
[false, "form"],
],
domain: [["model_id", "=", modelId]],
type: "ir.actions.act_window",
context: {
default_model_id: modelId,
},
});
},
sequence: 220,
};
}
debugRegistry
.category("action")
.add("actionSeparator", actionSeparator)
.add("editAction", editAction)
.add("viewFields", viewFields)
.add("manageFilters", manageFilters)
.add("accessSeparator", accessSeparator)
.add("viewAccessRights", viewAccessRights)
.add("viewRecordRules", viewRecordRules);

View file

@ -0,0 +1,5 @@
$link-decoration: none !default;
$container-padding-x: 15px; // Like in BS4
// remove Emoji fonts
$font-family-sans-serif: o-add-unicode-support-font(("Lucida Grande", Helvetica, Verdana, Arial, sans-serif), 1);

View file

@ -0,0 +1,99 @@
.o_background_footer, .o_background_header, .o_report_layout_striped {
color: map-get($grays, '700');
}
.o_background_header {
min-width: 900px;
border-bottom: 1px solid map-get($grays, '200');
img {
max-height: 96px;
max-width: 200px;
margin-right: 16px;
}
h3 {
color: $o-default-report-primary-color;
font-weight: 700;
font-size: 1.25rem;
max-width: 300px;
}
}
.o_background_footer {
.list-inline-item {
white-space: nowrap;
}
ul {
border-top: 1px solid $o-default-report-secondary-color;
border-bottom: 1px solid $o-default-report-secondary-color;
padding: 4px 0;
margin: 0 0 4px 0;
li {
color: $o-default-report-secondary-color;
}
}
}
.o_report_layout_background {
background-size: cover;
background-position: bottom center;
background-repeat: no-repeat;
min-height: 620px
}
.o_report_layout_striped {
strong {
color: $o-default-report-secondary-color;
}
.table {
border-top: 1px solid map-get($grays, '300');
}
.table td, .table th {
border-top: none;
}
h2 {
color: $o-default-report-primary-color;
}
thead tr th {
color: $o-default-report-secondary-color
}
tbody {
color: map-get($grays, '700');
tr {
&:nth-child(odd) {
background-color: rgba(220, 205, 216, 0.2);
}
&.o_line_section {
color: $o-brand-odoo;
background-color: rgba(73, 80, 87, 0.2) !important;
}
}
}
/*Total table*/
/* row div rule compat 12.0 */
.row > div > table,
div#total table {
tr {
&:nth-child(odd) {
background-color: transparent !important;
}
/* first-child & last-child rule compat 12.0 */
&:first-child,
&:last-child,
&.o_subtotal,
&.o_total {
border-top: none !important;
td {
border-top: 1px solid map-get($grays, '400') !important;
}
strong {
color: $o-default-report-primary-color;
}
}
}
}
}
/* special case for displaying report in iframe */
.o_in_iframe {
.o_background_header {
min-width: 0;
}
}

View file

@ -0,0 +1,113 @@
.o_boxed_footer, .o_boxed_header, .o_report_layout_boxed {
color: map-get($grays, '700');
font-size: 15px;
}
.o_boxed_header {
border-bottom: 1px solid map-get($grays, '200');
img {
max-height: 100px;
}
h4 {
margin-bottom: 0;
color: #999999;
font-weight: 700;
text-transform: uppercase;
}
ul, p {
margin-bottom: 0;
}
}
.o_boxed_footer {
margin-top: 200px;
border-top: 3px solid $o-default-report-secondary-color;
ul {
margin: 4px 0;
}
}
.o_report_layout_boxed {
#total strong {
color: $o-default-report-primary-color;
}
#informations strong {
color: $o-default-report-secondary-color;
}
> h2 {
text-transform: uppercase;
}
h2 span {
color: $o-default-report-primary-color;
}
table {
border: 1px solid map-get($grays, '700');
thead {
border-bottom: 2px solid map-get($grays, '700');
tr th {
text-transform: uppercase;
border: 1px solid map-get($grays, '700');
color: $o-default-report-secondary-color;
}
}
tbody {
color: map-get($grays, '700');
tr {
td {
// remove border-top from standard layout
border-top: none;
border-right: 1px solid map-get($grays, '700');
}
&.o_line_section td,
&.o_line_note td,
&.is-subtotal td {
border-top: 1px solid map-get($grays, '700');
border-bottom: 1px solid map-get($grays, '700');
}
&.o_line_section td {
background-color: rgba($o-default-report-primary-color, 0.7);
color: #fff;
}
&.is-subtotal,
td.o_price_total {
background-color: rgba($o-default-report-secondary-color, 0.1);
}
}
}
}
/* compat 12.0 */
.page > table:not(.o_main_table) tr td:last-child {
background-color: map-get($grays, '200');
color: $o-default-report-primary-color;
}
/* compat 12.0 */
.row:not(#total) > div > table tbody tr:not(:last-child) td:last-child {
background-color: map-get($grays, '200');
color: $o-default-report-primary-color;
}
/*Total table*/
/* row div rule compat 12.0 */
.row > div > table,
div#total table {
thead tr:first-child,
tr.o_subtotal {
border-bottom: 1px solid map-get($grays, '700');
}
tr {
&.o_subtotal{
td:first-child {
border-right: none;
}
}
&:last-child td,
&.o_total td {
background-color: rgba($o-default-report-primary-color, 0.9);
color: #fff;
&:first-child {
border-right: none;
}
}
&.o_total strong {
color: white;
}
}
}
}

View file

@ -0,0 +1,85 @@
.o_clean_footer, .o_clean_header, .o_report_layout_bold {
color: #000;
}
.o_clean_header img {
max-height: 90px;
max-width: 300px;
}
.o_clean_footer {
margin: 0 3px;
margin-top: 200px;
border-top: 3px solid $o-default-report-secondary-color;
h4 {
color: $o-default-report-secondary-color;
font-weight: bolder;
}
.pagenumber {
border: 3px solid $o-default-report-primary-color;
background-color: $o-default-report-secondary-color;
color: white;
padding: 4px 8px;
text-align: center;
}
}
.o_report_layout_bold {
h1, h2, h3 {
color: $o-default-report-primary-color;
font-weight: bolder;
}
strong {
color: $o-default-report-secondary-color;
}
table {
&.o_main_table {
margin-bottom: 0;
}
thead {
color: $o-default-report-secondary-color;
tr th {
border-top: 3px solid $o-default-report-secondary-color !important;
text-transform: uppercase;
}
}
tbody {
color: #000;
tr:first-child td {
border-top: none;
}
tr:last-child td {
border-bottom: 3px solid $o-default-report-secondary-color;
}
tr {
td {
padding: 15px 5px;
}
td:last-child {
}
}
}
}
#total {
strong {
color: $o-default-report-secondary-color;
}
}
/*Total table*/
/* compat 12.0 */
.row:not(#total) > div:has(table) {
top: -16px;
}
/* row col rule compat 12.0 */
.row > div > table,
div#total table {
tr {
&:first-child td,
&.o_subtotal {
border-top: none !important;
}
&:last-child td,
&.o_total {
border-top: 1px solid map-get($grays, '200') !important;
}
}
}
}

View file

@ -0,0 +1,29 @@
.o_standard_footer {
margin-top: 200px;
.list-inline-item {
white-space: nowrap;
}
}
.o_report_layout_standard {
h2 {
color: $o-default-report-primary-color;
}
#informations strong {
color: $o-default-report-secondary-color;
}
#total strong{
color: $o-default-report-primary-color;
}
table {
thead {
color: $o-default-report-secondary-color;
}
}
div[name=comment] p{
margin-bottom: 0;
}
}

View file

@ -0,0 +1,232 @@
$o-default-report-font: 'Lato' !default;
$o-default-report-primary-color: rgb(0, 0, 0) !default;
$o-default-report-secondary-color: rgb(0, 0, 0) !default;
// wkhtmltopdf doesn't support CSS custom properties (--name-stuff)
// It doesn't support CSS's rgba function
// We re-define here the .bg-[theme-colors] (and text)
// In order to define them as a css rgb function
// The gray scales are done in bootstrap_review.scss
$report-extended-theme-colors: map-merge(
$utilities-colors,
(
"black": to-rgb($black),
"white": to-rgb($white),
"body": to-rgb($body-bg)
)
);
@each $color-name, $color-value in $report-extended-theme-colors {
.text-#{$color-name} {
color: RGB($color-value) if($enable-important-utilities, !important, null);
}
.bg-#{$color-name} {
background-color: RGB($color-value) if($enable-important-utilities, !important, null);
}
}
html, body {
height: 100%;
direction: ltr;
}
body {
color: #000 !important;
word-wrap: break-word;
font-family: $o-default-report-font;
}
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1, .h1 {
font-size: 2.5rem;
}
h2, .h2 {
font-size: 2rem;
}
h3, .h3 {
font-size: 1.75rem;
}
h4, .h4 {
font-size: 1.5rem;
}
h5, .h5 {
font-size: 1.25rem;
}
h6, .h6 {
font-size: 1rem;
}
p, span, strong, em {
line-height: 1.5;
}
span.o_force_ltr {
display: inline;
}
.o_force_ltr, .o_field_phone {
unicode-bidi: embed; // ensure element has level of embedding for direction
/*rtl:ignore*/
direction: ltr;
}
.border-black td, .border-black th {
border-top: 1px solid black !important;
}
.table-sm {
> thead > tr > th {
border-bottom: none !important;
}
> tbody > tr {
border-top: none !important;
}
}
.zero_min_height {
min-height: 0px !important;
}
/* To avoid broken snippets in report rendering */
.jumbotron, .panel, .carousel, section{
page-break-inside: avoid;
}
/* Wkhtmltopdf doesn't support very well the media-print CSS (depends on the version) */
.d-print-none{
display: none;
}
.o_bold {
font-weight: bolder;
}
.o_italic {
font-style: italic;
}
.o_underline {
text-decoration: underline;
}
/*Total table*/
div#total {
page-break-inside: avoid;
table {
tr {
&.o_subtotal,
&.o_total {
td {
border-top: 1px solid black !important;
}
&.o_border_bottom {
td {
border-bottom: 1px solid black !important;
}
}
}
}
}
}
table {
&.table {
td {
vertical-align: $table-cell-vertical-align;
}
}
thead {
&.o_black_border {
tr {
th {
border-bottom: 2px solid black !important;
}
}
}
}
}
// TODO: investigate further for proper fix
// By default there is 40px padding on the lists (both <ol> and <ul>) and it is
// also the case with HTML reports, but when PDFs are rendered with Wkhtmltopdf,
// the padding is not applied on <ol>, it is strangely applied on <ul> only.
// So we simply remove the left padding, and apply left margin instead which
// seems to do the job while not breaking layout in both html/pdf.
ol {
margin-left: 40px;
padding-left: 0;
}
/* Checklist */
ul {
&.o_checklist {
> li {
list-style: none;
position: relative;
margin-left: 20px;
}
> li:not(.oe-nested):before {
content: '';
position: absolute;
left: -20px;
display: block;
height: 14px;
width: 14px;
top: 1px;
border: 1px solid;
cursor: pointer;
}
> li.o_checked:after {
content: "";
transition: opacity .5s;
position: absolute;
left: -18px;
top: -1px;
opacity: 1;
}
}
}
blockquote {
padding: $spacer/2 $spacer;
border-left: 5px solid;
border-color: map-get($grays, '300');
font-style: italic;
}
// Wkhtmltopdf doesn't handle flexbox properly, both the content
// of columns and columns themselves does not wrap over new lines
// when needed: the font of the pdf will reduce to make the content
// fit the page format.
// A (weak) solution is to force the content on one line and
// impose the width, so to have evenly size columns.
// This should work fine in most cases, but will yield ugly results
// when 6+ columns are rendered
.col-auto{
-webkit-box-flex: 1 !important;
}
// Boostrap 5 introduces variable paddings for container which wkhtmltopdf doesn't seem to process, so we restore Boostrap 4's paddings for PDFs
.container {
padding-right: $container-padding-x;
padding-left: $container-padding-x;
}
// Removes borders within a table-borderless as its new definition in Boostrap 5 still has its borders visible in PDFs
.table-borderless {
tbody, thead, tfoot, tr, td, th {
border: 0 none;
}
> :not(:first-child) {
border-top-style: none;
}
}
li.oe-nested {
display: block;
}

View file

@ -0,0 +1,58 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
import { getDefaultConfig } from "@web/views/view";
import { useSetupAction } from "@web/webclient/actions/action_hook";
import { useEnrichWithActionLinks } from "@web/webclient/actions/reports/report_hook";
import { Component, useRef, useSubEnv } from "@odoo/owl";
/**
* Most of the time reports are printed as pdfs.
* However, reports have 3 possible actions: pdf, text and HTML.
* This file is the HTML action.
* The HTML action is a client action (with control panel) rendering the template in an iframe.
* If not defined as the default action, the HTML is the fallback to pdf if wkhtmltopdf is not available.
*
* It has a button to print the report.
* It uses a feature to automatically create links to other odoo pages if the selector [res-id][res-model][view-type]
* is detected.
*/
export class ReportAction extends Component {
setup() {
useSubEnv({
config: {
...getDefaultConfig(),
...this.env.config,
},
});
useSetupAction();
this.action = useService("action");
this.title = this.props.display_name || this.props.name;
this.reportUrl = this.props.report_url;
this.iframe = useRef("iframe");
useEnrichWithActionLinks(this.iframe);
}
onIframeLoaded(ev) {
const iframeDocument = ev.target.contentWindow.document;
iframeDocument.body.classList.add("o_in_iframe", "container-fluid");
iframeDocument.body.classList.remove("container");
}
print() {
this.action.doAction({
type: "ir.actions.report",
report_type: "qweb-pdf",
report_name: this.props.report_name,
report_file: this.props.report_file,
data: this.props.data || {},
context: this.props.context || {},
display_name: this.title,
});
}
}
ReportAction.components = { Layout };
ReportAction.template = "web.ReportAction";

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="web.ReportAction" owl="1">
<div class="o_action">
<Layout display="{ controlPanel: { 'top-right' : false, 'bottom-right': false } }">
<t t-set-slot="control-panel-bottom-left">
<button t-on-click="print" type="button" class="btn btn-primary" title="Print">Print</button>
</t>
<iframe t-ref="iframe" t-on-load="onIframeLoaded" class="h-100 w-100" t-att-src="reportUrl" />
</Layout>
</div>
</t>
</templates>

View file

@ -0,0 +1,67 @@
/** @odoo-module */
import { useComponent, useEffect } from "@odoo/owl";
/**
* Hook used to enrich html and provide automatic links to action.
* Dom elements must have those attrs [res-id][res-model][view-type]
* Each element with those attrs will become a link to the specified resource.
* Works with Iframes.
*
* @param {owl reference} ref Owl ref to the element to enrich
* @param {string} [selector] Selector to apply to the element resolved by the ref.
*/
export function useEnrichWithActionLinks(ref, selector = null) {
const comp = useComponent();
useEffect(
(element) => {
// If we get an iframe, we need to wait until everything is loaded
if (element.matches("iframe")) {
element.onload = () => enrich(comp, element, selector, true);
} else {
enrich(comp, element, selector);
}
},
() => [ref.el]
);
}
function enrich(component, targetElement, selector, isIFrame = false) {
let doc = window.document;
// If we are in an iframe, we need to take the right document
// both for the element and the doc
if (isIFrame) {
targetElement = targetElement.contentDocument;
doc = targetElement;
}
// If there are selector, we may have multiple blocks of code to enrich
const targets = [];
if (selector) {
targets.push(...targetElement.querySelectorAll(selector));
} else {
targets.push(targetElement);
}
// Search the elements with the selector, update them and bind an action.
for (const currentTarget of targets) {
const elementsToWrap = currentTarget.querySelectorAll("[res-id][res-model][view-type]");
for (const element of elementsToWrap.values()) {
const wrapper = doc.createElement("a");
wrapper.setAttribute("href", "#");
wrapper.addEventListener("click", (ev) => {
ev.preventDefault();
component.env.services.action.doAction({
type: "ir.actions.act_window",
view_mode: element.getAttribute("view-type"),
res_id: Number(element.getAttribute("res-id")),
res_model: element.getAttribute("res-model"),
views: [[element.getAttribute("view-id"), element.getAttribute("view-type")]],
});
});
element.parentNode.insertBefore(wrapper, element);
wrapper.appendChild(element);
}
}
}

View file

@ -0,0 +1,188 @@
/*
HTML5 CSS Reset
Based on Eric Meyer's CSS Reset
and html5doctor.com HTML5 Reset
Copyright (c) 2011 736 Computing Services Limited
Released under the MIT license. http://opensource.736cs.com/licenses/mit
*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
font,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
audio,
canvas,
details,
figcaption,
figure,
footer,
header,
hgroup,
mark,
menu,
meter,
nav,
output,
progress,
section,
summary,
time,
video {
margin: 0;
padding: 0;
border: 0;
outline: 0;
font-size: 100%;
vertical-align: baseline;
background: transparent;
}
body {
line-height: 1;
}
article,
aside,
dialog,
figure,
footer,
header,
hgroup,
nav,
section,
blockquote {
display: block;
}
nav ul {
list-style: none;
}
ol {
list-style: decimal;
}
ul {
list-style: disc;
padding-left: 40px;
}
ul ul {
list-style: circle;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
ins {
text-decoration: underline;
}
del {
text-decoration: line-through;
}
mark {
background: none;
}
abbr[title],
dfn[title] {
border-bottom: 1px dotted #000;
cursor: help;
}
/* tables still need 'cellspacing="0"' in the markup */
table {
border-collapse: collapse;
border-spacing: 0;
}
hr {
display: block;
height: 1px;
border: 0;
border-top: 1px solid #ccc;
margin: 1em 0;
padding: 0;
}
input[type="submit"],
input[type="button"],
button {
padding: 0 !important;
margin: 0 !important;
}
input,
select,
a img {
vertical-align: middle;
}

View file

@ -0,0 +1,155 @@
/** @odoo-module **/
/**
* Builder for BarcodeDetector-like polyfill class using ZXing library.
*
* @param {ZXing} ZXing Zxing library
* @returns {class} ZxingBarcodeDetector class
*/
export function buildZXingBarcodeDetector(ZXing) {
const ZXingFormats = new Map([
["aztec", ZXing.BarcodeFormat.AZTEC],
["code_39", ZXing.BarcodeFormat.CODE_39],
["code_128", ZXing.BarcodeFormat.CODE_128],
["data_matrix", ZXing.BarcodeFormat.DATA_MATRIX],
["ean_8", ZXing.BarcodeFormat.EAN_8],
["ean_13", ZXing.BarcodeFormat.EAN_13],
["itf", ZXing.BarcodeFormat.ITF],
["pdf417", ZXing.BarcodeFormat.PDF_417],
["qr_code", ZXing.BarcodeFormat.QR_CODE],
["upc_a", ZXing.BarcodeFormat.UPC_A],
["upc_e", ZXing.BarcodeFormat.UPC_E],
]);
const allSupportedFormats = Array.from(ZXingFormats.keys());
/**
* ZXingBarcodeDetector class
*
* BarcodeDetector-like polyfill class using ZXing library.
* API follows the Shape Detection Web API (specifically Barcode Detection).
*/
class ZXingBarcodeDetector {
/**
* @param {object} opts
* @param {Array} opts.formats list of codes' formats to detect
*/
constructor(opts = {}) {
const formats = opts.formats || allSupportedFormats;
const hints = new Map([
[
ZXing.DecodeHintType.POSSIBLE_FORMATS,
formats.map((format) => ZXingFormats.get(format)),
],
// Enable Scanning at 90 degrees rotation
// https://github.com/zxing-js/library/issues/291
[ZXing.DecodeHintType.TRY_HARDER, true],
]);
this.reader = new ZXing.MultiFormatReader();
this.reader.setHints(hints);
}
/**
* Detect codes in image.
*
* @param {HTMLVideoElement} video source video element
* @returns {Promise<Array>} array of detected codes
*/
async detect(video) {
if (!(video instanceof HTMLVideoElement)) {
throw new DOMException(
"imageDataFrom() requires an HTMLVideoElement",
"InvalidArgumentError"
);
}
if (!isVideoElementReady(video)) {
throw new DOMException("HTMLVideoElement is not ready", "InvalidStateError");
}
const canvas = document.createElement("canvas");
let barcodeArea;
if (this.cropArea && (this.cropArea.x || this.cropArea.y)) {
barcodeArea = this.cropArea;
} else {
barcodeArea = {
x: 0,
y: 0,
width: video.videoWidth,
height: video.videoHeight,
};
}
canvas.width = barcodeArea.width;
canvas.height = barcodeArea.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(
video,
barcodeArea.x,
barcodeArea.y,
barcodeArea.width,
barcodeArea.height,
0,
0,
barcodeArea.width,
barcodeArea.height
);
const luminanceSource = new ZXing.HTMLCanvasElementLuminanceSource(canvas);
const binaryBitmap = new ZXing.BinaryBitmap(new ZXing.HybridBinarizer(luminanceSource));
try {
const result = this.reader.decode(binaryBitmap);
const { resultPoints } = result;
const boundingBox = DOMRectReadOnly.fromRect({
x: resultPoints[0].x,
y: resultPoints[0].y,
height: Math.max(1, Math.abs(resultPoints[1].y - resultPoints[0].y)),
width: Math.max(1, Math.abs(resultPoints[1].x - resultPoints[0].x)),
});
const cornerPoints = resultPoints;
const format = Array.from(ZXingFormats).find(
([k, val]) => val === result.getBarcodeFormat()
);
const rawValue = result.getText();
return [
{
boundingBox,
cornerPoints,
format,
rawValue,
},
];
} catch (err) {
if (err.name === "NotFoundException") {
return [];
}
throw err;
}
}
setCropArea(cropArea) {
this.cropArea = cropArea;
}
}
/**
* Supported codes formats
*
* @static
* @returns {Promise<string[]>}
*/
ZXingBarcodeDetector.getSupportedFormats = async () => allSupportedFormats;
return ZXingBarcodeDetector;
}
/**
* Check for HTMLVideoElement readiness.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
*/
const HAVE_NOTHING = 0;
const HAVE_METADATA = 1;
export function isVideoElementReady(video) {
return ![HAVE_NOTHING, HAVE_METADATA].includes(video.readyState);
}

View file

@ -0,0 +1,224 @@
/** @odoo-module **/
/* global BarcodeDetector */
import { browser } from "@web/core/browser/browser";
import Dialog from "web.OwlDialog";
import { delay } from "web.concurrency";
import { loadJS, templates } from "@web/core/assets";
import { isVideoElementReady, buildZXingBarcodeDetector } from "./ZXingBarcodeDetector";
import { CropOverlay } from "./crop_overlay";
import { Deferred } from "@web/core/utils/concurrency";
import {
App,
Component,
onMounted,
onWillStart,
onWillUnmount,
useRef,
useState,
} from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
export class BarcodeDialog extends Component {
/**
* @override
*/
setup() {
this.videoPreviewRef = useRef("videoPreview");
this.interval = null;
this.stream = null;
this.detector = null;
this.overlayInfo = {};
this.zoomRatio = 1;
this.state = useState({
isReady: false,
});
onWillStart(async () => {
let DetectorClass;
// Use Barcode Detection API if available.
// As support is still bleeding edge (mainly Chrome on Android),
// also provides a fallback using ZXing library.
if ("BarcodeDetector" in window) {
DetectorClass = BarcodeDetector;
} else {
await loadJS("/web/static/lib/zxing-library/zxing-library.js");
DetectorClass = buildZXingBarcodeDetector(window.ZXing);
}
const formats = await DetectorClass.getSupportedFormats();
this.detector = new DetectorClass({ formats });
});
onMounted(async () => {
const constraints = {
video: { facingMode: this.props.facingMode },
audio: false,
};
try {
this.stream = await browser.navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
const errors = {
NotFoundError: _t("No device can be found."),
NotAllowedError: _t("Odoo needs your authorization first."),
};
const errorMessage =
_t("Could not start scanning. ") + (errors[err.name] || err.message);
this.onError(new Error(errorMessage));
return;
}
this.videoPreviewRef.el.srcObject = this.stream;
await this.isVideoReady();
const { height, width } = getComputedStyle(this.videoPreviewRef.el);
const divWidth = width.slice(0, -2);
const divHeight = height.slice(0, -2);
const tracks = this.stream.getVideoTracks();
if (tracks.length) {
const [track] = tracks;
const settings = track.getSettings();
this.zoomRatio = Math.min(divWidth / settings.width, divHeight / settings.height);
}
this.interval = setInterval(this.detectCode.bind(this), 100);
});
onWillUnmount(() => {
clearInterval(this.interval);
this.interval = null;
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
});
}
isZXingBarcodeDetector() {
return this.detector && this.detector.__proto__.constructor.name === "ZXingBarcodeDetector";
}
/**
* Check for camera preview element readiness
*
* @returns {Promise} resolves when the video element is ready
*/
async isVideoReady() {
// FIXME: even if it shouldn't happened, a timeout could be useful here.
return new Promise(async (resolve) => {
while (!isVideoElementReady(this.videoPreviewRef.el)) {
await delay(10);
}
this.state.isReady = true;
resolve();
});
}
onResize(overlayInfo) {
this.overlayInfo = overlayInfo;
if (this.isZXingBarcodeDetector()) {
// TODO need refactoring when ZXing will support multiple result in one scan
// https://github.com/zxing-js/library/issues/346
this.detector.setCropArea(this.adaptValuesWithRatio(this.overlayInfo, true));
}
}
/**
* Detection success handler
*
* @param {string} result found code
*/
onResult(result) {
this.props.onClose({ barcode: result });
}
/**
* Detection error handler
*
* @param {Error} error
*/
onError(error) {
this.props.onClose({ error });
}
/**
* Attempt to detect codes in the current camera preview's frame
*/
async detectCode() {
try {
const codes = await this.detector.detect(this.videoPreviewRef.el);
for (const code of codes) {
if (!this.isZXingBarcodeDetector() && this.overlayInfo.x && this.overlayInfo.y) {
const { x, y, width, height } = this.adaptValuesWithRatio(code.boundingBox);
if (
x < this.overlayInfo.x ||
x + width > this.overlayInfo.x + this.overlayInfo.width ||
y < this.overlayInfo.y ||
y + height > this.overlayInfo.y + this.overlayInfo.height
) {
continue;
}
}
this.onResult(code.rawValue);
break;
}
} catch (err) {
this.onError(err);
}
}
adaptValuesWithRatio(object, dividerRatio = false) {
const newObject = Object.assign({}, object);
for (const key of Object.keys(newObject)) {
if (dividerRatio) {
newObject[key] /= this.zoomRatio;
} else {
newObject[key] *= this.zoomRatio;
}
}
return newObject;
}
}
Object.assign(BarcodeDialog, {
components: {
Dialog,
CropOverlay,
},
template: "web.BarcodeDialog",
});
/**
* Check for BarcodeScanner support
* @returns {boolean}
*/
export function isBarcodeScannerSupported() {
return browser.navigator.mediaDevices && browser.navigator.mediaDevices.getUserMedia;
}
/**
* Opens the BarcodeScanning dialog and begins code detection using the device's camera.
*
* @returns {Promise<string>} resolves when a {qr,bar}code has been detected
*/
export async function scanBarcode(facingMode = "environment") {
const promise = new Deferred();
const appForBarcodeDialog = new App(BarcodeDialog, {
env: owl.Component.env,
dev: owl.Component.env.isDebug(),
templates,
translatableAttributes: ["data-tooltip"],
translateFn: _t,
props: {
onClose: (result = {}) => {
appForBarcodeDialog.destroy();
if (result.error) {
promise.reject({ error: result.error });
} else {
promise.resolve(result.barcode);
}
},
facingMode: facingMode,
},
});
await appForBarcodeDialog.mount(document.body);
return promise;
}

View file

@ -0,0 +1,10 @@
.modal .o-barcode-modal .modal-body {
overflow: hidden;
@include media-breakpoint-down(md) {
padding: 0;
}
video {
object-fit: cover;
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BarcodeDialog" owl="1">
<Dialog title="'Barcode Scanner'" onClosed="props.onClose" fullscreen="true" renderFooter="false" contentClass="'o-barcode-modal'">
<CropOverlay onResize.bind="this.onResize" isReady="state.isReady">
<video t-ref="videoPreview" muted="true" autoplay="true" playsinline="true" class="w-100 h-100"/>
</CropOverlay>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,155 @@
/** @odoo-module **/
import { Component, useRef, onPatched } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { clamp } from "@web/core/utils/numbers";
export class CropOverlay extends Component {
setup() {
this.localStorageKey = "o-barcode-scanner-overlay";
this.cropContainerRef = useRef("crop-container");
this.isMoving = false;
this.boundaryOverlay = {};
this.relativePosition = {
x: 0,
y: 0,
};
onPatched(() => {
this.setupCropRect();
});
}
setupCropRect() {
if (!this.props.isReady) {
return;
}
this.computeDefaultPoint();
this.computeOverlayPosition();
this.calculateAndSetTransparentRect();
this.executeOnResizeCallback();
}
boundPoint(pointValue, boundaryRect) {
return {
x: clamp(pointValue.x, boundaryRect.left, boundaryRect.left + boundaryRect.width),
y: clamp(pointValue.y, boundaryRect.top, boundaryRect.top + boundaryRect.height),
};
}
calculateAndSetTransparentRect() {
const cropTransparentRect = this.getTransparentRec(
this.relativePosition,
this.boundaryOverlay
);
this.setCropValue(cropTransparentRect, this.relativePosition);
}
computeOverlayPosition() {
const cropOverlayElement = this.cropContainerRef.el.querySelector(".o_crop_overlay");
this.boundaryOverlay = cropOverlayElement.getBoundingClientRect();
}
executeOnResizeCallback() {
const transparentRec = this.getTransparentRec(this.relativePosition, this.boundaryOverlay);
browser.localStorage.setItem(this.localStorageKey, JSON.stringify(transparentRec));
this.props.onResize({
...transparentRec,
width: this.boundaryOverlay.width - 2 * transparentRec.x,
height: this.boundaryOverlay.height - 2 * transparentRec.y,
});
}
computeDefaultPoint() {
const firstChildComputedStyle = getComputedStyle(this.cropContainerRef.el.firstChild);
const elementWidth = firstChildComputedStyle.width.slice(0, -2);
const elementHeight = firstChildComputedStyle.height.slice(0, -2);
const stringSavedPoint = browser.localStorage.getItem(this.localStorageKey);
if (stringSavedPoint) {
const savedPoint = JSON.parse(stringSavedPoint);
this.relativePosition = {
x: clamp(savedPoint.x, 0, elementWidth),
y: clamp(savedPoint.y, 0, elementHeight),
};
} else {
const stepWidth = elementWidth / 10;
const width = stepWidth * 8;
const height = width / 4;
const startY = elementHeight / 2 - height / 2;
this.relativePosition = {
x: stepWidth + width,
y: startY + height,
};
}
}
getTransparentRec(point, rect) {
const middleX = rect.width / 2;
const middleY = rect.height / 2;
const newDeltaX = Math.abs(point.x - middleX);
const newDeltaY = Math.abs(point.y - middleY);
return {
x: middleX - newDeltaX,
y: middleY - newDeltaY,
};
}
setCropValue(point, iconPoint) {
if (!iconPoint) {
iconPoint = point;
}
this.cropContainerRef.el.style.setProperty("--o-crop-x", `${point.x}px`);
this.cropContainerRef.el.style.setProperty("--o-crop-y", `${point.y}px`);
this.cropContainerRef.el.style.setProperty("--o-crop-icon-x", `${iconPoint.x}px`);
this.cropContainerRef.el.style.setProperty("--o-crop-icon-y", `${iconPoint.y}px`);
}
pointerDown(event) {
event.preventDefault();
if (event.target.matches(".o_crop_icon")) {
this.computeOverlayPosition();
this.isMoving = true;
}
}
pointerMove(event) {
if (!this.isMoving) {
return;
}
let eventPosition;
if (event.touches && event.touches.length) {
eventPosition = event.touches[0];
} else {
eventPosition = event;
}
const { clientX, clientY } = eventPosition;
const restrictedPosition = this.boundPoint(
{
x: clientX,
y: clientY,
},
this.boundaryOverlay
);
this.relativePosition = {
x: restrictedPosition.x - this.boundaryOverlay.left,
y: restrictedPosition.y - this.boundaryOverlay.top,
};
this.calculateAndSetTransparentRect(this.relativePosition);
}
pointerUp(event) {
this.isMoving = false;
this.executeOnResizeCallback();
}
}
CropOverlay.template = "web.CropOverlay";
CropOverlay.props = {
onResize: Function,
isReady: Boolean,
slots: {
type: Object,
shape: {
default: {},
},
},
};

View file

@ -0,0 +1,32 @@
.o_crop_container {
position: relative;
> * {
grid-row: 1 / -1;
grid-column: 1 / -1;
}
.o_crop_overlay {
background-color: RGB(0 0 0 / 0.75);
mix-blend-mode: darken;
&::after {
content: '';
display: block;
height: 100%;
width: 100%;
clip-path: inset(var(--o-crop-y, 0px) var(--o-crop-x, 0px));
background-color: white;
}
}
.o_crop_icon {
--o-crop-icon-width: 20px;
--o-crop-icon-height: 20px;
position: absolute;
width: var(--o-crop-icon-width);
height: var(--o-crop-icon-height);
left: calc(var(--o-crop-icon-x, 0px) - (var(--o-crop-icon-width) / 2));
top: calc(var(--o-crop-icon-y, 0px) - (var(--o-crop-icon-height) / 2));
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CropOverlay" owl="1">
<div t-ref="crop-container"
t-on-mousedown="pointerDown" t-on-touchstart="pointerDown"
t-on-mousemove="pointerMove" t-on-touchmove="pointerMove"
t-on-mouseup="pointerUp" t-on-touchend="pointerUp"
class="d-grid align-content-center justify-content-center h-100 o_crop_container"
>
<t t-slot="default"/>
<t t-if="props.isReady">
<div class="o_crop_overlay"/>
<img class="o_crop_icon" src="/web/static/img/transform.svg" draggable="false"/>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,91 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Transition } from "@web/core/transition";
import { useBus, useService } from "@web/core/utils/hooks";
import { BurgerUserMenu } from "./burger_user_menu/burger_user_menu";
import { MobileSwitchCompanyMenu } from "./mobile_switch_company_menu/mobile_switch_company_menu";
import { Component, useState } from "@odoo/owl";
/**
* This file includes the widget Menu in mobile to render the BurgerMenu which
* opens fullscreen and displays the user menu and the current app submenus.
*/
const SWIPE_ACTIVATION_THRESHOLD = 100;
export class BurgerMenu extends Component {
setup() {
this.company = useService("company");
this.user = useService("user");
this.menuRepo = useService("menu");
this.state = useState({
isUserMenuOpened: false,
isBurgerOpened: false,
});
this.swipeStartX = null;
useBus(this.env.bus, "HOME-MENU:TOGGLED", () => {
this._closeBurger();
});
useBus(this.env.bus, "ACTION_MANAGER:UPDATE", ({ detail: req }) => {
if (req.id) {
this._closeBurger();
}
});
}
get currentApp() {
return this.menuRepo.getCurrentApp();
}
get currentAppSections() {
return (
(this.currentApp && this.menuRepo.getMenuAsTree(this.currentApp.id).childrenTree) || []
);
}
get isUserMenuUnfolded() {
return !this.isUserMenuTogglable || this.state.isUserMenuOpened;
}
get isUserMenuTogglable() {
return this.currentApp && this.currentAppSections.length > 0;
}
_closeBurger() {
this.state.isUserMenuOpened = false;
this.state.isBurgerOpened = false;
}
_openBurger() {
this.state.isBurgerOpened = true;
}
_toggleUserMenu() {
this.state.isUserMenuOpened = !this.state.isUserMenuOpened;
}
async _onMenuClicked(menu) {
await this.menuRepo.selectMenu(menu);
this._closeBurger();
}
_onSwipeStart(ev) {
this.swipeStartX = ev.changedTouches[0].clientX;
}
_onSwipeEnd(ev) {
if (!this.swipeStartX) {
return;
}
const deltaX = ev.changedTouches[0].clientX - this.swipeStartX;
if (deltaX < SWIPE_ACTIVATION_THRESHOLD) {
return;
}
this._closeBurger();
this.swipeStartX = null;
}
}
BurgerMenu.template = "web.BurgerMenu";
BurgerMenu.components = {
BurgerUserMenu,
MobileSwitchCompanyMenu,
Transition,
};
const systrayItem = {
Component: BurgerMenu,
};
registry.category("systray").add("burger_menu", systrayItem, { sequence: 0 });

View file

@ -0,0 +1,79 @@
//------------------------------------------------------------------------------
// Mobile Burger Menu
//------------------------------------------------------------------------------
.o_burger_menu {
width: 90%;
z-index: $zindex-tooltip + 10;
transition: transform .2s ease;
// Menu Toggle-Animations
transform: translateX(-100%);
&.burgerslide-enter, &.burgerslide-leave {
transform: translateX(0);
}
// ====== Top-Bar
.o_burger_menu_topbar {
min-height: $o-navbar-height-xs;
background-color: $o-burger-topbar-bg;
color: $o-burger-topbar-color;
line-height: $o-navbar-height-xs;
.dropdown-toggle, .o_burger_menu_close {
padding: 0 $o-horizontal-padding;
}
}
// ====== Menu content container (both App's and User's entries)
.o_burger_menu_content {
&.o_burger_menu_dark {
background-color: darken($o-burger-base-bg, 5%);
}
// Menu entries size and layout
ul {
background-color: rgba(invert($o-burger-base-color), .1);
box-shadow: inset 0 -1px 0 rgba($o-burger-base-color, .1);
li > div, li {
padding-left: $o-horizontal-padding;
}
// Handle menu text-indentation
li {
ul > li {
&, > div {
text-indent: 2em;
}
ul > li {
&, > div {
text-indent: 3em;
}
}
}
}
}
li, button {
@include o-hover-text-color(rgba($o-burger-base-color, .8), $o-burger-base-color);
}
// ====== 'User Menu' spefic design rules
.o_user_menu_mobile .dropdown-divider {
margin-left: $dropdown-item-padding-x;
margin-right: $dropdown-item-padding-x;
}
}
}
//------------------------------------------------------------------------------
// Design rules not scoped within the main component
//------------------------------------------------------------------------------
@include media-breakpoint-down(md) {
.o_debug_dropdown {
z-index: $zindex-tooltip + 10;
}
}

View file

@ -0,0 +1,7 @@
// = Burger Menu Variables
// ============================================================================
$o-burger-topbar-bg: $o-brand-odoo !default;
$o-burger-topbar-color: $o-white !default;
$o-burger-base-bg: $o-burger-topbar-bg !default;
$o-burger-base-color: $o-burger-topbar-color !default;

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Owl Templates -->
<div t-name="web.BurgerMenu" owl="1">
<button
class="o_mobile_menu_toggle o-no-caret d-md-none border-0"
title="Toggle menu" aria-label="Toggle menu"
t-on-click="_openBurger">
<i class="oi oi-panel-right"/>
</button>
<t t-portal="'body'">
<Transition name="'burgerslide'" visible="state.isBurgerOpened" leaveDuration="200" t-slot-scope="transition">
<div class="o_burger_menu position-fixed top-0 bottom-0 start-100 d-flex flex-column flex-nowrap" t-att-class="transition.className" t-on-touchstart.stop="_onSwipeStart" t-on-touchend.stop="_onSwipeEnd">
<div class="o_burger_menu_topbar d-flex align-items-center justify-content-between flex-shrink-0 py-0 fs-4"
t-on-click.stop='_toggleUserMenu'>
<small class="o-no-caret dropdown-toggle d-flex align-items-center justify-content-between" t-att-class="{'active bg-view text-body': isUserMenuUnfolded }">
<img class="o_burger_menu_avatar o_image_24_cover rounded-circle" t-att-src="'/web/image?model=res.users&amp;field=avatar_128&amp;id=' + user.userId" alt="Menu"/>
<span class="o_burger_menu_username px-2"><t t-esc="user.name"/></span>
<i t-if="isUserMenuTogglable" class="fa" t-att-class="state.isUserMenuOpened ? 'fa-caret-down' : 'fa-caret-left'"/>
</small>
<button class="o_burger_menu_close oi oi-close btn d-flex align-items-center h-100 bg-transparent border-0 fs-2 text-reset" aria-label="Close menu" title="Close menu" t-on-click.stop="_closeBurger"/>
</div>
<nav class="o_burger_menu_content flex-grow-1 flex-shrink-1 overflow-auto"
t-att-class="{o_burger_menu_dark: !isUserMenuUnfolded, 'bg-view': isUserMenuUnfolded}">
<!-- -->
<t t-if="isUserMenuUnfolded">
<MobileSwitchCompanyMenu t-if="Object.values(company.availableCompanies).length > 1" />
<BurgerUserMenu/>
</t>
<!-- Current App Sections -->
<ul t-if="!isUserMenuUnfolded" class="ps-0 mb-0">
<t t-foreach="currentAppSections" t-as="subMenu" t-key="subMenu_index">
<t t-call="web.BurgerSection">
<t t-set="section" t-value="subMenu" />
</t>
</t>
</ul>
</nav>
</div>
</Transition>
</t>
<t t-portal="'body'">
<div t-if="state.isBurgerOpened" class="o_burger_menu_backdrop modal-backdrop show d-block d-md-none" t-on-click.stop="_closeBurger" t-on-touchstart.stop="_onSwipeStart" t-on-touchend.stop="_onSwipeEnd" />
</t>
</div>
<t t-name="web.BurgerSection" owl="1">
<li t-if="section.childrenTree.length" class="ps-0">
<div class="py-3 bg-transparent" t-att-class="{ 'fs-4': !isNested }"
t-att-data-menu-xmlid="section.xmlid" t-esc="section.name"/>
<ul class="ps-0">
<t t-foreach="section.childrenTree" t-as="subSection" t-key="subSection_index">
<t t-call="web.BurgerSection">
<t t-set="section" t-value="subSection"/>
<t t-set="isNested" t-value="true"/>
</t>
</t>
</ul>
</li>
<li t-else="" t-on-click="() => this._onMenuClicked(section)" t-att-data-menu-xmlid="section.xmlid"
class="py-3" t-att-class="{ 'fs-4': !isNested }">
<t t-esc="section.name"/>
</li>
</t>
</templates>

View file

@ -0,0 +1,10 @@
/** @odoo-module **/
import { UserMenu } from "@web/webclient/user_menu/user_menu";
export class BurgerUserMenu extends UserMenu {
_onItemClicked(callback) {
callback();
}
}
BurgerUserMenu.template = "web.BurgerUserMenu";

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BurgerUserMenu" owl="1">
<div class="o_user_menu_mobile mt-2">
<t t-foreach="getElements()" t-as="element" t-key="element_index">
<t t-if="!element.hide">
<a t-if="element.type == 'item'" class="dropdown-item py-3 fs-4" t-att-href="element.href or ''" t-esc="element.description" t-on-click.stop.prevent="() => this._onItemClicked(element.callback)"/>
<CheckBox
t-if="element.type == 'switch'"
value="element.isChecked"
className="'dropdown-item form-switch d-flex flex-row-reverse justify-content-between py-3 fs-4 w-100'"
onChange="element.callback"
>
<t t-out="element.description"/>
</CheckBox>
<div t-if="element.type == 'separator'" role="separator" class="dropdown-divider"/>
</t>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,5 @@
/** @odoo-module **/
import { SwitchCompanyMenu } from "@web/webclient/switch_company_menu/switch_company_menu";
export class MobileSwitchCompanyMenu extends SwitchCompanyMenu {}
MobileSwitchCompanyMenu.template = "web.MobileSwitchCompanyMenu";

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.MobileSwitchCompanyMenu" owl="1">
<div class="o_burger_menu_companies p-3 bg-100">
<div class="o_burger_menu_user_title h6 mb-3">Companies</div>
<t t-foreach="Object.values(companyService.availableCompanies)" t-as="company" t-key="company.id">
<t t-set="id" t-value="company.id"/>
<t t-set="displayName" t-value="company.name"/>
<t t-set="isCompanySelected" t-value="selectedCompanies.includes(id)"/>
<t t-set="checkIcon" t-value="isCompanySelected ? 'fa-check-square text-primary' : 'fa-square-o'"/>
<t t-set="isCompanyCurrent" t-value="companyService.currentCompany.id === id"/>
<div class="d-flex menu_companies_item" t-att-data-company-id="id">
<div class="border-end toggle_company" t-att-class="{'border-primary' : isCompanyCurrent}" t-on-click="() => this.toggleCompany(id)">
<span class="btn border-0 p-2">
<i t-attf-class="fa fa-fw fs-2 m-0 {{checkIcon}}"/>
</span>
</div>
<div class="flex-grow-1 p-2 ms-1 log_into text-muted" t-att-class="{'alert-primary': isCompanyCurrent}" t-on-click="() => this.logIntoCompany(id)">
<span t-esc="displayName" class="company_label" t-att-class="isCompanyCurrent ? 'text-900 fw-bold' : ''"/>
<small t-if="isCompanyCurrent" class="ms-1">(current)</small>
</div>
</div>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,496 @@
/**
* The purpose of this test is to click on every installed App and then open each
* view. On each view, click on each filter.
*/
(function (exports) {
"use strict";
const MOUSE_EVENTS = ["mouseover", "mouseenter", "mousedown", "mouseup", "click"];
const BLACKLISTED_MENUS = [
"base.menu_theme_store",
"base.menu_third_party",
"account.menu_action_account_bank_journal_form",
"event_barcode.menu_event_registration_desk", // there's no way to come back from this menu
"hr_attendance.menu_hr_attendance_kiosk_no_user_mode", // same here
"pos_adyen.menu_pos_adyen_account",
"payment_odoo.menu_adyen_account",
"payment_odoo.root_adyen_menu",
];
const { isEnterprise } = odoo.info;
const { onWillStart } = owl;
let appsMenusOnly = false;
const isStudioInstalled = "@web_studio/studio_service" in odoo.__DEBUG__.services;
let actionCount = 0;
let viewUpdateCount = 0;
let studioCount = 0;
let appIndex;
let menuIndex;
let subMenuIndex;
let testedApps;
let testedMenus;
/**
* Hook on specific activities of the webclient to detect when to move forward.
* This should be done only once.
*/
let setupDone = false;
function ensureSetup() {
if (setupDone) {
return;
}
setupDone = true;
const env = odoo.__WOWL_DEBUG__.root.env;
env.bus.addEventListener("ACTION_MANAGER:UI-UPDATED", () => {
actionCount++;
});
const AbstractController = odoo.__DEBUG__.services["web.AbstractController"];
AbstractController.include({
start() {
this.$el.attr("data-view-type", this.viewType);
return this._super.apply(this, arguments);
},
async update() {
await this._super(...arguments);
viewUpdateCount++;
},
});
const { patch } = odoo.__DEBUG__.services["@web/core/utils/patch"];
const { WithSearch } = odoo.__DEBUG__.services["@web/search/with_search/with_search"];
patch(WithSearch.prototype, "PatchedWithSearch", {
setup() {
this._super();
onWillStart(() => {
viewUpdateCount++;
});
},
async render() {
await this._super(...arguments);
viewUpdateCount++;
},
});
// This test file is not respecting Odoo module dependencies.
// The following module might not be loaded (eg. if mail is not installed).
const DiscussWidgetModule = odoo.__DEBUG__.services["@mail/widgets/discuss/discuss"];
const DiscussWidget = DiscussWidgetModule && DiscussWidgetModule[Symbol.for("default")];
if (DiscussWidget) {
DiscussWidget.include({
/**
* Overriding a method that is called every time the discuss
* component is updated.
*/
_updateControlPanel: async function () {
await this._super(...arguments);
viewUpdateCount++;
},
});
}
}
/**
* Returns a promise that resolves after the next animation frame.
*
* @returns {Promise}
*/
async function waitForNextAnimationFrame() {
await new Promise(setTimeout);
await new Promise((r) => requestAnimationFrame(r));
}
/**
* Simulate all of the mouse events triggered during a click action.
*
* @param {EventTarget} target the element on which to perform the click
* @param {string} elDescription description of the item
* @returns {Promise} resolved after next animation frame
*/
async function triggerClick(target, elDescription) {
if (target) {
console.log("Clicking on", elDescription);
} else {
throw new Error(`No element "${elDescription}" found.`);
}
MOUSE_EVENTS.forEach((type) => {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, view: window });
target.dispatchEvent(event);
});
await waitForNextAnimationFrame();
}
/**
* Wait a certain amount of time for a condition to occur
*
* @param {function} stopCondition a function that returns a boolean
* @returns {Promise} that is rejected if the timeout is exceeded
*/
function waitForCondition(stopCondition, tl = 30000) {
return new Promise(function (resolve, reject) {
const interval = 250;
let timeLimit = tl;
function checkCondition() {
if (stopCondition()) {
resolve();
} else {
timeLimit -= interval;
if (timeLimit > 0) {
// recursive call until the resolve or the timeout
setTimeout(checkCondition, interval);
} else {
console.error(
"Timeout, the clicked element took more than",
tl / 1000,
"seconds to load"
);
reject();
}
}
}
setTimeout(checkCondition, interval);
});
}
/**
* Make sure the home menu is open (enterprise only)
*/
async function ensureHomeMenu() {
const homeMenu = document.querySelector(".o_home_menu");
if (!homeMenu) {
let menuToggle = document.querySelector("nav.o_main_navbar > a.o_menu_toggle");
if (!menuToggle) {
// In the Barcode application, there is no navbar. So you have to click
// on the o_stock_barcode_menu button which is the equivalent of
// the o_menu_toggle button in the navbar.
menuToggle = document.querySelector(".o_stock_barcode_menu");
}
await triggerClick(menuToggle, "home menu toggle button");
await waitForCondition(() => document.querySelector(".o_home_menu"));
}
}
/**
* Make sure the apps menu is open (community only)
*/
async function ensureAppsMenu() {
const appsMenu = document.querySelector(".o_navbar_apps_menu .dropdown-menu");
if (!appsMenu) {
const toggler = document.querySelector(".o_navbar_apps_menu .dropdown-toggle");
await triggerClick(toggler, "apps menu toggle button");
await waitForCondition(() =>
document.querySelector(".o_navbar_apps_menu .dropdown-menu")
);
}
}
/**
* Return the next menu to test, and update the internal counters.
*
* @returns {DomElement}
*/
async function getNextMenu() {
const menus = document.querySelectorAll(
".o_menu_sections > .dropdown > .dropdown-toggle, .o_menu_sections > .dropdown-item"
);
if (menuIndex === menus.length) {
menuIndex = 0;
return; // all menus done
}
let menu = menus[menuIndex];
if (menu.classList.contains("dropdown-toggle")) {
// the current menu is a dropdown toggler -> open it and pick a menu inside the dropdown
if (!menu.nextElementSibling) {
// might already be opened if the last menu was blacklisted
await triggerClick(menu, "menu toggler");
}
const dropdown = menu.nextElementSibling;
if (!dropdown) {
menuIndex = 0; // empty More menu has no dropdown (FIXME?)
return;
}
const items = dropdown.querySelectorAll(".dropdown-item");
menu = items[subMenuIndex];
if (subMenuIndex === items.length - 1) {
// this is the last item, so go to the next menu
menuIndex++;
subMenuIndex = 0;
} else {
// this isn't the last item, so increment the index inside this dropdown
subMenuIndex++;
}
} else {
// the current menu isn't a dropdown, so go to the next menu
menuIndex++;
}
return menu;
}
/**
* Return the next app to test, and update the internal counter.
*
* @returns {DomElement}
*/
async function getNextApp() {
let apps;
if (isEnterprise) {
await ensureHomeMenu();
apps = document.querySelectorAll(".o_apps .o_app");
} else {
await ensureAppsMenu();
apps = document.querySelectorAll(".o_navbar_apps_menu .dropdown-item");
}
const app = apps[appIndex];
appIndex++;
return app;
}
/**
* Test Studio
* Click on the Studio systray item to enter Studio, and simply leave it once loaded.
*/
async function testStudio() {
if (!isStudioInstalled) {
return;
}
const studioIcon = document.querySelector(".o_web_studio_navbar_item:not(.o_disabled) a i");
if (!studioIcon) {
return;
}
// Open the filter menu dropdown
await triggerClick(studioIcon, "entering studio");
await waitForCondition(() => document.querySelector(".o_in_studio"));
await triggerClick(document.querySelector(".o_web_studio_leave"), "leaving studio");
await waitForCondition(() => document.querySelector(".o_main_navbar:not(.o_studio_navbar) .o_menu_toggle"));
studioCount++;
}
/**
* Test filters
* Click on each filter in the control pannel
*/
async function testFilters() {
if (appsMenusOnly === true) {
return;
}
const filterMenuButton = document.querySelector(".o_control_panel .o_filter_menu > button");
if (!filterMenuButton) {
return;
}
// Open the filter menu dropdown
await triggerClick(
filterMenuButton,
`toggling menu "${filterMenuButton.innerText.trim()}"`
);
const simpleFilterSel = ".o_control_panel .o_filter_menu > .dropdown-menu > .dropdown-item";
const dateFilterSel = ".o_control_panel .o_filter_menu > .dropdown-menu > .dropdown";
const filterMenuItems = document.querySelectorAll(`${simpleFilterSel},${dateFilterSel}`);
console.log("Testing", filterMenuItems.length, "filters");
for (const filter of filterMenuItems) {
const currentViewCount = viewUpdateCount;
if (filter.classList.contains("dropdown")) {
await triggerClick(
filter.querySelector(".dropdown-toggle"),
`filter "${filter.innerText.trim()}"`
);
// the sub-dropdown opens 200ms after the mousenter, so we trigger an ArrayRight
// keydown s.t. it opens directly
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" }));
await waitForNextAnimationFrame();
// If a fitler has options, it will simply unfold and show all options.
// We then click on the first one.
const firstOption = filter.querySelector(".dropdown-menu > .dropdown-item");
if (firstOption) {
await triggerClick(
firstOption,
`filter option "${firstOption.innerText.trim()}"`
);
await waitForCondition(() => currentViewCount !== viewUpdateCount);
}
} else {
await triggerClick(filter, `filter "${filter.innerText.trim()}"`);
await waitForCondition(() => currentViewCount !== viewUpdateCount);
}
}
}
/**
* Orchestrate the test of views
* This function finds the buttons that permit to switch views and orchestrate
* the click on each of them
* @returns {Promise}
*/
async function testViews() {
if (appsMenusOnly === true) {
return;
}
const switchButtons = document.querySelectorAll(
"nav.o_cp_switch_buttons > button.o_switch_view:not(.active):not(.o_map)"
);
for (const switchButton of switchButtons) {
// Only way to get the viewType from the switchButton
const viewType = [...switchButton.classList]
.find((cls) => cls !== "o_switch_view" && cls.startsWith("o_"))
.slice(2);
console.log("Testing view switch:", viewType);
// timeout to avoid click debounce
setTimeout(function () {
const target = document.querySelector(
`nav.o_cp_switch_buttons > button.o_switch_view.o_${viewType}`
);
if (target) {
triggerClick(target, `${viewType} view switcher`);
}
}, 250);
await waitForCondition(() => {
return document.querySelector(`.o_switch_view.o_${viewType}.active`) !== null;
});
await testStudio();
await testFilters();
}
}
/**
* Test a menu item by:
* 1 - clikcing on the menuItem
* 2 - Orchestrate the view switch
*
* @param {DomElement} element: the menu item
* @returns {Promise}
*/
async function testMenuItem(element) {
const menuDescription = element.innerText.trim() + " " + element.dataset.menuXmlid;
console.log("Testing menu", menuDescription);
testedMenus.push(element.dataset.menuXmlid);
if (BLACKLISTED_MENUS.includes(element.dataset.menuXmlid)) {
return Promise.resolve(); // Skip black listed menus
}
const startActionCount = actionCount;
await triggerClick(element, `menu item "${element.innerText.trim()}"`);
let isModal = false;
return waitForCondition(function () {
// sometimes, the app is just a modal that needs to be closed
const $modal = $('.modal[role="dialog"]');
if ($modal.length > 0) {
const closeButton = document.querySelector("header > button.btn-close");
if (closeButton) {
closeButton.focus();
triggerClick(closeButton, "modal close button");
} else {
$modal.modal("hide");
}
isModal = true;
return true;
}
return startActionCount !== actionCount;
})
.then(() => {
if (!isModal) {
return testStudio();
}
})
.then(() => {
if (!isModal) {
return testFilters();
}
})
.then(() => {
if (!isModal) {
return testViews();
}
})
.catch((err) => {
console.error("Error while testing", menuDescription);
return Promise.reject(err);
});
}
/**
* Test an "App" menu item by orchestrating the following actions:
* 1 - clicking on its menuItem
* 2 - clicking on each view
* 3 - clicking on each menu
* 3.1 - clicking on each view
* @param {DomElement} element: the App menu item
* @returns {Promise}
*/
async function testApp(element) {
console.log("Testing app menu:", element.dataset.menuXmlid);
testedApps.push(element.dataset.menuXmlid);
await testMenuItem(element);
if (appsMenusOnly === true) {
return;
}
menuIndex = 0;
subMenuIndex = 0;
let menu = await getNextMenu();
while (menu) {
await testMenuItem(menu);
menu = await getNextMenu();
}
}
/**
* Main function that starts orchestration of tests
*/
async function _clickEverywhere(xmlId) {
ensureSetup();
console.log("Starting ClickEverywhere test");
console.log(`Odoo flavor: ${isEnterprise ? "Enterprise" : "Community"}`);
const startTime = performance.now();
testedApps = [];
testedMenus = [];
appIndex = 0;
menuIndex = 0;
subMenuIndex = 0;
try {
let app;
if (xmlId) {
if (isEnterprise) {
app = document.querySelector(`a.o_app.o_menuitem[data-menu-xmlid="${xmlId}"]`);
} else {
await triggerClick(
document.querySelector(".o_navbar_apps_menu .dropdown-toggle")
);
app = document.querySelector(
`.o_navbar_apps_menu .dropdown-item[data-menu-xmlid="${xmlId}"]`
);
}
if (!app) {
throw new Error(`No app found for xmlid ${xmlId}`);
}
await testApp(app);
} else {
while ((app = await getNextApp())) {
await testApp(app);
}
}
console.log(`Test took ${(performance.now() - startTime) / 1000} seconds`);
console.log(`Successfully tested ${testedApps.length} apps`);
console.log(`Successfully tested ${testedMenus.length - testedApps.length} menus`);
if (isStudioInstalled) {
console.log(`Successfully tested ${studioCount} views in Studio`);
}
console.log("test successful");
} catch (err) {
console.log(`Test took ${(performance.now() - startTime) / 1000} seconds`);
console.error(err || "test failed");
}
console.log(testedApps);
console.log(testedMenus);
}
function clickEverywhere(xmlId, light) {
appsMenusOnly = light;
setTimeout(_clickEverywhere, 1000, xmlId);
}
exports.clickEverywhere = clickEverywhere;
})(window);

View file

@ -0,0 +1,22 @@
/** @odoo-module alias=web.clickEverywhere **/
import { loadJS } from "@web/core/assets";
import { registry } from "@web/core/registry";
export default async function startClickEverywhere(xmlId, appsMenusOnly) {
await loadJS("web/static/src/webclient/clickbot/clickbot.js");
window.clickEverywhere(xmlId, appsMenusOnly);
}
function runClickTestItem({ env }) {
return {
type: "item",
description: env._t("Run Click Everywhere Test"),
callback: () => {
startClickEverywhere();
},
sequence: 30,
};
}
registry.category("debug").category("default").add("runClickTestItem", runClickTestItem);

View file

@ -0,0 +1,87 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { symmetricalDifference } from "../core/utils/arrays";
import { session } from "@web/session";
function parseCompanyIds(cidsFromHash) {
const cids = [];
if (typeof cidsFromHash === "string") {
cids.push(...cidsFromHash.split(",").map(Number));
} else if (typeof cidsFromHash === "number") {
cids.push(cidsFromHash);
}
return cids;
}
function computeAllowedCompanyIds(cids) {
const { user_companies } = session;
let allowedCompanyIds = cids || [];
const availableCompaniesFromSession = user_companies.allowed_companies;
const notReallyAllowedCompanies = allowedCompanyIds.filter(
(id) => !(id in availableCompaniesFromSession)
);
if (!allowedCompanyIds.length || notReallyAllowedCompanies.length) {
allowedCompanyIds = [user_companies.current_company];
}
return allowedCompanyIds;
}
export const companyService = {
dependencies: ["user", "router", "cookie"],
start(env, { user, router, cookie }) {
let cids;
if ("cids" in router.current.hash) {
cids = parseCompanyIds(router.current.hash.cids);
} else if ("cids" in cookie.current) {
cids = parseCompanyIds(cookie.current.cids);
}
const allowedCompanyIds = computeAllowedCompanyIds(cids);
const stringCIds = allowedCompanyIds.join(",");
router.replaceState({ cids: stringCIds }, { lock: true });
cookie.setCookie("cids", stringCIds);
user.updateContext({ allowed_company_ids: allowedCompanyIds });
const availableCompanies = session.user_companies.allowed_companies;
return {
availableCompanies,
get allowedCompanyIds() {
return allowedCompanyIds.slice();
},
get currentCompany() {
return availableCompanies[allowedCompanyIds[0]];
},
setCompanies(mode, ...companyIds) {
// compute next company ids
let nextCompanyIds;
if (mode === "toggle") {
nextCompanyIds = symmetricalDifference(allowedCompanyIds, companyIds);
} else if (mode === "loginto") {
const companyId = companyIds[0];
if (allowedCompanyIds.length === 1) {
// 1 enabled company: stay in single company mode
nextCompanyIds = [companyId];
} else {
// multi company mode
nextCompanyIds = [
companyId,
...allowedCompanyIds.filter((id) => id !== companyId),
];
}
}
nextCompanyIds = nextCompanyIds.length ? nextCompanyIds : [companyIds[0]];
// apply them
router.pushState({ cids: nextCompanyIds }, { lock: true });
cookie.setCookie("cids", nextCompanyIds);
browser.setTimeout(() => browser.location.reload()); // history.pushState is a little async
},
};
},
};
registry.category("services").add("company", companyService);

View file

@ -0,0 +1,84 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { browser } from "@web/core/browser/browser";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
function runJSTestsItem({ env }) {
const runTestsURL = browser.location.origin + "/web/tests?debug=assets";
return {
type: "item",
description: env._t("Run JS Tests"),
href: runTestsURL,
callback: () => {
browser.open(runTestsURL);
},
sequence: 10,
};
}
function runJSTestsMobileItem({ env }) {
const runTestsMobileURL = browser.location.origin + "/web/tests/mobile?debug=assets";
return {
type: "item",
description: env._t("Run JS Mobile Tests"),
href: runTestsMobileURL,
callback: () => {
browser.open(runTestsMobileURL);
},
sequence: 20,
};
}
export function openViewItem({ env }) {
async function onSelected(records) {
const views = await env.services.orm.searchRead(
"ir.ui.view",
[["id", "=", records[0]]],
["name", "model", "type"],
{ limit: 1 }
);
const view = views[0];
view.type = view.type === "tree" ? "list" : view.type; // ignore tree view
env.services.action.doAction({
type: "ir.actions.act_window",
name: view.name,
res_model: view.model,
views: [[view.id, view.type]],
});
}
return {
type: "item",
description: env._t("Open View"),
callback: () => {
env.services.dialog.add(SelectCreateDialog, {
resModel: "ir.ui.view",
title: env._t("Select a view"),
multiSelect: false,
domain: [
["type", "!=", "qweb"],
["type", "!=", "search"],
],
onSelected,
});
},
sequence: 40,
};
}
// This separates the items defined above from global items that aren't webclient-only
function globalSeparator() {
return {
type: "separator",
sequence: 400,
};
}
registry
.category("debug")
.category("default")
.add("runJSTestsItem", runJSTestsItem)
.add("runJSTestsMobileItem", runJSTestsMobileItem)
.add("globalSeparator", globalSeparator)
.add("openViewItem", openViewItem);

View file

@ -0,0 +1,83 @@
// = Icons
// ============================================================================
// Sizes
// ----------------------------------------------------------------------------
$oi-default-size: 1em; // Normally 13px
$oi-fw-ratio: 1.28571429; // Matches .fa-fw
$oi-sizes: (
'small': (
'font-size': .769em, // ~ 10px
'vertical-align': 10%,
),
'normal': (
'font-size': $oi-default-size,
),
'large': (
'font-size': 1.315em, // ~ 17px
'vertical-align': -6%,
),
'larger': (
'font-size': 1.462em, // ~ 19px
'vertical-align': -10%,
),
);
// Force font-weight to normal
// ----------------------------------------------------------------------------
.oi, .fa {
font-weight: $font-weight-normal;
}
// Define base-classes' properties using CSS variables.
// Allows to dinamically update rules according to the context and eventual
// utility classes.
// ----------------------------------------------------------------------------
.oi, .fa {
&:before {
font-size: var(--oi-font-size, $oi-default-size);
vertical-align: var(--oi-vertical-align, 0);
text-rendering: geometricPrecision;
}
}
.oi-fw {
display: inline-block;
width: calc(#{$oi-fw-ratio} * var(--oi-font-size, #{$oi-default-size}));
text-align: center;
}
// Print CSS variables for each size/breakpoint
// ----------------------------------------------------------------------------
@each $breakpoint in map-keys($grid-breakpoints) {
@include media-breakpoint-up($breakpoint) {
$-infix: breakpoint-infix($breakpoint, $grid-breakpoints);
@each $-key, $-values in $oi-sizes {
.oi#{$-infix}-#{$-key} {
@each $-rule, $-value in $-values {
@include print-variable('oi-#{$-rule}', $-value);
}
}
}
}
}
// Rely on animations already defined by FontAwesome
// ----------------------------------------------------------------------------
.oi-spin {
animation: fa-spin 2s infinite linear;
}
.oi-pulse {
animation: fa-spin 1s infinite steps(8);
}
.o_barcode {
-webkit-mask: url('/web/static/img/barcode.svg') center/contain no-repeat;
mask: url('/web/static/img/barcode.svg') center/contain no-repeat;
background-color: $o-brand-primary;
}

View file

@ -0,0 +1,72 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { Transition } from "@web/core/transition";
import { Component, useState } from "@odoo/owl";
/**
* Loading Indicator
*
* When the user performs an action, it is good to give him some feedback that
* something is currently happening. The purpose of the Loading Indicator is to
* display a small rectangle on the bottom right of the screen with just the
* text 'Loading' and the number of currently running rpcs.
*
* After a delay of 3s, if a rpc is still not completed, we also block the UI.
*/
export class LoadingIndicator extends Component {
setup() {
this.uiService = useService("ui");
this.state = useState({
count: 0,
show: false,
});
this.rpcIds = new Set();
this.shouldUnblock = false;
this.startShowTimer = null;
this.blockUITimer = null;
useBus(this.env.bus, "RPC:REQUEST", this.requestCall);
useBus(this.env.bus, "RPC:RESPONSE", this.responseCall);
}
requestCall({ detail: rpcId }) {
if (this.state.count === 0) {
browser.clearTimeout(this.startShowTimer);
this.startShowTimer = browser.setTimeout(() => {
if (this.state.count) {
this.state.show = true;
this.blockUITimer = browser.setTimeout(() => {
this.shouldUnblock = true;
this.uiService.block();
}, 3000);
}
}, 250);
}
this.rpcIds.add(rpcId);
this.state.count++;
}
responseCall({ detail: rpcId }) {
this.rpcIds.delete(rpcId);
this.state.count = this.rpcIds.size;
if (this.state.count === 0) {
browser.clearTimeout(this.startShowTimer);
browser.clearTimeout(this.blockUITimer);
this.state.show = false;
if (this.shouldUnblock) {
this.uiService.unblock();
this.shouldUnblock = false;
}
}
}
}
LoadingIndicator.template = "web.LoadingIndicator";
LoadingIndicator.components = { Transition };
registry.category("main_components").add("LoadingIndicator", {
Component: LoadingIndicator,
});

View file

@ -0,0 +1,14 @@
.o_loading_indicator {
position: fixed;
bottom: 0;
right: 0;
z-index: $zindex-modal + 1;
background-color: $o-brand-odoo;
color: white;
padding: 4px;
transition: opacity 0.4s;
&.o-fade-leave, &.o-fade-enter {
opacity: 0;
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.LoadingIndicator" owl="1">
<Transition visible="state.show" name="'o-fade'" t-slot-scope="transition" leaveDuration="400">
<span class="o_loading_indicator" t-att-class="transition.className">Loading<t t-if="env.debug" t-esc="' (' + state.count + ')'" /></span>
</Transition>
</t>
</templates>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.AppIconCommand" owl="1">
<div class="o_command_default position-relative d-flex align-items-center px-4 py-2 cursor-pointer">
<img t-if="props.webIconData" class="me-2 o_app_icon position-relative rounded-1" t-attf-src="{{props.webIconData}}"/>
<div t-else="" class="me-2 o_app_icon d-flex align-items-center justify-content-center" t-attf-style="background-color:{{props.webIcon.backgroundColor}}" >
<i t-att-class="props.webIcon.iconClass" t-attf-style="color:{{props.webIcon.color}}"></i>
</div>
<t t-slot="name"/>
<span class="ms-auto flex-shrink-0">
<t t-slot="focusMessage"/>
</span>
</div>
</t>
</templates>

View file

@ -0,0 +1,63 @@
/** @odoo-module **/
/**
* Traverses the given menu tree, executes the given callback for each node with
* the node itself and the list of its ancestors as arguments.
*
* @param {Object} tree tree of menus as exported by the menus service
* @param {Function} cb
* @param {[Object]} [parents] the ancestors of the tree root, if any
*/
function traverseMenuTree(tree, cb, parents = []) {
cb(tree, parents);
tree.childrenTree.forEach((c) => traverseMenuTree(c, cb, parents.concat([tree])));
}
/**
* Computes the "apps" and "menuItems" from a given menu tree.
*
* @param {Object} menuTree tree of menus as exported by the menus service
* @returns {Object} with keys "apps" and "menuItems" (HomeMenu props)
*/
export function computeAppsAndMenuItems(menuTree) {
const apps = [];
const menuItems = [];
traverseMenuTree(menuTree, (menuItem, parents) => {
if (!menuItem.id || !menuItem.actionID) {
return;
}
const isApp = menuItem.id === menuItem.appID;
const item = {
parents: parents
.slice(1)
.map((p) => p.name)
.join(" / "),
label: menuItem.name,
id: menuItem.id,
xmlid: menuItem.xmlid,
actionID: menuItem.actionID,
appID: menuItem.appID,
};
if (isApp) {
if (menuItem.webIconData) {
item.webIconData = menuItem.webIconData;
} else {
const [iconClass, color, backgroundColor] = (menuItem.webIcon || "").split(",");
if (backgroundColor !== undefined) {
// Could split in three parts?
item.webIcon = { iconClass, color, backgroundColor };
} else {
item.webIconData = "/web_enterprise/static/img/default_icon_app.png";
}
}
} else {
item.menuID = parents[1].id;
}
if (isApp) {
apps.push(item);
} else {
menuItems.push(item);
}
});
return { apps, menuItems };
}

View file

@ -0,0 +1,74 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { fuzzyLookup } from "@web/core/utils/search";
import { computeAppsAndMenuItems } from "@web/webclient/menus/menu_helpers";
import { Component } from "@odoo/owl";
class AppIconCommand extends Component {}
AppIconCommand.template = "web.AppIconCommand";
const commandCategoryRegistry = registry.category("command_categories");
commandCategoryRegistry.add("apps", { namespace: "/" }, { sequence: 10 });
commandCategoryRegistry.add("menu_items", { namespace: "/" }, { sequence: 20 });
const commandSetupRegistry = registry.category("command_setup");
commandSetupRegistry.add("/", {
emptyMessage: _lt("No menu found"),
name: _lt("menus"),
placeholder: _lt("Search for a menu..."),
});
const commandProviderRegistry = registry.category("command_provider");
commandProviderRegistry.add("menu", {
namespace: "/",
async provide(env, options) {
const result = [];
const menuService = env.services.menu;
let { apps, menuItems } = computeAppsAndMenuItems(menuService.getMenuAsTree("root"));
if (options.searchValue !== "") {
apps = fuzzyLookup(options.searchValue, apps, (menu) => menu.label);
fuzzyLookup(options.searchValue, menuItems, (menu) =>
(menu.parents + " / " + menu.label).split("/").reverse().join("/")
).forEach((menu) => {
result.push({
action() {
menuService.selectMenu(menu);
},
category: "menu_items",
name: menu.parents + " / " + menu.label,
href: menu.href || `#menu_id=${menu.id}&amp;action_id=${menu.actionID}`,
});
});
}
apps.forEach((menu) => {
const props = {};
if (menu.webIconData) {
const prefix = menu.webIconData.startsWith("P")
? "data:image/svg+xml;base64,"
: "data:image/png;base64,";
props.webIconData = menu.webIconData.startsWith("data:image")
? menu.webIconData
: prefix + menu.webIconData.replace(/\s/g, "");
} else {
props.webIcon = menu.webIcon;
}
result.push({
Component: AppIconCommand,
action() {
menuService.selectMenu(menu);
},
category: "apps",
name: menu.label,
href: menu.href || `#menu_id=${menu.id}&amp;action_id=${menu.actionID}`,
props,
});
});
return result;
},
});

View file

@ -0,0 +1,87 @@
/** @odoo-module **/
import { browser } from "../../core/browser/browser";
import { registry } from "../../core/registry";
import { session } from "@web/session";
const loadMenusUrl = `/web/webclient/load_menus`;
function makeFetchLoadMenus() {
const cacheHashes = session.cache_hashes;
let loadMenusHash = cacheHashes.load_menus || new Date().getTime().toString();
return async function fetchLoadMenus(reload) {
if (reload) {
loadMenusHash = new Date().getTime().toString();
} else if (odoo.loadMenusPromise) {
return odoo.loadMenusPromise;
}
const res = await browser.fetch(`${loadMenusUrl}/${loadMenusHash}`);
if (!res.ok) {
throw new Error("Error while fetching menus");
}
return res.json();
};
}
function makeMenus(env, menusData, fetchLoadMenus) {
let currentAppId;
return {
getAll() {
return Object.values(menusData);
},
getApps() {
return this.getMenu("root").children.map((mid) => this.getMenu(mid));
},
getMenu(menuID) {
return menusData[menuID];
},
getCurrentApp() {
if (!currentAppId) {
return;
}
return this.getMenu(currentAppId);
},
getMenuAsTree(menuID) {
const menu = this.getMenu(menuID);
if (!menu.childrenTree) {
menu.childrenTree = menu.children.map((mid) => this.getMenuAsTree(mid));
}
return menu;
},
async selectMenu(menu) {
menu = typeof menu === "number" ? this.getMenu(menu) : menu;
if (!menu.actionID) {
return;
}
await env.services.action.doAction(menu.actionID, { clearBreadcrumbs: true });
this.setCurrentMenu(menu);
},
setCurrentMenu(menu) {
menu = typeof menu === "number" ? this.getMenu(menu) : menu;
if (menu && menu.appID !== currentAppId) {
currentAppId = menu.appID;
env.bus.trigger("MENUS:APP-CHANGED");
// FIXME: lock API: maybe do something like
// pushState({menu_id: ...}, { lock: true}); ?
env.services.router.pushState({ menu_id: menu.id }, { lock: true });
}
},
async reload() {
if (fetchLoadMenus) {
menusData = await fetchLoadMenus(true);
env.bus.trigger("MENUS:APP-CHANGED");
}
},
};
}
export const menuService = {
dependencies: ["action", "router"],
async start(env) {
const fetchLoadMenus = makeFetchLoadMenus();
const menusData = await fetchLoadMenus();
return makeMenus(env, menusData, fetchLoadMenus);
},
};
registry.category("services").add("menu", menuService);

View file

@ -0,0 +1,218 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { debounce } from "@web/core/utils/timing";
import { ErrorHandler } from "@web/core/utils/components";
import {
Component,
onWillDestroy,
onWillUnmount,
useExternalListener,
useEffect,
useRef,
} from "@odoo/owl";
const systrayRegistry = registry.category("systray");
const getBoundingClientRect = Element.prototype.getBoundingClientRect;
class NavBarDropdownItem extends DropdownItem {}
NavBarDropdownItem.template = "web.NavBar.DropdownItem";
NavBarDropdownItem.props = {
...DropdownItem.props,
style: { type: String, optional: true },
};
export class MenuDropdown extends Dropdown {
setup() {
super.setup();
useEffect(
() => {
if (this.props.xmlid) {
this.togglerRef.el.dataset.menuXmlid = this.props.xmlid;
}
},
() => []
);
}
}
MenuDropdown.props.xmlid = {
type: String,
optional: true,
};
export class NavBar extends Component {
setup() {
this.currentAppSectionsExtra = [];
this.actionService = useService("action");
this.menuService = useService("menu");
this.root = useRef("root");
this.appSubMenus = useRef("appSubMenus");
const debouncedAdapt = debounce(this.adapt.bind(this), 250);
onWillDestroy(() => debouncedAdapt.cancel());
useExternalListener(window, "resize", debouncedAdapt);
let adaptCounter = 0;
const renderAndAdapt = () => {
adaptCounter++;
this.render();
};
systrayRegistry.on("UPDATE", this, renderAndAdapt);
this.env.bus.on("MENUS:APP-CHANGED", this, renderAndAdapt);
onWillUnmount(() => {
systrayRegistry.off("UPDATE", this);
this.env.bus.off("MENUS:APP-CHANGED", this);
});
// We don't want to adapt every time we are patched
// rather, we adapt only when menus or systrays have changed.
useEffect(
() => {
this.adapt();
},
() => [adaptCounter]
);
}
handleItemError(error, item) {
// remove the faulty component
item.isDisplayed = () => false;
Promise.resolve().then(() => {
throw error;
});
}
get currentApp() {
return this.menuService.getCurrentApp();
}
get currentAppSections() {
return (
(this.currentApp && this.menuService.getMenuAsTree(this.currentApp.id).childrenTree) ||
[]
);
}
// This dummy setter is only here to prevent conflicts between the
// Enterprise NavBar extension and the Website NavBar patch.
set currentAppSections(_) {}
get systrayItems() {
return systrayRegistry
.getEntries()
.map(([key, value]) => ({ key, ...value }))
.filter((item) => ("isDisplayed" in item ? item.isDisplayed(this.env) : true))
.reverse();
}
// This dummy setter is only here to prevent conflicts between the
// Enterprise NavBar extension and the Website NavBar patch.
set systrayItems(_) {}
/**
* Adapt will check the available width for the app sections to get displayed.
* If not enough space is available, it will replace by a "more" menu
* the least amount of app sections needed trying to fit the width.
*
* NB: To compute the widths of the actual app sections, a render needs to be done upfront.
* By the end of this method another render may occur depending on the adaptation result.
*/
async adapt() {
if (!this.root.el) {
/** @todo do we still need this check? */
// currently, the promise returned by 'render' is resolved at the end of
// the rendering even if the component has been destroyed meanwhile, so we
// may get here and have this.el unset
return;
}
// ------- Initialize -------
// Get the sectionsMenu
const sectionsMenu = this.appSubMenus.el;
if (!sectionsMenu) {
// No need to continue adaptations if there is no sections menu.
return;
}
// Save initial state to further check if new render has to be done.
const initialAppSectionsExtra = this.currentAppSectionsExtra;
const firstInitialAppSectionExtra = [...initialAppSectionsExtra].shift();
const initialAppId = firstInitialAppSectionExtra && firstInitialAppSectionExtra.appID;
// Restore (needed to get offset widths)
const sections = [
...sectionsMenu.querySelectorAll(":scope > *:not(.o_menu_sections_more)"),
];
for (const section of sections) {
section.classList.remove("d-none");
}
this.currentAppSectionsExtra = [];
// ------- Check overflowing sections -------
// use getBoundingClientRect to get unrounded values for width in order to avoid rounding problem
// with offsetWidth.
const sectionsAvailableWidth = getBoundingClientRect.call(sectionsMenu).width;
const sectionsTotalWidth = sections.reduce(
(sum, s) => sum + getBoundingClientRect.call(s).width,
0
);
if (sectionsAvailableWidth < sectionsTotalWidth) {
// Sections are overflowing
// Initial width is harcoded to the width the more menu dropdown will take
let width = 46;
for (const section of sections) {
if (sectionsAvailableWidth < width + section.offsetWidth) {
// Last sections are overflowing
const overflowingSections = sections.slice(sections.indexOf(section));
overflowingSections.forEach((s) => {
// Hide from normal menu
s.classList.add("d-none");
// Show inside "more" menu
const sectionId =
s.dataset.section ||
s.querySelector("[data-section]").getAttribute("data-section");
const currentAppSection = this.currentAppSections.find(
(appSection) => appSection.id.toString() === sectionId
);
this.currentAppSectionsExtra.push(currentAppSection);
});
break;
}
width += section.offsetWidth;
}
}
// ------- Final rendering -------
const firstCurrentAppSectionExtra = [...this.currentAppSectionsExtra].shift();
const currentAppId = firstCurrentAppSectionExtra && firstCurrentAppSectionExtra.appID;
if (
initialAppSectionsExtra.length === this.currentAppSectionsExtra.length &&
initialAppId === currentAppId
) {
// Do not render if more menu items stayed the same.
return;
}
return this.render();
}
onNavBarDropdownItemSelection(menu) {
if (menu) {
this.menuService.selectMenu(menu);
}
}
getMenuItemHref(payload) {
const parts = [`menu_id=${payload.id}`];
if (payload.actionID) {
parts.push(`action=${payload.actionID}`);
}
return "#" + parts.join("&");
}
}
NavBar.template = "web.NavBar";
NavBar.components = { Dropdown, DropdownItem: NavBarDropdownItem, MenuDropdown, ErrorHandler };

View file

@ -0,0 +1,136 @@
// = Main Navbar
// ============================================================================
.o_main_navbar {
@include print-variable(o-navbar-height, $o-navbar-height-xs);
@include media-breakpoint-up(lg) {
@include print-variable(o-navbar-height, $o-navbar-height-lg);
}
display: flex;
height: var(--o-navbar-height);
min-width: min-content;
border-bottom: $o-navbar-border-bottom;
background: $o-navbar-background;
// = Reset browsers defaults
// --------------------------------------------------------------------------
> ul {
padding: 0;
margin: 0;
list-style: none;
}
// = General components & behaviours
// --------------------------------------------------------------------------
.dropdown.show > .dropdown-toggle {
@extend %-main-navbar-entry-active;
}
.o_nav_entry {
@extend %-main-navbar-entry-base;
@extend %-main-navbar-entry-spacing;
@extend %-main-navbar-entry-bg-hover;
}
.dropdown-toggle {
@extend %-main-navbar-entry-base;
@extend %-main-navbar-entry-spacing;
@extend %-main-navbar-entry-bg-hover;
border: 0;
}
.dropdown-menu {
margin-top: 0;
border-top: 0;
@include border-top-radius(0);
}
.dropdown-header.dropdown-menu_group {
margin-top: 0;
}
.dropdown-item + .dropdown-header:not(.o_more_dropdown_section_group) {
margin-top: .3em;
}
.o_dropdown_menu_group_entry.dropdown-item {
padding-left: $o-dropdown-hpadding * 1.5;
+ .dropdown-item:not(.o_dropdown_menu_group_entry) {
margin-top: .8em;
}
}
// = Navbar Sections & Children
// --------------------------------------------------------------------------
.o_navbar_apps_menu .dropdown-toggle {
@extend %-main-navbar-entry-base;
padding-left: $o-horizontal-padding;
font-size: $o-navbar-brand-font-size;
}
.o_menu_brand {
@extend %-main-navbar-entry-base;
@extend %-main-navbar-entry-spacing;
padding-left: 0;
font-size: $o-navbar-brand-font-size;
&:hover {
background: none;
}
}
.o_menu_sections {
width: 0;
.o_more_dropdown_section_group {
margin-top: .8em;
&:first-child {
margin-top: $dropdown-padding-y * -1;
padding-top: $dropdown-padding-y * 1.5;
}
}
}
.o_menu_systray > * {
> a, > .dropdown > a, > button, > label { // <label> is required by website
@extend %-main-navbar-entry-base;
@extend %-main-navbar-entry-spacing;
@extend %-main-navbar-entry-bg-hover;
@include media-breakpoint-up(lg) {
padding: 0 $o-horizontal-padding * .6;
}
.badge {
margin-right: -.5em;
border: 0;
height: $o-navbar-badge-size + $o-navbar-badge-padding + $o-navbar-badge-size-adjust;
padding: ($o-navbar-badge-padding * .5) $o-navbar-badge-padding;
background-color: var(--o-navbar-badge-bg, #{$o-navbar-badge-bg});
font-size: $o-navbar-badge-size;
color: var(--o-navbar-badge-color, inherit);
text-shadow: var(--o-navbar-badge-text-shadow, #{1px 1px 0 rgba($black, .3)});
transform: translate(-10%, -30%);
}
}
}
}
// = SuperUser Design
// ============================================================================
body.o_is_superuser .o_menu_systray {
position: relative;
background: repeating-linear-gradient(135deg, #d9b904, #d9b904 10px, #373435 10px, #373435 20px);
&:before {
content: "";
@include o-position-absolute(2px, 2px, 2px, 2px);
background-color: $o-brand-odoo;
}
}

View file

@ -0,0 +1,55 @@
// = Main Navbar Variables
// ============================================================================
$o-navbar-height-xs: 46px !default;
$o-navbar-height-lg: 40px !default;
$o-navbar-height: $o-navbar-height-lg !default;
$o-navbar-background: $o-brand-odoo !default;
$o-navbar-border-bottom: 1px solid darken($o-brand-odoo, 10%) !default;
$o-navbar-entry-color: $o-white !default;
$o-navbar-entry-font-size: $o-font-size-base !default;
$o-navbar-entry-bg--hover: rgba($o-black, .08) !default;
$o-navbar-brand-font-size: $o-navbar-entry-font-size * 1.3 !default;
$o-navbar-badge-size: 11px !default;
$o-navbar-badge-size-adjust: 1px !default;
$o-navbar-badge-padding: 4px !default;
$o-navbar-badge-bg: $o-success !default;
// = % PseudoClasses
//
// Regroup and expose rules shared across components
// --------------------------------------------------------------------------
%-main-navbar-entry-base {
position: relative;
display: flex;
align-items: center;
width: auto;
height: var(--o-navbar-height, #{$o-navbar-height});
user-select: none;
background: transparent;
font-size: $o-navbar-entry-font-size;
@include o-hover-text-color(rgba($o-navbar-entry-color, .9), $o-navbar-entry-color);
}
%-main-navbar-entry-spacing {
padding: 0 $o-horizontal-padding * .75;
line-height: var(--o-navbar-height);
}
%-main-navbar-entry-bg-hover {
&:hover {
background-color: $o-navbar-entry-bg--hover;
}
}
%-main-navbar-entry-active {
&, &:hover, &:focus {
background: $o-navbar-entry-bg--hover;
color: $o-navbar-entry-color;
}
}

View file

@ -0,0 +1,178 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.NavBar" owl="1">
<header class="o_navbar" t-ref="root">
<nav
class="o_main_navbar"
data-command-category="navbar"
>
<!-- Apps Menu -->
<t t-call="web.NavBar.AppsMenu">
<t t-set="apps" t-value="menuService.getApps()" />
</t>
<!-- App Brand -->
<DropdownItem
t-if="currentApp"
href="getMenuItemHref(currentApp)"
t-esc="currentApp.name"
class="'o_menu_brand d-none d-md-block'"
dataset="{ menuXmlid: currentApp.xmlid, section: currentApp.id }"
onSelected="() => this.onNavBarDropdownItemSelection(currentApp)"
/>
<!-- Current App Sections -->
<t t-if="currentAppSections.length" t-call="web.NavBar.SectionsMenu">
<t t-set="sections" t-value="currentAppSections" />
</t>
<!-- Systray -->
<div class="o_menu_systray d-flex flex-shrink-0 ms-auto" role="menu">
<t t-foreach="systrayItems" t-as="item" t-key="item.key">
<!-- This ensures the correct order of the systray items -->
<div t-att-data-index="item.index"/>
<ErrorHandler onError="error => this.handleItemError(error, item)">
<t t-component="item.Component" t-props="item.props"/>
</ErrorHandler>
</t>
</div>
</nav>
</header>
</t>
<t t-name="web.NavBar.AppsMenu" owl="1">
<Dropdown hotkey="'h'" title="'Home Menu'" class="'o_navbar_apps_menu'">
<t t-set-slot="toggler">
<i class="oi oi-apps" />
</t>
<DropdownItem
t-foreach="apps"
t-as="app"
t-key="app.id"
class="{ 'o_app': true, focus: menuService.getCurrentApp() === app }"
href="getMenuItemHref(app)"
t-esc="app.name"
dataset="{ menuXmlid: app.xmlid, section: app.id }"
onSelected="() => this.onNavBarDropdownItemSelection(app)"
/>
</Dropdown>
</t>
<t t-name="web.NavBar.SectionsMenu" owl="1">
<div class="o_menu_sections d-none d-md-flex flex-grow-1 flex-shrink-1" t-ref="appSubMenus" role="menu">
<t t-foreach="sections" t-as="section" t-key="section.id">
<t
t-set="sectionsVisibleCount"
t-value="(sections.length - currentAppSectionsExtra.length)"
/>
<t t-if="section_index lt Math.min(10, sectionsVisibleCount)">
<t t-set="hotkey" t-value="((section_index + 1) % 10).toString()" />
</t>
<t t-else="">
<t t-set="hotkey" t-value="undefined" />
</t>
<t t-if="!section.childrenTree.length">
<DropdownItem
title="section.name"
class="'o_nav_entry'"
href="getMenuItemHref(section)"
hotkey="hotkey"
t-esc="section.name"
dataset="{ menuXmlid: section.xmlid, section: section.id }"
onSelected="() => this.onNavBarDropdownItemSelection(section)"
/>
</t>
<t t-else="">
<MenuDropdown
hotkey="hotkey"
title="section.name"
xmlid="section.xmlid"
>
<t t-set-slot="toggler">
<span t-esc="section.name" t-att-data-section="section.id" />
</t>
<t t-call="web.NavBar.SectionsMenu.Dropdown.MenuSlot">
<t t-set="items" t-value="section.childrenTree" />
<t t-set="decalage" t-value="20" />
</t>
</MenuDropdown>
</t>
</t>
<t t-if="currentAppSectionsExtra.length" t-call="web.NavBar.SectionsMenu.MoreDropdown">
<t t-set="sections" t-value="currentAppSectionsExtra" />
<t t-if="sectionsVisibleCount lt 10">
<t t-set="hotkey" t-value="(sectionsVisibleCount + 1 % 10).toString()" />
</t>
</t>
</div>
</t>
<t t-name="web.NavBar.DropdownItem" t-inherit="web.DropdownItem" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-tag]" position="attributes">
<attribute name="t-att-style">props.style</attribute>
</xpath>
</t>
<t t-name="web.NavBar.SectionsMenu.Dropdown.MenuSlot" owl="1">
<t t-set="style" t-value="`padding-left: ${decalage}px;`" />
<t t-foreach="items" t-as="item" t-key="item.id">
<DropdownItem
t-if="!item.childrenTree.length"
href="getMenuItemHref(item)"
class="{
'dropdown-item': true,
o_dropdown_menu_group_entry: decalage gt 20
}"
style="style"
t-esc="item.name"
dataset="{ menuXmlid: item.xmlid, section: item.id }"
onSelected="() => this.onNavBarDropdownItemSelection(item)"
/>
<t t-else="">
<div class="dropdown-menu_group dropdown-header" t-att-style="style" t-esc="item.name" />
<t t-call="web.NavBar.SectionsMenu.Dropdown.MenuSlot">
<t t-set="items" t-value="item.childrenTree" />
<t t-set="decalage" t-value="decalage + 12" />
</t>
</t>
</t>
</t>
<t t-name="web.NavBar.SectionsMenu.MoreDropdown" owl="1">
<Dropdown class="'o_menu_sections_more'" title="'More Menu'" hotkey="hotkey">
<t t-set-slot="toggler">
<i class="fa fa-plus"/>
</t>
<t t-foreach="sections" t-as="section" t-key="section.id">
<t t-if="!section.childrenTree.length">
<DropdownItem
class="'o_more_dropdown_section'"
href="getMenuItemHref(section)"
t-esc="section.name"
dataset="{ menuXmlid: section.xmlid, section: section.id }"
onSelected="() => this.onNavBarDropdownItemSelection(section)"
/>
</t>
<t t-else="">
<div
class="o_more_dropdown_section o_more_dropdown_section_group dropdown-header bg-100"
t-esc="section.name"
/>
<t t-call="web.NavBar.SectionsMenu.Dropdown.MenuSlot">
<t t-set="items" t-value="section.childrenTree" />
<t t-set="decalage" t-value="20" />
</t>
</t>
</t>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,87 @@
//------------------------------------------------------------------------------
// Select2
//------------------------------------------------------------------------------
// Redefine select2 dropdown color values in variables to be easily handled.
// eg. for the dark mode
.select2-container .select2-choice {
background-color: $dropdown-bg;
background-image: var(--select2-background-image, linear-gradient(to top, #eee 0%, #fff 50%));
color: var(--select2-color, #444);
}
.select2-container.select2-drop-above .select2-choice {
background-image: var(--select2__dropAbove-background-image, linear-gradient(to top, #eee 0%, #fff 90%));
}
.select2-drop-mask {
background-color: $o-view-background-color;
}
.select2-drop {
background-color: $dropdown-bg;
color: $o-main-text-color;
}
.select2-container:not(.select2-dropdown-open) .select2-choice .select2-arrow {
background: var(--select2__arrow-background, linear-gradient(to top, #ccc 0%, #eee 60%));
}
.select2-search input,
html[dir="rtl"] .select2-search input,
.select2-search input.select2-active {
background-color: var(--select2__searchInput-background-color, #fff);
background-repeat: no-repeat;
}
.select2-search input {
background-image: var(--select2__searchInput-background-image, url('/web/static/lib/select2/select2.png'), linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0);
background-position: 100% -22px;
}
html[dir="rtl"] .select2-search input {
background-image: var(--select2__searchInput-background-image, url('/web/static/lib/select2/select2.png'), linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0);
background-position: -37px -22px;
}
.select2-search input.select2-active {
background-image: var(--select2__searchInput-background-image--active, url('/web/static/lib/select2/select2-spinner.gif'), linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0);
background-position: 100%;
}
.select2-dropdown-open .select2-choice {
box-shadow: var(--select2-box-shadow, 0 1px 0 #fff inset);
}
.select2-results .select2-highlighted {
background: $o-brand-primary;
color: var(--select2__resultsHighlighted-color, #fff);
}
.select2-results {
.select2-no-results, .select2-searching, .select2-ajax-error, .select2-selection-limit {
background: $dropdown-bg;
color: $o-main-text-color;
}
}
.select2-container .select2-choice,
.select2-container.select2-drop-above .select2-choice,
.select2-drop,
.select2-drop.select2-drop-above,
.select2-drop-auto-width,
.select2-container .select2-choice .select2-arrow,
html[dir="rtl"] .select2-container .select2-choice .select2-arrow,
.select2-search input {
border-color: var(--select2-border-color, #aaa);
}
.select2-drop-active,
.select2-drop.select2-drop-above.select2-drop-active,
.select2-container-active .select2-choice,
.select2-container-active .select2-choices,
.select2-dropdown-open.select2-drop-above .select2-choice,
.select2-dropdown-open.select2-drop-above .select2-choices,
.form-control.select2-container.select2-dropdown-open {
border-color: var(--select2-border-color--active, #5897fb);
}

View file

@ -0,0 +1,42 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { BooleanField } from "@web/views/fields/boolean/boolean_field";
import { useService } from "@web/core/utils/hooks";
import { UpgradeDialog } from "./upgrade_dialog";
/**
* The upgrade boolean field is intended to be used in config settings.
* When checked, an upgrade popup is showed to the user.
*/
export class UpgradeBooleanField extends BooleanField {
setup() {
super.setup();
this.dialogService = useService("dialog");
this.isEnterprise = odoo.info && odoo.info.isEnterprise;
}
async onChange(newValue) {
if (!this.isEnterprise) {
this.dialogService.add(
UpgradeDialog,
{},
{
onClose: () => {
this.props.update(false);
},
}
);
} else {
super.onChange(...arguments);
}
}
}
UpgradeBooleanField.isUpgradeField = true;
UpgradeBooleanField.additionalClasses = [
...UpgradeBooleanField.additionalClasses || [],
"o_field_boolean",
];
registry.category("fields").add("upgrade_boolean", UpgradeBooleanField);

View file

@ -0,0 +1,22 @@
/** @odoo-module */
import { Dialog } from "@web/core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
export class UpgradeDialog extends Component {
setup() {
this.orm = useService("orm");
this.router = useService("router");
}
async _confirmUpgrade() {
const usersCount = await this.orm.call("res.users", "search_count", [
[["share", "=", false]],
]);
window.open("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + usersCount, "_blank");
this.props.close();
}
}
UpgradeDialog.template = "web.UpgradeDialog";
UpgradeDialog.components = { Dialog };

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.UpgradeDialog" owl="1">
<Dialog size="'md'" title="'Odoo Enterprise'">
<div class="row" role="status">
<div class="col-6">
Get this feature and much more with Odoo Enterprise!
<ul class="list-unstyled">
<li><i class="fa fa-check"></i> Access to all Enterprise Apps</li>
<li><i class="fa fa-check"></i> New design</li>
<li><i class="fa fa-check"></i> Mobile support</li>
<li><i class="fa fa-check"></i> Upgrade to future versions</li>
<li><i class="fa fa-check"></i> Bugfixes guarantee</li>
<li><a href="http://www.odoo.com/editions?utm_source=db&amp;utm_medium=enterprise" target="_blank"><i class="fa fa-plus"></i> And more</a></li>
</ul>
</div>
<div class="col-6">
<img class="img-fluid" t-att-src="'/web/static/img/enterprise_upgrade.jpg'" draggable="false" alt="Upgrade to enterprise"/>
</div>
</div>
<t t-set-slot="footer" owl="1">
<button class="btn btn-primary" t-on-click="_confirmUpgrade">Upgrade now</button>
<button class="btn btn-secondary" t-on-click="this.props.close">Cancel</button>
</t>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,27 @@
/** @odoo-module **/
import { FormLabel } from "@web/views/form/form_label";
import { HighlightText } from "./highlight_text";
export class FormLabelHighlightText extends FormLabel {
setup() {
super.setup();
const isEnterprise = odoo.info && odoo.info.isEnterprise;
if (
this.props.fieldInfo &&
this.props.fieldInfo.FieldComponent &&
this.props.fieldInfo.FieldComponent.isUpgradeField &&
!isEnterprise
) {
this.upgradeEnterprise = true;
}
}
get className() {
if (this.props.className) {
return this.props.className;
}
return super.className;
}
}
FormLabelHighlightText.template = "web.FormLabelHighlightText";
FormLabelHighlightText.components = { HighlightText };

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FormLabelHighlightText" owl="1">
<label class="o_form_label" t-att-for="props.id" t-att-class="className">
<HighlightText originalText="props.string"/>
<t t-if="upgradeEnterprise">
<span class="badge text-bg-primary oe_inline o_enterprise_label">Enterprise</span>
</t>
</label>
</t>
</templates>

View file

@ -0,0 +1,25 @@
/** @odoo-module **/
import { escapeRegExp } from "@web/core/utils/strings";
import { Component, useState, onWillRender } from "@odoo/owl";
export class HighlightText extends Component {
setup() {
this.searchState = useState(this.env.searchState);
onWillRender(() => {
const splitText = this.props.originalText.split(
new RegExp(`(${escapeRegExp(this.searchState.value)})`, "ig")
);
this.splitText =
this.searchState.value.length && splitText.length > 1
? splitText
: [this.props.originalText];
});
}
}
HighlightText.template = "web.HighlightText";
HighlightText.props = {
originalText: String,
};
HighlightText.highlightClass = "highlighter";

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.HighlightText" owl="1">
<t t-foreach="splitText" t-as="name" t-key="name_index">
<b t-if="name_index % 2" t-out="name" t-att-class="constructor.highlightClass"/>
<t t-else="" t-out="name"/>
</t>
</t>
</templates>

View file

@ -0,0 +1,16 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { RadioField } from "@web/views/fields/radio/radio_field";
import { FormLabelHighlightText } from "./form_label_highlight_text";
export class SettingsRadioField extends RadioField {}
SettingsRadioField.extractStringExpr = (fieldName, record) => {
const radioItems = SettingsRadioField.getItems(fieldName, record);
return radioItems.map((r) => r[1]);
};
SettingsRadioField.template = "web.SettingsRadioField";
SettingsRadioField.components = { ...RadioField.components, FormLabelHighlightText };
registry.category("fields").add("base_settings.radio", SettingsRadioField);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SettingsRadioField" t-inherit="web.RadioField" t-inherit-mode="primary" owl="1">
<xpath expr="//label" position="replace">
<FormLabelHighlightText className="'form-check-label'" id="`${id}_${item[0]}`" string="item[1]"/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,48 @@
/** @odoo-module **/
import { escapeRegExp } from "@web/core/utils/strings";
import { Component, useState, useChildSubEnv } from "@odoo/owl";
export class Setting extends Component {
setup() {
this.state = useState({
search: this.env.searchState,
showAllContainer: this.env.showAllContainer,
});
// Don't search on a header setting
if (this.props.type === "header") {
useChildSubEnv({ searchState: { value: "" } });
}
this.labels = this.props.labels || [];
}
visible() {
if (!this.state.search.value) {
return true;
}
// Always shown a header setting
if (this.props.type === "header") {
return true;
}
if (this.state.showAllContainer.showAllContainer) {
return true;
}
const regexp = new RegExp(escapeRegExp(this.state.search.value), "i");
if (regexp.test(this.labels.join())) {
return true;
}
return false;
}
get classNames() {
const { class: _class, type } = this.props;
const classNames = {
o_setting_box: true,
o_searchable_setting: this.labels.length && type !== "header",
[_class]: Boolean(_class),
};
return classNames;
}
}
Setting.template = "web.Setting";

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Setting" owl="1">
<div t-att-class="classNames" t-att-title="props.title" t-if="visible()">
<t t-slot="default"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,29 @@
/** @odoo-module **/
import { Component, useState, useEffect, useRef } from "@odoo/owl";
export class SettingsApp extends Component {
setup() {
this.state = useState({
search: this.env.searchState,
});
this.settingsAppRef = useRef("settingsApp");
useEffect(
() => {
if (this.settingsAppRef.el) {
const force =
this.state.search.value &&
!this.settingsAppRef.el.querySelector(
".o_settings_container:not(.d-none)"
) &&
!this.settingsAppRef.el.querySelector(
".o_setting_box.o_searchable_setting"
);
this.settingsAppRef.el.classList.toggle("d-none", force);
}
},
() => [this.state.search.value]
);
}
}
SettingsApp.template = "web.SettingsApp";

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SettingsApp" owl="1">
<div class="app_settings_block" t-if="props.selectedTab === props.key or state.search.value.length !== 0" t-att-string="props.string" t-att-data-key="props.key" t-ref="settingsApp">
<div class="settingSearchHeader h4" t-if="state.search.value.length !== 0" role="search">
<img class="icon" t-att-src="props.imgurl" alt="Search"></img>
<span class="appName"><t t-esc="props.string"/></span>
</div>
<t t-slot="default"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,57 @@
/** @odoo-module **/
import { HighlightText } from "./../highlight_text/highlight_text";
import { escapeRegExp } from "@web/core/utils/strings";
import { Component, useState, useRef, useEffect, onWillRender, useChildSubEnv } from "@odoo/owl";
export class SettingsContainer extends Component {
setup() {
this.state = useState({
search: this.env.searchState,
});
this.showAllContainerState = useState({
showAllContainer: false,
});
useChildSubEnv({
showAllContainer: this.showAllContainerState,
});
this.settingsContainerRef = useRef("settingsContainer");
this.settingsContainerTitleRef = useRef("settingsContainerTitle");
this.settingsContainerTipRef = useRef("settingsContainerTip");
useEffect(
() => {
const regexp = new RegExp(escapeRegExp(this.state.search.value), "i");
const force =
this.state.search.value &&
!regexp.test([this.props.title, this.props.tip].join()) &&
!this.settingsContainerRef.el.querySelector(
".o_setting_box.o_searchable_setting"
);
this.toggleContainer(force);
},
() => [this.state.search.value]
);
onWillRender(() => {
const regexp = new RegExp(escapeRegExp(this.state.search.value), "i");
if (regexp.test([this.props.title, this.props.tip].join())) {
this.showAllContainerState.showAllContainer = true;
} else {
this.showAllContainerState.showAllContainer = false;
}
});
}
toggleContainer(force) {
if (this.settingsContainerTitleRef.el) {
this.settingsContainerTitleRef.el.classList.toggle("d-none", force);
}
if (this.settingsContainerTipRef.el) {
this.settingsContainerTipRef.el.classList.toggle("d-none", force);
}
this.settingsContainerRef.el.classList.toggle("d-none", force);
}
}
SettingsContainer.template = "web.SettingsContainer";
SettingsContainer.components = {
HighlightText,
};

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SettingsContainer" owl="1">
<h2 t-if="props.title" t-ref="settingsContainerTitle"><HighlightText originalText="props.title"/></h2>
<h3 t-if="props.tip" class="o_setting_tip text-muted" t-ref="settingsContainerTip"><HighlightText originalText="props.tip"/></h3>
<div t-att-class="props.class" t-ref="settingsContainer">
<t t-slot="default"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,67 @@
/** @odoo-module **/
import { ActionSwiper } from "@web/core/action_swiper/action_swiper";
import { Component, useState, useRef, useEffect } from "@odoo/owl";
export class SettingsPage extends Component {
setup() {
this.state = useState({
selectedTab: "",
search: this.env.searchState,
});
if (this.props.modules) {
this.state.selectedTab = this.props.initialTab || this.props.modules[0].key;
}
this.settingsRef = useRef("settings");
this.scrollMap = Object.create(null);
useEffect(
(settingsEl, currentTab) => {
if (!settingsEl) {
return;
}
const { scrollTop } = this.scrollMap[currentTab] || 0;
settingsEl.scrollTop = scrollTop;
},
() => [this.settingsRef.el, this.state.selectedTab]
);
}
getCurrentIndex() {
return this.props.modules.findIndex((object) => {
return object.key === this.state.selectedTab;
});
}
hasRightSwipe() {
return (
this.env.isSmall && this.state.search.value.length === 0 && this.getCurrentIndex() !== 0
);
}
hasLeftSwipe() {
return (
this.env.isSmall &&
this.state.search.value.length === 0 &&
this.getCurrentIndex() !== this.props.modules.length - 1
);
}
onRightSwipe() {
this.state.selectedTab = this.props.modules[this.getCurrentIndex() - 1].key;
}
onLeftSwipe() {
this.state.selectedTab = this.props.modules[this.getCurrentIndex() + 1].key;
}
onSettingTabClick(key) {
if (this.settingsRef.el) {
const { scrollTop } = this.settingsRef.el;
this.scrollMap[this.state.selectedTab] = { scrollTop };
}
this.state.selectedTab = key;
this.env.searchState.value = "";
}
}
SettingsPage.template = "web.SettingsPage";
SettingsPage.components = { ActionSwiper };

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SettingsPage" owl="1">
<div class="settings_tab" t-if="!env.isSmall or state.search.value.length === 0">
<t t-foreach="props.modules" t-as="module" t-key="module.key">
<div class="tab" t-if="!module.isVisible" t-att-class="(state.selectedTab === module.key and state.search.value.length === 0) ? 'selected': ''" t-att-data-key="module.key" role="tab" t-on-click="() => this.onSettingTabClick(module.key)">
<div class="icon d-none d-md-block" t-attf-style="background : url('{{module.imgurl}}') no-repeat center;background-size:contain;"/> <span class="app_name"><t t-esc="module.string"/></span>
</div>
</t>
</div>
<ActionSwiper
onRightSwipe = " hasRightSwipe() ? {
action: onRightSwipe.bind(this),
} : undefined"
onLeftSwipe = " hasLeftSwipe() ? {
action: onLeftSwipe.bind(this),
} : undefined"
animationOnMove="false"
animationType="'forwards'"
swipeDistanceRatio="6">
<div class="settings" t-ref="settings">
<t t-slot="NoContentHelper" t-if="props.slots['NoContentHelper'].isVisible"/>
<t t-slot="default" selectedTab="state.selectedTab"/>
</div>
</ActionSwiper>
</t>
</templates>

View file

@ -0,0 +1,21 @@
/** @odoo-module **/
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _lt } from "@web/core/l10n/translation";
export class SettingsConfirmationDialog extends ConfirmationDialog {
_stayHere() {
if (this.props.stayHere) {
this.props.stayHere();
}
this.props.close();
}
}
SettingsConfirmationDialog.defaultProps = {
title: _lt("Unsaved changes"),
};
SettingsConfirmationDialog.template = "web.SettingsConfirmationDialog";
SettingsConfirmationDialog.props = {
...ConfirmationDialog.props,
stayHere: { type: Function, optional: true },
};

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SettingsConfirmationDialog" owl="1">
<Dialog size="'md'" title="props.title" modalRef="modalRef">
<t t-esc="props.body" />
<t t-set-slot="footer" owl="1">
<button class="btn btn-primary" t-on-click="_confirm">
Save
</button>
<button class="btn btn-secondary" t-on-click="_stayHere">
Stay Here
</button>
<button class="btn btn-secondary" t-on-click="_cancel">
Discard
</button>
</t>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,16 @@
/** @odoo-module */
import { evaluateExpr } from "@web/core/py_js/py";
import { formView } from "@web/views/form/form_view";
export class SettingsArchParser extends formView.ArchParser {
parseXML() {
const result = super.parseXML(...arguments);
Array.from(result.querySelectorAll(".app_settings_header field")).forEach((el) => {
const options = evaluateExpr(el.getAttribute("options") || "{}");
options.isHeaderField = true;
el.setAttribute("options", JSON.stringify(options));
});
return result;
}
}

View file

@ -0,0 +1,206 @@
/** @odoo-module **/
import { append, createElement } from "@web/core/utils/xml";
import { FormCompiler } from "@web/views/form/form_compiler";
import { getModifier } from "@web/views/view_compiler";
function compileSettingsPage(el, params) {
const settingsPage = createElement("SettingsPage");
settingsPage.setAttribute("slots", "{NoContentHelper:props.slots.NoContentHelper}");
settingsPage.setAttribute("initialTab", "props.initialApp");
settingsPage.setAttribute("t-slot-scope", "settings");
//props
const modules = [];
for (const child of el.children) {
if (child.nodeName === "div" && child.classList.value.includes("app_settings_block")) {
params.module = {
key: child.getAttribute("data-key"),
string: child.getAttribute("string"),
imgurl: getAppIconUrl(child.getAttribute("data-key")),
isVisible: getModifier(child, "invisible"),
};
if (!child.classList.value.includes("o_not_app")) {
modules.push(params.module);
append(settingsPage, this.compileNode(child, params));
}
}
}
settingsPage.setAttribute("modules", JSON.stringify(modules));
return settingsPage;
}
function getAppIconUrl(module) {
return module === "general_settings"
? "/base/static/description/settings.png"
: "/" + module + "/static/description/icon.png";
}
function compileSettingsApp(el, params) {
const settingsApp = createElement("SettingsApp");
settingsApp.setAttribute("t-props", JSON.stringify(params.module));
settingsApp.setAttribute("selectedTab", "settings.selectedTab");
for (const child of el.children) {
append(settingsApp, this.compileNode(child, params));
}
return settingsApp;
}
function compileSettingsHeader(el, params) {
const header = el.cloneNode();
for (const child of el.children) {
append(header, this.compileNode(child, { ...params, settingType: "header" }));
}
return header;
}
let settingsContainer = null;
function compileSettingsGroupTitle(el, params) {
if (!settingsContainer) {
settingsContainer = createElement("SettingsContainer");
}
settingsContainer.setAttribute("title", `\`${el.textContent}\``);
}
function compileSettingsGroupTip(el, params) {
if (!settingsContainer) {
settingsContainer = createElement("SettingsContainer");
}
settingsContainer.setAttribute("tip", `\`${el.textContent}\``);
}
function compileSettingsContainer(el, params) {
if (!settingsContainer) {
settingsContainer = createElement("SettingsContainer");
}
for (const child of el.children) {
append(settingsContainer, this.compileNode(child, params));
}
const res = settingsContainer;
settingsContainer = null;
return res;
}
function compileSettingBox(el, params) {
const setting = createElement("Setting");
params.labels = [];
if (params.settingType) {
setting.setAttribute("type", `\`${params.settingType}\``);
}
if (el.getAttribute("title")) {
setting.setAttribute("title", `\`${el.getAttribute("title")}\``);
}
for (const child of el.children) {
append(setting, this.compileNode(child, params));
}
setting.setAttribute("labels", JSON.stringify(params.labels));
return setting;
}
function compileField(el, params) {
const res = this.compileField(el, params);
let widgetName;
if (el.hasAttribute("widget")) {
widgetName = el.getAttribute("widget");
const label = params.getFieldExpr(el.getAttribute("name"), widgetName);
if (label) {
params.labels.push(label);
}
}
return res;
}
const labelsWeak = new WeakMap();
function compileLabel(el, params) {
const res = this.compileLabel(el, params);
// It the node is a FormLabel component node, the label is
// localized *after* the field.
// We don't know yet if the label refers to a field or not.
if (res.textContent && res.tagName !== "FormLabel") {
params.labels.push(res.textContent.trim());
labelsWeak.set(res, { textContent: res.textContent });
highlightElement(res);
}
return res;
}
function compileGenericLabel(el, params) {
const res = this.compileGenericNode(el, params);
if (res.textContent) {
params.labels.push(res.textContent.trim());
highlightElement(res);
}
return res;
}
function highlightElement(el) {
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
if (child.textContent.trim()) {
const highlight = createElement("HighlightText");
highlight.setAttribute("originalText", `\`${child.textContent}\``);
el.replaceChild(highlight, child);
}
} else if (child.childNodes.length) {
highlightElement(child);
}
}
}
function compileForm() {
const res = this.compileForm(...arguments);
res.classList.remove("o_form_nosheet");
res.classList.remove("p-2");
res.classList.remove("px-lg-5");
return res;
}
export class SettingsFormCompiler extends FormCompiler {
setup() {
super.setup();
this.compilers.unshift(
{ selector: "form", fn: compileForm },
{ selector: "div.settings", fn: compileSettingsPage },
{ selector: "div.app_settings_block", fn: compileSettingsApp },
{ selector: "div.app_settings_header", fn: compileSettingsHeader },
// objects to show/hide in the search
{ selector: "div.o_setting_box", fn: compileSettingBox },
{ selector: "div.o_settings_container", fn: compileSettingsContainer },
// h2
{ selector: "h2", fn: compileSettingsGroupTitle },
{ selector: "h3.o_setting_tip", fn: compileSettingsGroupTip },
// search terms and highlight :
{ selector: "label", fn: compileLabel, doNotCopyAttributes: true },
{ selector: "span.o_form_label", fn: compileGenericLabel },
{ selector: "div.text-muted", fn: compileGenericLabel },
{ selector: "field", fn: compileField }
);
}
createLabelFromField(fieldId, fieldName, fieldString, label, params) {
const labelweak = labelsWeak.get(label);
if (labelweak) {
// Undo what we've done when we where not sure whether this label was attached to a field
// Now, we now it is.
label.textContent = labelweak.textContent;
}
const res = super.createLabelFromField(fieldId, fieldName, fieldString, label, params);
if (labelweak || label.hasAttribute("data-no-label")) {
// the work of pushing the label in the search structure is already done
return res;
}
let labelText = label.textContent || fieldString;
labelText = labelText ? labelText : params.record.fields[fieldName].string;
params.labels.push(labelText);
return res;
}
}

View file

@ -0,0 +1,140 @@
/** @odoo-module **/
import { useAutofocus } from "@web/core/utils/hooks";
import { pick } from "@web/core/utils/objects";
import { formView } from "@web/views/form/form_view";
import { SettingsConfirmationDialog } from "./settings_confirmation_dialog";
import { SettingsFormRenderer } from "./settings_form_renderer";
import { useSubEnv, useState, useRef, useEffect } from "@odoo/owl";
export class SettingsFormController extends formView.Controller {
setup() {
super.setup();
useAutofocus();
this.state = useState({ displayNoContent: false });
this.searchState = useState({ value: "" });
this.rootRef = useRef("root");
useSubEnv({ searchState: this.searchState });
useEffect(
() => {
if (this.searchState.value) {
if (
this.rootRef.el.querySelector(".o_settings_container:not(.d-none)") ||
this.rootRef.el.querySelector(
".settings .o_settings_container:not(.d-none) .o_setting_box.o_searchable_setting"
)
) {
this.state.displayNoContent = false;
} else {
this.state.displayNoContent = true;
}
} else {
this.state.displayNoContent = false;
}
},
() => [this.searchState.value]
);
useEffect(() => {
if (this.env.__getLocalState__) {
this.env.__getLocalState__.remove(this);
}
});
this.initialApp = "module" in this.props.context && this.props.context.module;
}
/**
* @override
*/
async beforeExecuteActionButton(clickParams) {
if (clickParams.name === "cancel") {
return true;
}
if (
this.model.root.isDirty &&
!["execute"].includes(clickParams.name) &&
!clickParams.noSaveDialog
) {
return this._confirmSave();
} else {
return this.model.root.save({ stayInEdition: true });
}
}
displayName() {
return this.env._t("Settings");
}
beforeLeave() {
if (this.model.root.isDirty) {
return this._confirmSave();
}
}
//This is needed to avoid the auto save when unload
beforeUnload() {}
//This is needed to avoid writing the id on the url
updateURL() {}
async saveButtonClicked() {
await this._save();
}
async _save() {
this.env.onClickViewButton({
clickParams: {
name: "execute",
type: "object",
},
getResParams: () =>
pick(this.model.root, "context", "evalContext", "resModel", "resId", "resIds"),
});
}
discard() {
this.env.onClickViewButton({
clickParams: {
name: "cancel",
type: "object",
special: "cancel",
},
getResParams: () =>
pick(this.model.root, "context", "evalContext", "resModel", "resId", "resIds"),
});
}
async _confirmSave() {
let _continue = true;
await new Promise((resolve) => {
this.dialogService.add(SettingsConfirmationDialog, {
body: this.env._t("Would you like to save your changes?"),
confirm: async () => {
await this._save();
// It doesn't make sense to do the action of the button
// as the res.config.settings `execute` method will trigger a reload.
_continue = false;
resolve();
},
cancel: async () => {
await this.model.root.discard();
await this.model.root.save({ stayInEdition: true });
_continue = true;
resolve();
},
stayHere: () => {
_continue = false;
resolve();
},
});
});
return _continue;
}
}
SettingsFormController.components = {
...formView.Controller.components,
Renderer: SettingsFormRenderer,
};
SettingsFormController.template = "web.SettingsFormView";

View file

@ -0,0 +1,61 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { FormRenderer } from "@web/views/form/form_renderer";
import { FormLabelHighlightText } from "./highlight_text/form_label_highlight_text";
import { HighlightText } from "./highlight_text/highlight_text";
import { Setting } from "./settings/setting";
import { SettingsContainer } from "./settings/settings_container";
import { SettingsApp } from "./settings/settings_app";
import { SettingsPage } from "./settings/settings_page";
import { useState } from "@odoo/owl";
const fieldRegistry = registry.category("fields");
const labels = Object.create(null);
export class SettingsFormRenderer extends FormRenderer {
setup() {
if (!labels[this.props.archInfo.arch]) {
labels[this.props.archInfo.arch] = [];
}
super.setup();
this.searchState = useState(this.env.searchState);
}
get shouldAutoFocus() {
return false;
}
get compileParams() {
return {
...super.compileParams,
labels: labels[this.props.archInfo.arch],
getFieldExpr: this.getFieldExpr,
record: this.props.record,
};
}
getFieldExpr(fieldName, fieldWidget) {
const name = `base_settings.${fieldWidget}`;
let fieldClass;
if (fieldRegistry.contains(name)) {
fieldClass = fieldRegistry.get(name);
}
if (fieldClass && fieldClass.extractStringExpr) {
return fieldClass.extractStringExpr(fieldName, this.record);
} else {
return "";
}
}
}
SettingsFormRenderer.components = {
...FormRenderer.components,
Setting,
SettingsContainer,
SettingsPage,
SettingsApp,
HighlightText,
FormLabel: FormLabelHighlightText,
};

View file

@ -0,0 +1,48 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { formView } from "@web/views/form/form_view";
import { SettingsFormController } from "./settings_form_controller";
import { SettingsFormRenderer } from "./settings_form_renderer";
import { SettingsFormCompiler } from "./settings_form_compiler";
import BasicModel from "web.BasicModel";
import { SettingsArchParser } from "./settings_form_arch_parser";
const BaseSettingsModel = BasicModel.extend({
isNew(id) {
return this.localData[id].model === "res.config.settings"
? true
: this._super.apply(this, arguments);
},
_applyChange: function (recordID, changes, options) {
// Check if the changes isHeaderField.
const record = this.localData[recordID];
let isHeaderField = false;
for (const fieldName of Object.keys(changes)) {
const fieldInfo = record.fieldsInfo[options.viewType][fieldName];
isHeaderField = fieldInfo.options && fieldInfo.options.isHeaderField;
}
if (isHeaderField) {
options.doNotSetDirty = true;
}
return this._super.apply(this, arguments);
},
});
class SettingsRelationalModel extends formView.Model {}
SettingsRelationalModel.LegacyModel = BaseSettingsModel;
export const settingsFormView = {
...formView,
display: {},
buttonTemplate: "web.SettingsFormView.Buttons",
ArchParser: SettingsArchParser,
Model: SettingsRelationalModel,
ControlPanel: ControlPanel,
Controller: SettingsFormController,
Compiler: SettingsFormCompiler,
Renderer: SettingsFormRenderer,
};
registry.category("views").add("base_settings", settingsFormView);

View file

@ -0,0 +1,227 @@
body:not(.o_touch_device) .o_settings_container .o_field_selection {
&:not(:hover):not(:focus-within) {
& select:not(:hover) {
background: transparent $o-caret-down no-repeat right center;
}
}
}
.o_base_settings {
height: 100%;
overflow: auto;
}
.o_setting_container {
height: 100%;
> * {
overflow: auto;
flex: 1 0 auto;
}
}
.o_setting_box {
margin-bottom: 8px;
margin-top: 8px;
position: relative;
}
.o_setting_left_pane {
width: 24px;
float: left;
}
.o_setting_right_pane {
margin-left: 24px;
border-left: 1px solid $border-color;
padding-left: 12px;
.o_input_dropdown > .o_input {
width: 100%;
}
.o_field_widget:not(.o_field_boolean) {
@include media-breakpoint-up(md) {
width: 50%;
}
flex: 0 0 auto;
&.o_field_many2manytags > .o_field_widget {
flex: 1 0 50px;
}
}
button.btn-link:first-child {
padding: 0;
}
a.oe-link {
font-size: 12px;
}
}
.o_enterprise_label {
position: absolute;
top: 0px;
right: 40px;
}
// MIXINS
@mixin o-base-settings-horizontal-padding($padding-base: $input-btn-padding-y-sm) {
padding: $padding-base $o-horizontal-padding;
@include media-breakpoint-up(xl) {
padding-left: $o-horizontal-padding*2;;
}
}
// Use a very specif selector to overwrite generic form-view rules
.o_form_view.o_form_nosheet.o_base_settings {
display: flex;
flex-flow: column nowrap;
padding: 0px;
}
// BASE SETTINGS LAYOUT
.o_base_settings {
--settings__tab-bg: #{map-get($grays, '900')};
--settings__tab-bg--active: #{map-get($grays, '800')};
--settings__tab-color: #{map-get($grays, '400')};
--settings__title-bg: #{map-get($grays, '200')};
height: 100%;
.o_control_panel {
flex: 0 0 auto;
.o_panel {
display: flex;
flex-flow: row wrap;
width: 100%;
}
.o_form_statusbar {
padding: 0;
margin: 0;
border: 0;
}
}
.o_setting_container {
display: flex;
flex: 1 1 auto;
overflow: auto;
.settings_tab {
display: flex;
flex: 0 0 auto;
flex-flow: column nowrap;
background: var(--settings__tab-bg);
overflow: auto;
.selected {
background-color: var(--settings__tab-bg--active);
box-shadow: inset 2px 0 0 $o-brand-primary;
}
.tab {
display: flex;
padding: 0 $o-horizontal-padding*2 0 $o-horizontal-padding;
height: 40px;
color: var(--settings__tab-color);
font-size: 13px;
line-height: 40px;
cursor: pointer;
white-space: nowrap;
.icon {
width: 23px;
min-width: 23px;
margin-right: 10px;
}
&:hover, &.selected {
color: white;
}
}
}
.settings {
position: relative;
flex: 1 1 100%;
background-color: $o-view-background-color;
overflow: auto;
> .app_settings_block {
h2 {
margin: 0 0 !important;
@include o-base-settings-horizontal-padding(.4rem);
background-color: var(--settings__title-bg);
font-size: 15px;
&.o_invisible_modifier + .o_settings_container {
display: none;
}
}
h3 {
margin: 0 0 !important;
@include o-base-settings-horizontal-padding(.4rem);
font-weight: 400;
font-size: 13px;
}
.o_settings_container {
max-width: map-get($grid-breakpoints, lg); // Provide a maximum container size to ensure readability
@include media-breakpoint-up(md) {
@include o-base-settings-horizontal-padding(0);
}
margin-bottom: 24px;
.o_form_label + .fa, .o_form_label + .o_doc_link {
margin-left: map-get($spacers, 2);
}
}
}
.settingSearchHeader {
display: flex;
align-items: center;
margin-bottom: 10px;
@include o-base-settings-horizontal-padding(.8rem);
background-color: map-get($grays, '200');
.icon {
width: 1.4em;
height: 1.4em;
margin-right: 10px;
}
& + .app_settings_header {
margin-top: -10px;
}
}
.app_settings_header {
@include o-base-settings-horizontal-padding(0);
@include media-breakpoint-down(md) {
.content-group .flex-nowrap {
flex-wrap: wrap !important;
}
}
}
.highlighter {
background: yellow;
}
.o_datepicker .o_datepicker_button {
visibility: visible;
}
}
.d-block {
display: block!important;
}
}
}

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SettingsFormView" t-inherit="web.FormView" t-inherit-mode="primary" owl="1">
<xpath expr="./div[@t-ref='root']" position="attributes">
<attribute name="class">o-settings-form-view o_field_highlight</attribute>
</xpath>
<xpath expr="//Layout" position="inside">
<t t-set-slot="control-panel-top-right">
<div class="o_cp_searchview d-flex flex-grow-1" role="search">
<div class="o_searchview pb-1 align-self-center border-bottom flex-grow-1" role="search" aria-autocomplete="list">
<i class="o_searchview_icon oi oi-search" role="img" aria-label="Search..." title="Search..." />
<div class="o_searchview_input_container">
<input type="text" class="o_searchview_input" accesskey="Q" placeholder="Search..." role="searchbox" t-model="searchState.value" t-ref="autofocus"/>
</div>
</div>
</div>
</t>
</xpath>
<xpath expr="//Layout/t[@t-set-slot='control-panel-status-indicator']" position="replace"/>
<xpath expr="//Layout/t[@t-component='props.Renderer']" position="attributes">
<attribute name="initialApp">initialApp</attribute>
</xpath>
<xpath expr="//Layout/t[@t-component='props.Renderer']" position="inside">
<t t-set-slot="NoContentHelper" isVisible="state.displayNoContent">
<t t-call="web.NoContentHelper">
<t t-set="title" t-value="'No setting found'"/>
<t t-set="description" t-value="'Try searching for another keyword'"/>
</t>
</t>
</xpath>
</t>
<t t-name="web.SettingsFormView.Buttons" t-inherit="web.FormView.Buttons" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_form_buttons_edit')]" position="inside">
<span t-if="model.root.isDirty" class="text-muted ms-2 o_dirty_warning">Unsaved changes</span>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,66 @@
@include media-breakpoint-down(md) {
.o_base_settings {
flex-flow: column nowrap;
> .o_control_panel {
@include o-webclient-padding($top: 10px, $bottom: 10px);
}
.o_setting_container {
flex-flow: column nowrap;
.settings_tab {
flex: 0 0 $o-base-settings-mobile-tabs-height;
flex-direction: row;
position: relative;
overflow: hidden;
overflow-x: auto;
padding: 0;
border-bottom: 1px solid $border-color;
.tab {
display: block;
width: auto;
height: $o-base-settings-mobile-tabs-height;
padding: $o-base-settings-mobile-tabs-height*0.25 $o-base-settings-mobile-tabs-height*0.4;
text-align: center;
font-size: 14px;
font-weight: 500;
line-height: inherit;
transition: 0.2s all ease 0s;
transform: translate3d(0, 0, 0);
.app_name {
display: block;
white-space: nowrap;
text-transform: uppercase;
}
&:after {
content: '';
background: $o-brand-primary;
opacity: 0;
@include o-position-absolute(auto, 0, 0, 0);
width: 100%;
height: 3px;
transition: 0.2s all ease 0s;
}
&.current {
font-weight: bold;
// Reset default style for 'selected' tabs
box-shadow: none;
background: none;
&:after {
opacity: 1;
}
}
}
}
}
}
}

View file

@ -0,0 +1,20 @@
/** @odoo-modules */
import { registry } from "@web/core/registry";
export const demoDataService = {
dependencies: ["rpc"],
async start(env, { rpc }) {
let isDemoDataActiveProm;
return {
isDemoDataActive() {
if (!isDemoDataActiveProm) {
isDemoDataActiveProm = rpc("/base_setup/demo_active");
}
return isDemoDataActiveProm;
},
};
},
};
registry.category("services").add("demo_data", demoDataService);

View file

@ -0,0 +1,43 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { SettingsContainer } from "../settings/settings_container";
import { Setting } from "../settings/setting";
import { Component, onWillStart } from "@odoo/owl";
/**
* Widget in the settings that handles the "Developer Tools" section.
* Can be used to enable/disable the debug modes.
* Can be used to load the demo data.
*/
export class ResConfigDevTool extends Component {
setup() {
this.isDebug = Boolean(odoo.debug);
this.isAssets = odoo.debug.includes("assets");
this.isTests = odoo.debug.includes("tests");
this.action = useService("action");
this.demo = useService("demo_data");
onWillStart(async () => {
this.isDemoDataActive = await this.demo.isDemoDataActive();
});
}
/**
* Forces demo data to be installed in a database without demo data installed.
*/
onClickForceDemo() {
this.action.doAction("base.demo_force_install_action");
}
}
ResConfigDevTool.template = "res_config_dev_tool";
ResConfigDevTool.components = {
SettingsContainer,
Setting,
};
registry.category("view_widgets").add("res_config_dev_tool", ResConfigDevTool);

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<template>
<div t-name='res_config_dev_tool' owl="1">
<div id="developer_tool">
<t t-set="title">Developer Tools</t>
<SettingsContainer title="title" class="'row mt16 o_settings_container'">
<Setting class="'col-12 col-lg-6 o_setting_box'" id="devel_tool">
<div class="o_setting_right_pane">
<a t-if="!isDebug" class="d-block" href="?debug=1">Activate the developer mode</a>
<a t-if="!isAssets" class="d-block" href="?debug=assets">Activate the developer mode (with assets)</a>
<a t-if="!isTests" class="d-block" href="?debug=assets,tests">Activate the developer mode (with tests assets)</a>
<a t-if="isDebug" class="d-block" href="?debug=">Deactivate the developer mode</a>
<a t-if="isDebug and !isDemoDataActive" t-on-click.prevent="onClickForceDemo" class="o_web_settings_force_demo" href="#">Load demo data</a>
</div>
</Setting>
</SettingsContainer>
</div>
</div>
</template>

View file

@ -0,0 +1,24 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import { Component } from "@odoo/owl";
const { DateTime } = luxon;
/**
* Widget in the settings that handles a part of the "About" section.
* Contains info about the odoo version, database expiration date and copyrights.
*/
class ResConfigEdition extends Component {
setup() {
this.serverVersion = session.server_version;
this.expirationDate = session.expiration_date
? DateTime.fromSQL(session.expiration_date).toLocaleString(DateTime.DATE_FULL)
: DateTime.now().plus({ days: 30 }).toLocaleString(DateTime.DATE_FULL);
}
}
ResConfigEdition.template = "res_config_edition";
registry.category("view_widgets").add("res_config_edition", ResConfigEdition);

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<template>
<div t-name='res_config_edition' owl="1">
<div class="col-12 o_setting_box" id="edition">
<div class="o_setting_right_pane">
<div class="user-heading">
<h3 class="px-0">
Odoo <t t-esc="serverVersion"/>
(Community Edition)
</h3>
</div>
<div>
<div class="tab-content">
<div role="tabpanel" id="settings" class="tab-pane active text-muted o_web_settings_compact_subtitle">
<small>Copyright © 2004 <a target="_blank" href="https://www.odoo.com" style="text-decoration: underline;">Odoo S.A.</a> <a id="license" target="_blank" href="http://www.gnu.org/licenses/lgpl.html" style="text-decoration: underline;">GNU LGPL Licensed</a></small>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,128 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
import { unique } from "@web/core/utils/arrays";
import { useService } from "@web/core/utils/hooks";
import { Component, useState, onWillStart } from "@odoo/owl";
class ResConfigInviteUsers extends Component {
setup() {
this.orm = useService("orm");
this.invite = useService("user_invite");
this.action = useService("action");
this.notification = useService("notification");
this.state = useState({
status: "idle", // idle, inviting
emails: "",
invite: null,
});
onWillStart(async () => {
this.state.invite = await this.invite.fetchData();
});
}
/**
* @param {string} email
* @returns {boolean} true if the given email address is valid
*/
validateEmail(email) {
const re = /^([a-z0-9][-a-z0-9_+.]*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,63}(?:\.[a-z]{2})?)$/i;
return re.test(email);
}
get emails() {
return unique(
this.state.emails
.split(/[ ,;\n]+/)
.map((email) => email.trim())
.filter((email) => email.length)
);
}
validate() {
if (!this.emails.length) {
throw new Error(_t("Empty email address"));
}
const invalidEmails = [];
for (const email of this.emails) {
if (!this.validateEmail(email)) {
invalidEmails.push(email);
}
}
if (invalidEmails.length) {
throw new Error(
`${_t("Invalid email address")}${
invalidEmails.length > 1 ? "es" : ""
}: ${invalidEmails.join(", ")}`
);
}
}
get inviteButtonText() {
if (this.state.status === "inviting") {
return _t("Inviting...");
}
return _t("Invite");
}
onClickMore(ev) {
this.action.doAction(this.state.invite.action_pending_users);
}
onClickUser(ev, user) {
const action = Object.assign({}, this.state.invite.action_pending_users, {
res_id: user[0],
});
this.action.doAction(action);
}
onKeydownUserEmails(ev) {
const keys = ["Enter", "Tab", ","];
if (keys.includes(ev.key)) {
if (ev.key === "Tab" && !this.emails.length) {
return;
}
ev.preventDefault();
this.sendInvite();
}
}
/**
* Send invitation for valid and unique email addresses
*
* @private
*/
async sendInvite() {
try {
this.validate();
} catch (e) {
this.notification.add(e.message, { type: "danger" });
return;
}
this.state.status = "inviting";
const pendingUserEmails = this.state.invite.pending_users.map((user) => user[1]);
const emailsLeftToProcess = this.emails.filter(
(email) => !pendingUserEmails.includes(email)
);
try {
if (emailsLeftToProcess) {
await this.orm.call("res.users", "web_create_users", [emailsLeftToProcess]);
this.state.invite = await this.invite.fetchData(true);
}
} finally {
this.state.emails = "";
this.state.status = "idle";
}
}
}
ResConfigInviteUsers.template = "res_config_invite_users";
registry.category("view_widgets").add("res_config_invite_users", ResConfigInviteUsers);

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name='res_config_invite_users' owl="1">
<p class="o_form_label">Invite New Users</p>
<div class="d-flex">
<input t-model="state.emails" t-att-disabled="state.status != 'idle'" class="o_user_emails o_input mt8 text-truncate" t-on-keydown="onKeydownUserEmails" type="text" placeholder="Enter e-mail address"/>
<button t-att-disabled="state.status != 'idle'" class="btn btn-primary o_web_settings_invite flex-shrink-0" t-on-click="sendInvite"><strong><t t-esc="inviteButtonText"/></strong></button>
</div>
<t t-if="state.invite.pending_users.length">
<p class="o_form_label pt-3">Pending Invitations:</p>
<span t-foreach="state.invite.pending_users" t-as="pending" t-key="pending[0]">
<a href="#" class="badge rounded-pill text-primary border border-primary o_web_settings_user" t-on-click.prevent="(ev) => this.onClickUser(ev, pending)"> <t t-esc="pending[1]"/> </a>
</span>
<t t-if="state.invite.pending_users.length &lt; state.invite.pending_count">
<br/>
<a href="#" class="o_web_settings_more" t-on-click.prevent="onClickMore"><t t-esc="state.invite.pending_count - state.invite.pending_users.length"/> more </a>
</t>
</t>
</div>
</templates>

View file

@ -0,0 +1,17 @@
.o_setting_container {
.o_web_settings_user {
font-size: 95%;
font-weight: 500;
}
}
.o_doc_link {
text-decoration: none;
font-weight: normal;
&::after{
content: "\f059"; //fa-question-circle
font-family: 'FontAwesome';
font-size: 1.2rem;
}
}

View file

@ -0,0 +1,20 @@
/** @odoo-modules */
import { registry } from "@web/core/registry";
export const userInviteService = {
dependencies: ["rpc"],
async start(env, { rpc }) {
let dataProm;
return {
fetchData(reload = false) {
if (!dataProm || reload) {
dataProm = rpc("/base_setup/data");
}
return dataProm;
},
};
},
};
registry.category("services").add("user_invite", userInviteService);

View file

@ -0,0 +1,53 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { browser } from "@web/core/browser/browser";
import { symmetricalDifference } from "@web/core/utils/arrays";
import { Component, useState } from "@odoo/owl";
export class SwitchCompanyMenu extends Component {
setup() {
this.companyService = useService("company");
this.currentCompany = this.companyService.currentCompany;
this.state = useState({ companiesToToggle: [] });
}
toggleCompany(companyId) {
this.state.companiesToToggle = symmetricalDifference(this.state.companiesToToggle, [
companyId,
]);
browser.clearTimeout(this.toggleTimer);
this.toggleTimer = browser.setTimeout(() => {
this.companyService.setCompanies("toggle", ...this.state.companiesToToggle);
}, this.constructor.toggleDelay);
}
logIntoCompany(companyId) {
browser.clearTimeout(this.toggleTimer);
this.companyService.setCompanies("loginto", companyId);
}
get selectedCompanies() {
return symmetricalDifference(
this.companyService.allowedCompanyIds,
this.state.companiesToToggle
);
}
}
SwitchCompanyMenu.template = "web.SwitchCompanyMenu";
SwitchCompanyMenu.components = { Dropdown, DropdownItem };
SwitchCompanyMenu.toggleDelay = 1000;
export const systrayItem = {
Component: SwitchCompanyMenu,
isDisplayed(env) {
const { availableCompanies } = env.services.company;
return Object.keys(availableCompanies).length > 1;
},
};
registry.category("systray").add("SwitchCompanyMenu", systrayItem, { sequence: 1 });

View file

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SwitchCompanyMenu" owl="1">
<Dropdown class="'o_switch_company_menu d-none d-md-block'" position="'bottom-end'">
<t t-set-slot="toggler">
<i class="fa fa-building d-lg-none"/>
<span class="oe_topbar_name d-none d-lg-block" t-esc="currentCompany.name"/>
</t>
<t t-foreach="Object.values(companyService.availableCompanies).sort((c1, c2) => c1.sequence - c2.sequence)" t-as="company" t-key="company.id">
<t t-call="web.SwitchCompanyItem">
<t t-set="company" t-value="company" />
</t>
</t>
</Dropdown>
</t>
<t t-name="web.SwitchCompanyItem" owl="1">
<DropdownItem class="'p-0 bg-white'">
<t t-set="isCompanySelected" t-value="selectedCompanies.includes(company.id)"/>
<t t-set="isCurrent" t-value="company.id === companyService.currentCompany.id"/>
<div class="d-flex" data-menu="company" t-att-data-company-id="company.id">
<div
role="menuitemcheckbox"
t-att-aria-checked="isCompanySelected ? 'true' : 'false'"
t-att-aria-label="company.name"
t-att-title="(isCompanySelected ? 'Hide ' : 'Show ') + company.name + ' content.'"
tabindex="0"
class="border-end toggle_company"
t-attf-class="{{isCurrent ? 'border-primary' : ''}}"
t-on-click.stop="() => this.toggleCompany(company.id)">
<span class="btn btn-light border-0 p-2">
<i class="fa fa-fw py-2" t-att-class="isCompanySelected ? 'fa-check-square text-primary' : 'fa-square-o'"/>
</span>
</div>
<div
role="button"
t-att-aria-pressed="isCurrent ? 'true' : 'false'"
t-att-aria-label="'Switch to ' + company.name "
t-att-title="'Switch to ' + company.name "
tabindex="0"
class="d-flex flex-grow-1 align-items-center py-0 log_into ps-2"
t-att-class="isCurrent ? 'alert-primary ms-1 me-2' : 'btn btn-light fw-normal border-0'"
t-on-click="() => this.logIntoCompany(company.id)">
<span
class='company_label pe-3'
t-att-class="isCurrent ? 'text-900 fw-bold' : 'ms-1'">
<t t-esc="company.name"/>
</span>
</div>
</div>
</DropdownItem>
</t>
</templates>

View file

@ -0,0 +1,40 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
const userMenuRegistry = registry.category("user_menuitems");
export class UserMenu extends Component {
setup() {
this.user = useService("user");
const { origin } = browser.location;
const { userId } = this.user;
this.source = `${origin}/web/image?model=res.users&field=avatar_128&id=${userId}`;
}
getElements() {
const sortedItems = userMenuRegistry
.getAll()
.map((element) => element(this.env))
.sort((x, y) => {
const xSeq = x.sequence ? x.sequence : 100;
const ySeq = y.sequence ? y.sequence : 100;
return xSeq - ySeq;
});
return sortedItems;
}
}
UserMenu.template = "web.UserMenu";
UserMenu.components = { Dropdown, DropdownItem, CheckBox };
export const systrayItem = {
Component: UserMenu,
};
registry.category("systray").add("web.user_menu", systrayItem, { sequence: 0 });

View file

@ -0,0 +1,7 @@
.o_user_menu .dropdown-toggle {
padding-right: $o-horizontal-padding;
.oe_topbar_name {
max-width: 20rem;
}
}

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.UserMenu" owl="1">
<Dropdown class="'o_user_menu d-none d-md-block pe-0'" togglerClass="'py-1 py-lg-0'">
<t t-set-slot="toggler">
<img class="rounded-circle o_user_avatar h-75 py-1" t-att-src="source" alt="User"/>
<span class="oe_topbar_name d-none d-lg-block ms-1 text-truncate"><t t-esc="user.name"/><t t-if="env.debug" t-esc="' (' + user.db.name + ')'"/></span>
</t>
<t t-foreach="getElements()" t-as="element" t-key="element_index">
<t t-if="!element.hide">
<DropdownItem
t-if="element.type == 'item' || element.type == 'switch'"
href="element.href"
dataset="{ menu: element.id }"
onSelected="element.callback"
>
<CheckBox
t-if="element.type == 'switch'"
value="element.isChecked"
className="'form-switch d-flex flex-row-reverse justify-content-between p-0 w-100'"
onChange="element.callback"
>
<t t-out="element.description"/>
</CheckBox>
<t t-else="" t-out="element.description"/>
</DropdownItem>
<div t-if="element.type == 'separator'" role="separator" class="dropdown-divider"/>
</t>
</t>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,126 @@
/** @odoo-module **/
import { Component, markup } from "@odoo/owl";
import { isMacOS } from "@web/core/browser/feature_detection";
import { escape } from "@web/core/utils/strings";
import { session } from "@web/session";
import { browser } from "../../core/browser/browser";
import { registry } from "../../core/registry";
function documentationItem(env) {
const documentationURL = "https://www.odoo.com/documentation/16.0";
return {
type: "item",
id: "documentation",
description: env._t("Documentation"),
href: documentationURL,
callback: () => {
browser.open(documentationURL, "_blank");
},
sequence: 10,
};
}
function supportItem(env) {
const url = session.support_url;
return {
type: "item",
id: "support",
description: env._t("Support"),
href: url,
callback: () => {
browser.open(url, "_blank");
},
sequence: 20,
};
}
class ShortcutsFooterComponent extends Component {
setup() {
this.runShortcutKey = isMacOS() ? "CONTROL" : "ALT";
}
}
ShortcutsFooterComponent.template = "web.UserMenu.ShortcutsFooterComponent";
function shortCutsItem(env) {
const shortcut = env._t("Shortcuts");
return {
type: "item",
id: "shortcuts",
hide: env.isSmall,
description: markup(
`<div class="d-flex align-items-center justify-content-between">
<span>${escape(shortcut)}</span>
<span class="fw-bold">${isMacOS() ? "CMD" : "CTRL"}+K</span>
</div>`
),
callback: () => {
env.services.command.openMainPalette({ FooterComponent: ShortcutsFooterComponent });
},
sequence: 30,
};
}
function separator() {
return {
type: "separator",
sequence: 40,
};
}
export function preferencesItem(env) {
return {
type: "item",
id: "settings",
description: env._t("Preferences"),
callback: async function () {
const actionDescription = await env.services.orm.call("res.users", "action_get");
actionDescription.res_id = env.services.user.userId;
env.services.action.doAction(actionDescription);
},
sequence: 50,
};
}
function odooAccountItem(env) {
return {
type: "item",
id: "account",
description: env._t("My Odoo.com account"),
callback: () => {
env.services
.rpc("/web/session/account")
.then((url) => {
browser.location.href = url;
})
.catch(() => {
browser.location.href = "https://accounts.odoo.com/account";
});
},
sequence: 60,
};
}
function logOutItem(env) {
const route = "/web/session/logout";
return {
type: "item",
id: "logout",
description: env._t("Log out"),
href: `${browser.location.origin}${route}`,
callback: () => {
browser.location.href = route;
},
sequence: 70,
};
}
registry
.category("user_menuitems")
.add("documentation", documentationItem)
.add("support", supportItem)
.add("shortcuts", shortCutsItem)
.add("separator", separator)
.add("profile", preferencesItem)
.add("odoo_account", odooAccountItem)
.add("log_out", logOutItem);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.UserMenu.ShortcutsFooterComponent" owl="1">
<span>
<span class='fw-bolder text-primary'>TIP</span> — press <span class='fw-bolder text-primary'><t t-esc="runShortcutKey"/></span> on any screen to show shortcut overlays and <span class='fw-bolder text-primary'><t t-esc="runShortcutKey"/> + KEY</span> to trigger a shortcut.
</span>
</t>
</templates>

View file

@ -0,0 +1,117 @@
/** @odoo-module **/
import { useOwnDebugContext } from "@web/core/debug/debug_context";
import { DebugMenu } from "@web/core/debug/debug_menu";
import { localization } from "@web/core/l10n/localization";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { ActionContainer } from "./actions/action_container";
import { NavBar } from "./navbar/navbar";
import { Component, onMounted, useExternalListener, useState } from "@odoo/owl";
export class WebClient extends Component {
setup() {
this.menuService = useService("menu");
this.actionService = useService("action");
this.title = useService("title");
this.router = useService("router");
this.user = useService("user");
useService("legacy_service_provider");
useOwnDebugContext({ categories: ["default"] });
if (this.env.debug) {
registry.category("systray").add(
"web.debug_mode_menu",
{
Component: DebugMenu,
},
{ sequence: 100 }
);
}
this.localization = localization;
this.state = useState({
fullscreen: false,
});
this.title.setParts({ zopenerp: "Odoo" }); // zopenerp is easy to grep
useBus(this.env.bus, "ROUTE_CHANGE", this.loadRouterState);
useBus(this.env.bus, "ACTION_MANAGER:UI-UPDATED", ({ detail: mode }) => {
if (mode !== "new") {
this.state.fullscreen = mode === "fullscreen";
}
});
onMounted(() => {
this.loadRouterState();
// the chat window and dialog services listen to 'web_client_ready' event in
// order to initialize themselves:
this.env.bus.trigger("WEB_CLIENT_READY");
});
useExternalListener(window, "click", this.onGlobalClick, { capture: true });
}
async loadRouterState() {
let stateLoaded = await this.actionService.loadState();
let menuId = Number(this.router.current.hash.menu_id || 0);
if (!stateLoaded && menuId) {
// Determines the current actionId based on the current menu
const menu = this.menuService.getAll().find((m) => menuId === m.id);
const actionId = menu && menu.actionID;
if (actionId) {
await this.actionService.doAction(actionId, { clearBreadcrumbs: true });
stateLoaded = true;
}
}
if (stateLoaded && !menuId) {
// Determines the current menu based on the current action
const currentController = this.actionService.currentController;
const actionId = currentController && currentController.action.id;
const menu = this.menuService.getAll().find((m) => m.actionID === actionId);
menuId = menu && menu.appID;
}
if (menuId) {
// Sets the menu according to the current action
this.menuService.setCurrentMenu(menuId);
}
if (!stateLoaded) {
// If no action => falls back to the default app
await this._loadDefaultApp();
}
}
_loadDefaultApp() {
// Selects the first root menu if any
const root = this.menuService.getMenu("root");
const firstApp = root.children[0];
if (firstApp) {
return this.menuService.selectMenu(firstApp);
}
}
/**
* @param {MouseEvent} ev
*/
onGlobalClick(ev) {
// When a ctrl-click occurs inside an <a href/> element
// we let the browser do the default behavior and
// we do not want any other listener to execute.
if (
(ev.ctrlKey || ev.metaKey) &&
!ev.target.isContentEditable &&
((ev.target instanceof HTMLAnchorElement && ev.target.href) ||
(ev.target instanceof HTMLElement && ev.target.closest("a[href]:not([href=''])")))
) {
ev.stopImmediatePropagation();
return;
}
}
}
WebClient.components = {
ActionContainer,
NavBar,
MainComponentsContainer,
};
WebClient.template = "web.WebClient";

View file

@ -0,0 +1,377 @@
:root {
--o-webclient-color-scheme:#{$o-webclient-color-scheme};
font-size: $o-root-font-size;
}
html, body {
position: relative;
width: 100%;
height: 100%;
}
// Disable default border-style added by _reboot.scss
tfoot {
tr, td, th {
border-style: none;
}
}
// ------------------------------------------------------------------
// General
// ------------------------------------------------------------------
.o_web_client {
direction: ltr;
position: relative; // normally useless but required by bootstrap-datepicker
background-color: $o-webclient-background-color;
color-scheme: $o-webclient-color-scheme;
}
// ------------------------------------------------------------------
// Misc. widgets
// ------------------------------------------------------------------
// Buttons
.o_icon_button {
background-color: transparent;
border: 0;
padding: 0;
outline: none;
}
// User input typically entered via keyboard
kbd {
border: 1px solid $o-gray-200;
box-shadow: $kbd-box-shadow;
}
//== Backgound light colors variations (bootstrap extensions)
@each $-name, $-bg-color in $theme-colors {
$-safe-text-color: color-contrast(mix($-bg-color, $o-view-background-color));
@include bg-variant(".bg-#{$-name}-light", rgba(map-get($theme-colors, $-name), 0.5), $-safe-text-color);
}
//== Badges
.badge {
min-width: $o-badge-min-width;
}
//== Btn-link
.btn-link {
font-weight: $btn-font-weight;
// -- Btn-link variations
// Adapt the behavieur of .btn-link buttons when in conjuction with contextual
// classes (eg. text-warning). 'muted' is set as default text color, while
// the contextual-class color will be used on ':hover'.
// Apply all theme-colors variations except "secondary"
@each $-name, $-color in map-remove($theme-colors, "secondary") {
&.btn-#{$-name}, &.text-#{$-name} {
@include o-btn-link-variant($o-main-color-muted!important, o-text-color($-name) or $-color!important);
}
}
// Specific behavieur for "secondary"
&.btn-secondary {
@include o-btn-link-variant($body-color, $headings-color);
}
}
// Set position of our custom o-dropdown-menu (not bootstrap)
.o-dropdown-menu {
top: 100%;
&.dropdown-menu-start {
left: 0;
right: auto;
}
&.dropdown-menu-end {
left: auto;
right: 0;
}
}
// == Boostrap Dropdown
// ----------------------------------------------------------------------------
.dropdown-menu {
max-height: 70vh;
overflow: auto;
background-clip: border-box;
box-shadow: $o-dropdown-box-shadow;
}
:not(.dropstart) > .dropdown-item {
&.active, &.selected {
position: relative;
font-weight: $font-weight-bold;
&:focus, &:hover {
background-color: $dropdown-link-hover-bg;
}
&:not(.dropdown-item_active_noarrow) {
&:before {
@include o-position-absolute(0);
transform: translate(-1.5em, 90%);
font: .7em/1em FontAwesome;
color: $primary;
content: "\f00c";
}
&.disabled:before {
color: $dropdown-link-disabled-color;
}
}
}
}
/*!rtl:begin:ignore*/
.o-dropdown.dropstart > .dropdown-item.dropdown-toggle:not(.dropdown-item_active_noarrow) {
&.active, &.selected {
&::after {
@include o-position-absolute(0, $left: 90%);
transform: translate(0, 90%);
font: .7em/1em FontAwesome;
color: $link-color;
display: inline-block;
content: "\f00c";
border: 0;
}
&.disabled:after {
color: $dropdown-link-disabled-color;
}
}
}
/*!rtl:end:ignore*/
.dropdown-header {
font-weight: $font-weight-bold;
padding-bottom: .1em;
&:not(:first-child) {
margin-top: .3em;
}
}
.dropdown-divider:first-child {
display: none;
}
//== Printing improvements
@media print {
.table-responsive {
overflow-x: initial;
}
}
//== Action manager
// ensure special links are styled as pointers even when they don't
// have an href
[type="action"],
[type="toggle"] {
cursor: pointer !important;
}
.o_web_client.o_touch_device {
.btn {
&, .btn-sm {
font-size: $font-size-base;
padding: $o-touch-btn-padding;
}
&.fa {
font-size: 1.3em;
padding: 2px 10px;
}
}
}
//------------------------------------------------------------------------------
// Inputs and selects (note: put the o_input class to have the style)
//------------------------------------------------------------------------------
[type="text"],
[type="password"],
[type="number"],
[type="email"],
[type="tel"],
textarea,
select {
width: 100%;
display: block;
outline: none;
}
select {
// FIXME buggy 'padding-left'
cursor: pointer;
min-width: 50px;
appearance: none;
background: transparent $o-caret-down no-repeat right center;
border-radius: 0; // webkit OSX browsers have a border-radius on select
color: $o-main-text-color;
> option {
background: $light;
}
// This is a hack to remove the outline in FF
&:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 $o-main-text-color;
> option {
color: $o-main-text-color;
}
}
}
@mixin o-placeholder {
// Rules below need to be separeted. Otherwise all browsers will discard the whole rule.
color: $input-placeholder-color;
}
::-webkit-input-placeholder {
// WebKit, Blink, Edge
@include o-placeholder;
}
::-moz-placeholder {
// Mozilla Firefox 19+
@include o-placeholder;
}
:-ms-input-placeholder {
// Internet Explorer 10-11
@include o-placeholder;
}
//------------------------------------------------------------------------------
// Buttons
//------------------------------------------------------------------------------
// Bootstrap define buttons using just one base-color for each color variation
// (eg.$primary), without control over text-color or border.
// The following code define exceptions for buttons that needs a different
// design by default or in specific scenarios.
.btn-secondary {
// Customize the button design without overwrite the default '$secondary'
// variable that it's used by other components like list-groups, tables,
// badges...
@include button-variant(
$background: $o-btn-secondary-bg,
$border: $o-btn-secondary-bg,
$hover-background: $o-btn-secondary-hover-bg,
$hover-border: $o-btn-secondary-hover-border,
$active-background: $o-btn-secondary-hover-bg,
$active-border: $o-btn-secondary-hover-border
);
// By default, act like a .btn-link
@include o-hover-text-color($link-color, $link-hover-color);
&.disabled, &:disabled {
@include o-hover-text-color($o-gray-500, $o-gray-500);
}
}
.btn-outline-secondary {
// Slightly customize to act like an odoo custom btn-secondary with
// light gray border and $secondary text.
// Used for not prioritary actions ordropdown (eg. dashboard view).
@include button-variant(
$background: $o-btn-secondary-bg,
$border: $o-gray-200,
$hover-background: $o-btn-secondary-hover-bg,
$hover-border: $o-gray-300,
$active-background: $o-btn-secondary-hover-bg,
$active-border: $o-gray-300
);
}
.btn-light {
// Achieve "clickable text" elements that act like buttons once the
// the user interacts with (eg. control-panel dropdowns);
@include button-variant(
$background: $o-btn-light-bg,
$border: $o-btn-light-border,
$hover-background: $o-btn-light-background-hover,
$hover-border: $o-btn-light-background-hover,
$active-background: $o-btn-light-background-hover,
$active-border: $o-btn-light-background-hover
);
}
.btn-outline-secondary, .btn-light {
@include o-hover-text-color($o-main-text-color, $o-gray-900);
&.disabled, &:disabled {
@include o-hover-text-color($o-main-text-color, $o-main-text-color);
}
}
//------------------------------------------------------------------------------
// Misc.
//------------------------------------------------------------------------------
//== Titles
@include media-breakpoint-down(md) {
h1 {
font-size: $h1-font-size * 3 / 4;
}
h2,
h3,
h4,
h5,
h6 {
font-size: $o-font-size-base-touch;
}
}
//== Alerts
.alert {
&.alert-info,
&.alert-success,
&.alert-warning,
&.alert-danger {
border-width: 0 0 0 3px;
}
a {
font-weight: $alert-link-font-weight;
}
}
//== Badges
.badge {
&.text-bg-default, &.bg-light, &.text-bg-light, &.bg-default, &.text-primary{
outline: 1px solid $o-brand-primary;
outline-offset: -1px;
}
}
//== Buttons
// Uppercase primary and secondary except when secondary is used as a dropdown
// toggler.
.btn-primary,
.btn-secondary:not(.dropdown-toggle):not(.dropdown-item),
.btn-secondary.o_arrow_button:not(.dropdown-item) {
text-transform: uppercase;
}
// Disable unnecessary box-shadows on mouse hover
.btn:focus:hover {
box-shadow: none;
}
//== Navbar
.navbar .navbar-toggle {
border-color: transparent;
}
//== Labels
.label {
border-radius: 0;
font-size: 1em; // Override 75% of .label
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.WebClient" owl="1">
<t t-if="!state.fullscreen">
<NavBar/>
</t>
<ActionContainer/>
<MainComponentsContainer/>
</t>
</templates>

View file

@ -0,0 +1,100 @@
// ------------------------------------------------------------------
// Base layout rules, use the 'webclient.scss' file for styling
// ------------------------------------------------------------------
html {
height: 100%;
.o_web_client {
height: 100%;
display: flex;
flex-flow: column nowrap;
overflow: hidden;
> .o_action_manager {
direction: ltr; //Define direction attribute here so when rtlcss preprocessor run, it converts it to rtl
flex: 1 1 auto;
height: 100%;
overflow: hidden;
> .o_action {
height: 100%;
display: flex;
flex-flow: column nowrap;
overflow: hidden;
@include media-breakpoint-up(lg) {
-ms-overflow-style: none; // IE and Edge
scrollbar-width: none; // Firefox
&::-webkit-scrollbar { // Chrome, Safari and Opera
display: none;
}
}
> .o_control_panel {
flex: 0 0 auto;
}
.o_content {
flex: 1 1 auto;
position: relative; // Allow to redistribute the 100% height to its child
overflow: auto;
height: 100%;
}
@include media-breakpoint-down(md) {
// Made the o_action scroll instead of its o_content.
// Except when the view wants to handle the scroll itself.
&:not(.o_action_delegate_scroll), .o_form_view_container {
overflow: auto;
.o_content {
overflow: initial;
}
}
}
}
}
.o_main_navbar {
flex: 0 0 auto;
}
.o_control_panel {
flex: 0 0 auto;
}
.o_content {
direction: ltr; //Define direction attribute here so when rtlcss preprocessor run, it converts it to rtl
flex: 1 1 auto;
position: relative; // Allow to redistribute the 100% height to its child
> .o_view_controller {
position: absolute; // Get the 100% height of its flex parent
top: 0;
right: 0;
bottom: 0;
left: 0;
height: 100%;
direction: ltr;
}
}
}
// FIXME: Fix scrolling in modal on focus input due to a bug between Chrome (Android) and Bootstrap
body.modal-open {
position: fixed;
}
}
@media print {
html .o_web_client {
.o_main_navbar {
display: none;
}
.o_content {
position: static;
overflow: visible;
height: auto;
}
}
}