mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 10:52:01 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
|
|
@ -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>`;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
188
odoo-bringout-oca-ocb-web/web/static/src/webclient/actions/reports/reset.min.css
vendored
Normal file
188
odoo-bringout-oca-ocb-web/web/static/src/webclient/actions/reports/reset.min.css
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
.modal .o-barcode-modal .modal-body {
|
||||
overflow: hidden;
|
||||
@include media-breakpoint-down(md) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
video {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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&field=avatar_128&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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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}&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}&action_id=${menu.actionID}`,
|
||||
props,
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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&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>
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 < 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.o_user_menu .dropdown-toggle {
|
||||
padding-right: $o-horizontal-padding;
|
||||
|
||||
.oe_topbar_name {
|
||||
max-width: 20rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
117
odoo-bringout-oca-ocb-web/web/static/src/webclient/webclient.js
Normal file
117
odoo-bringout-oca-ocb-web/web/static/src/webclient/webclient.js
Normal 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";
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue