合并企业版代码(未测试,先提交到测试分支)
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { session } from "@web/session";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
import { deserializeDateTime, serializeDate, formatDate } from "@web/core/l10n/dates";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { ExpirationPanel } from "./expiration_panel";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
const { Component, xml, useState } = owl;
|
||||
|
||||
function daysUntil(datetime) {
|
||||
const duration = datetime.diff(DateTime.utc(), "days");
|
||||
return Math.round(duration.values.days);
|
||||
}
|
||||
|
||||
export class SubscriptionManager {
|
||||
constructor(env, { rpc, orm, cookie, notification }) {
|
||||
this.env = env;
|
||||
this.rpc = rpc;
|
||||
this.orm = orm;
|
||||
this.cookie = cookie;
|
||||
this.notification = notification;
|
||||
if (session.expiration_date) {
|
||||
this.expirationDate = deserializeDateTime(session.expiration_date);
|
||||
} else {
|
||||
// If no date found, assume 1 month and hope for the best
|
||||
this.expirationDate = DateTime.utc().plus({ days: 30 });
|
||||
}
|
||||
this.expirationReason = session.expiration_reason;
|
||||
// Hack: we need to know if there is at least one app installed (except from App and
|
||||
// Settings). We use mail to do that, as it is a dependency of almost every addon. To
|
||||
// determine whether mail is installed or not, we check for the presence of the key
|
||||
// "notification_type" in session_info, as it is added in mail for internal users.
|
||||
this.hasInstalledApps = "notification_type" in session;
|
||||
// "user" or "admin"
|
||||
this.warningType = session.warning;
|
||||
this.lastRequestStatus = null;
|
||||
this.isWarningHidden = this.cookie.current.oe_instance_hide_panel;
|
||||
}
|
||||
|
||||
get formattedExpirationDate() {
|
||||
return formatDate(this.expirationDate, { format: "DDD" });
|
||||
}
|
||||
|
||||
get daysLeft() {
|
||||
return daysUntil(this.expirationDate);
|
||||
}
|
||||
|
||||
get unregistered() {
|
||||
return ["trial", "demo", false].includes(this.expirationReason);
|
||||
}
|
||||
|
||||
hideWarning() {
|
||||
// Hide warning for 24 hours.
|
||||
this.cookie.setCookie("oe_instance_hide_panel", true, 24 * 60 * 60);
|
||||
this.isWarningHidden = true;
|
||||
}
|
||||
|
||||
async buy() {
|
||||
const limitDate = serializeDate(DateTime.utc().minus({ days: 15 }));
|
||||
const args = [
|
||||
[
|
||||
["share", "=", false],
|
||||
["login_date", ">=", limitDate],
|
||||
],
|
||||
];
|
||||
const nbUsers = await this.orm.call("res.users", "search_count", args);
|
||||
}
|
||||
/**
|
||||
* Save the registration code then triggers a ping to submit it.
|
||||
*/
|
||||
async submitCode(enterpriseCode) {
|
||||
const [oldDate, , linkedSubscriptionUrl, linkedEmail] = await Promise.all([
|
||||
this.orm.call("ir.config_parameter", "get_param", ["database.expiration_date"]),
|
||||
this.orm.call("ir.config_parameter", "set_param", [
|
||||
"database.enterprise_code",
|
||||
enterpriseCode,
|
||||
]),
|
||||
// Aren't these a race condition ??? They depend on the upcoming ping...
|
||||
this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.already_linked_subscription_url",
|
||||
]),
|
||||
this.orm.call("ir.config_parameter", "get_param", ["database.already_linked_email"]),
|
||||
]);
|
||||
|
||||
await this.orm.call("publisher_warranty.contract", "update_notification", [[]]);
|
||||
|
||||
const expirationDate = await this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.expiration_date",
|
||||
]);
|
||||
|
||||
if (linkedSubscriptionUrl) {
|
||||
this.lastRequestStatus = "link";
|
||||
this.linkedSubscriptionUrl = linkedSubscriptionUrl;
|
||||
this.mailDeliveryStatus = null;
|
||||
this.linkedEmail = linkedEmail;
|
||||
} else if (expirationDate !== oldDate) {
|
||||
this.lastRequestStatus = "success";
|
||||
this.expirationDate = deserializeDateTime(expirationDate);
|
||||
if (this.daysLeft > 30) {
|
||||
const message = _t(
|
||||
"Thank you, your registration was successful! Your database is valid until %s."
|
||||
);
|
||||
this.notification.add(sprintf(message, this.formattedExpirationDate), {
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.lastRequestStatus = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async checkStatus() {
|
||||
await this.orm.call("publisher_warranty.contract", "update_notification", [[]]);
|
||||
|
||||
const expirationDateStr = await this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.expiration_date",
|
||||
]);
|
||||
this.lastRequestStatus = "update";
|
||||
this.expirationDate = deserializeDateTime(expirationDateStr);
|
||||
}
|
||||
|
||||
async sendUnlinkEmail() {
|
||||
const sendUnlinkInstructionsUrl = await this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.already_linked_send_mail_url",
|
||||
]);
|
||||
this.mailDeliveryStatus = "ongoing";
|
||||
const { result, reason } = await this.rpc(sendUnlinkInstructionsUrl);
|
||||
if (result) {
|
||||
this.mailDeliveryStatus = "success";
|
||||
} else {
|
||||
this.mailDeliveryStatus = "fail";
|
||||
this.mailDeliveryStatusError = reason;
|
||||
}
|
||||
}
|
||||
|
||||
async renew() {
|
||||
const enterpriseCode = await this.orm.call("ir.config_parameter", "get_param", [
|
||||
"database.enterprise_code",
|
||||
]);
|
||||
|
||||
const contractQueryString = enterpriseCode ? `?contract=${enterpriseCode}` : "";
|
||||
browser.location = `${url}${contractQueryString}`;
|
||||
}
|
||||
|
||||
async upsell() {
|
||||
const limitDate = serializeDate(DateTime.utc().minus({ days: 15 }));
|
||||
const [enterpriseCode, nbUsers] = await Promise.all([
|
||||
this.orm.call("ir.config_parameter", "get_param", ["database.enterprise_code"]),
|
||||
this.orm.call("res.users", "search_count", [
|
||||
[
|
||||
["share", "=", false],
|
||||
["login_date", ">=", limitDate],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
const contractQueryString = enterpriseCode ? `&contract=${enterpriseCode}` : "";
|
||||
browser.location = `${url}?num_users=${nbUsers}${contractQueryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
class ExpiredSubscriptionBlockUI extends Component {
|
||||
setup() {
|
||||
this.subscription = useState(useService("enterprise_subscription"));
|
||||
}
|
||||
}
|
||||
ExpiredSubscriptionBlockUI.template = xml`
|
||||
<t t-if="subscription.daysLeft <= 0">
|
||||
<div class="o_blockUI"/>
|
||||
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1100" class="d-flex align-items-center justify-content-center">
|
||||
<ExpirationPanel/>
|
||||
</div>
|
||||
</t>`;
|
||||
ExpiredSubscriptionBlockUI.components = { ExpirationPanel };
|
||||
|
||||
export const enterpriseSubscriptionService = {
|
||||
name: "enterprise_subscription",
|
||||
dependencies: ["orm", "rpc", "cookie", "notification"],
|
||||
start(env, { rpc, orm, cookie, notification }) {
|
||||
registry
|
||||
.category("main_components")
|
||||
.add("expired_subscription_block_ui", { Component: ExpiredSubscriptionBlockUI });
|
||||
return new SubscriptionManager(env, { rpc, orm, cookie, notification });
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("enterprise_subscription", enterpriseSubscriptionService);
|
||||
@@ -0,0 +1,77 @@
|
||||
/** @odoo-module **/
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Transition } from "@web/core/transition";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
|
||||
const { Component, useState, useRef } = owl;
|
||||
|
||||
/**
|
||||
* Expiration panel
|
||||
*
|
||||
* Component representing the banner located on top of the home menu. Its purpose
|
||||
* is to display the expiration state of the current database and to help the
|
||||
* user to buy/renew its subscription.
|
||||
* @extends Component
|
||||
*/
|
||||
export class ExpirationPanel extends Component {
|
||||
setup() {
|
||||
this.subscription = useState(useService("enterprise_subscription"));
|
||||
this.cookie = useService("cookie");
|
||||
|
||||
this.state = useState({
|
||||
displayRegisterForm: false,
|
||||
});
|
||||
|
||||
this.inputRef = useRef("input");
|
||||
}
|
||||
|
||||
get buttonText() {
|
||||
return this.subscription.lastRequestStatus === "error" ? "Retry" : "Register";
|
||||
}
|
||||
|
||||
get alertType() {
|
||||
if (this.subscription.lastRequestStatus === "success") {
|
||||
return "success";
|
||||
}
|
||||
const { daysLeft } = this.subscription;
|
||||
if (daysLeft <= 6) {
|
||||
return "danger";
|
||||
} else if (daysLeft <= 16) {
|
||||
return "warning";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
get expirationMessage() {
|
||||
const { _t } = this.env;
|
||||
const { daysLeft } = this.subscription;
|
||||
if (daysLeft <= 0) {
|
||||
return _t("This database has expired. ");
|
||||
}
|
||||
const delay = daysLeft === 30 ? _t("1 month") : sprintf(_t("%s days"), daysLeft);
|
||||
if (this.subscription.expirationReason === "demo") {
|
||||
return sprintf(_t("This demo database will expire in %s. "), delay);
|
||||
}
|
||||
return sprintf(_t("This database will expire in %s. "), delay);
|
||||
}
|
||||
|
||||
showRegistrationForm() {
|
||||
this.state.displayRegisterForm = !this.state.displayRegisterForm;
|
||||
}
|
||||
|
||||
async onCodeSubmit() {
|
||||
const enterpriseCode = this.inputRef.el.value;
|
||||
if (!enterpriseCode) {
|
||||
return;
|
||||
}
|
||||
await this.subscription.submitCode(enterpriseCode);
|
||||
if (this.subscription.lastRequestStatus === "success") {
|
||||
this.state.displayRegisterForm = false;
|
||||
} else {
|
||||
this.state.buttonText = "Retry";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExpirationPanel.template = "DatabaseExpirationPanel";
|
||||
ExpirationPanel.components = { Transition };
|
||||
@@ -0,0 +1,8 @@
|
||||
.database_expiration_panel .oe_instance_register_form {
|
||||
max-height: 0;
|
||||
transition: max-height 0.4s;
|
||||
|
||||
&.o-vertical-slide-enter-active {
|
||||
max-height: 10rem; // fixed value is required to properly trigger transition
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="DatabaseExpirationPanel" owl="1">
|
||||
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,9 @@
|
||||
// = Home Menu
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_home_menu_background {
|
||||
.o_menu_systray .badge {
|
||||
--o-navbar-badge-bg: #{$o-brand-primary};
|
||||
}
|
||||
}
|
||||
310
web_enterprise/static/src/webclient/home_menu/home_menu.js
Normal file
310
web_enterprise/static/src/webclient/home_menu/home_menu.js
Normal file
@@ -0,0 +1,310 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { isIosApp, isMacOS } from "@web/core/browser/feature_detection";
|
||||
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { ExpirationPanel } from "./expiration_panel";
|
||||
|
||||
const {
|
||||
Component,
|
||||
useExternalListener,
|
||||
onMounted,
|
||||
onPatched,
|
||||
onWillUpdateProps,
|
||||
useState,
|
||||
useRef,
|
||||
} = owl;
|
||||
|
||||
class FooterComponent extends Component {
|
||||
setup() {
|
||||
this.controlKey = isMacOS() ? "COMMAND" : "CONTROL";
|
||||
}
|
||||
}
|
||||
FooterComponent.template = "web_enterprise.HomeMenu.CommandPalette.Footer";
|
||||
/**
|
||||
* Home menu
|
||||
*
|
||||
* This component handles the display and navigation between the different
|
||||
* available applications and menus.
|
||||
* @extends Component
|
||||
*/
|
||||
export class HomeMenu extends Component {
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object[]} props.apps application icons
|
||||
* @param {number} props.apps[].actionID
|
||||
* @param {number} props.apps[].id
|
||||
* @param {string} props.apps[].label
|
||||
* @param {string} props.apps[].parents
|
||||
* @param {(boolean|string|Object)} props.apps[].webIcon either:
|
||||
* - boolean: false (no webIcon)
|
||||
* - string: path to Odoo icon file
|
||||
* - Object: customized icon (background, class and color)
|
||||
* @param {string} [props.apps[].webIconData]
|
||||
* @param {string} props.apps[].xmlid
|
||||
*/
|
||||
setup() {
|
||||
this.command = useService("command");
|
||||
this.menus = useService("menu");
|
||||
this.homeMenuService = useService("home_menu");
|
||||
this.subscription = useState(useService("enterprise_subscription"));
|
||||
this.ui = useService("ui");
|
||||
this.state = useState({
|
||||
focusedIndex: null,
|
||||
isIosApp: isIosApp(),
|
||||
});
|
||||
this.inputRef = useRef("input");
|
||||
|
||||
if (!this.env.isSmall) {
|
||||
this._registerHotkeys();
|
||||
}
|
||||
|
||||
onWillUpdateProps(() => {
|
||||
// State is reset on each remount
|
||||
this.state.focusedIndex = null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this._focusInput();
|
||||
});
|
||||
|
||||
onPatched(() => {
|
||||
if (this.state.focusedIndex !== null && !this.env.isSmall) {
|
||||
const selectedItem = document.querySelector(".o_home_menu .o_menuitem.o_focused");
|
||||
// When TAB is managed externally the class o_focused disappears.
|
||||
if (selectedItem) {
|
||||
// Center window on the focused item
|
||||
selectedItem.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Getters
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
get displayedApps() {
|
||||
return this.props.apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get maxIconNumber() {
|
||||
const w = window.innerWidth;
|
||||
if (w < 576) {
|
||||
return 3;
|
||||
} else if (w < 768) {
|
||||
return 4;
|
||||
} else {
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} menu
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_openMenu(menu) {
|
||||
return this.menus.selectMenu(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this.state.focusedIndex if not null.
|
||||
* @private
|
||||
* @param {string} cmd
|
||||
*/
|
||||
_updateFocusedIndex(cmd) {
|
||||
const nbrApps = this.displayedApps.length;
|
||||
const lastIndex = nbrApps - 1;
|
||||
const focusedIndex = this.state.focusedIndex;
|
||||
if (lastIndex < 0) {
|
||||
return;
|
||||
}
|
||||
if (focusedIndex === null) {
|
||||
this.state.focusedIndex = 0;
|
||||
return;
|
||||
}
|
||||
const lineNumber = Math.ceil(nbrApps / this.maxIconNumber);
|
||||
const currentLine = Math.ceil((focusedIndex + 1) / this.maxIconNumber);
|
||||
let newIndex;
|
||||
switch (cmd) {
|
||||
case "previousElem":
|
||||
newIndex = focusedIndex - 1;
|
||||
break;
|
||||
case "nextElem":
|
||||
newIndex = focusedIndex + 1;
|
||||
break;
|
||||
case "previousColumn":
|
||||
if (focusedIndex % this.maxIconNumber) {
|
||||
// app is not the first one on its line
|
||||
newIndex = focusedIndex - 1;
|
||||
} else {
|
||||
newIndex =
|
||||
focusedIndex + Math.min(lastIndex - focusedIndex, this.maxIconNumber - 1);
|
||||
}
|
||||
break;
|
||||
case "nextColumn":
|
||||
if (focusedIndex === lastIndex || (focusedIndex + 1) % this.maxIconNumber === 0) {
|
||||
// app is the last one on its line
|
||||
newIndex = (currentLine - 1) * this.maxIconNumber;
|
||||
} else {
|
||||
newIndex = focusedIndex + 1;
|
||||
}
|
||||
break;
|
||||
case "previousLine":
|
||||
if (currentLine === 1) {
|
||||
newIndex = focusedIndex + (lineNumber - 1) * this.maxIconNumber;
|
||||
if (newIndex > lastIndex) {
|
||||
newIndex = lastIndex;
|
||||
}
|
||||
} else {
|
||||
// we go to the previous line on same column
|
||||
newIndex = focusedIndex - this.maxIconNumber;
|
||||
}
|
||||
break;
|
||||
case "nextLine":
|
||||
if (currentLine === lineNumber) {
|
||||
newIndex = focusedIndex % this.maxIconNumber;
|
||||
} else {
|
||||
// we go to the next line on the closest column
|
||||
newIndex =
|
||||
focusedIndex + Math.min(this.maxIconNumber, lastIndex - focusedIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// if newIndex is out of bounds -> normalize it
|
||||
if (newIndex < 0) {
|
||||
newIndex = lastIndex;
|
||||
} else if (newIndex > lastIndex) {
|
||||
newIndex = 0;
|
||||
}
|
||||
this.state.focusedIndex = newIndex;
|
||||
}
|
||||
|
||||
_focusInput() {
|
||||
if (!this.env.isSmall && this.inputRef.el) {
|
||||
this.inputRef.el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} app
|
||||
*/
|
||||
_onAppClick(app) {
|
||||
this._openMenu(app);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_registerHotkeys() {
|
||||
const hotkeys = [
|
||||
["ArrowDown", () => this._updateFocusedIndex("nextLine")],
|
||||
["ArrowRight", () => this._updateFocusedIndex("nextColumn")],
|
||||
["ArrowUp", () => this._updateFocusedIndex("previousLine")],
|
||||
["ArrowLeft", () => this._updateFocusedIndex("previousColumn")],
|
||||
["Tab", () => this._updateFocusedIndex("nextElem")],
|
||||
["shift+Tab", () => this._updateFocusedIndex("previousElem")],
|
||||
[
|
||||
"Enter",
|
||||
() => {
|
||||
const menu = this.displayedApps[this.state.focusedIndex];
|
||||
if (menu) {
|
||||
this._openMenu(menu);
|
||||
}
|
||||
},
|
||||
],
|
||||
["Escape", () => this.homeMenuService.toggle(false)],
|
||||
];
|
||||
hotkeys.forEach((hotkey) => {
|
||||
useHotkey(...hotkey, {
|
||||
allowRepeat: true,
|
||||
});
|
||||
});
|
||||
useExternalListener(window, "keydown", this._onKeydownFocusInput);
|
||||
}
|
||||
|
||||
_onKeydownFocusInput() {
|
||||
if (
|
||||
document.activeElement !== this.inputRef.el &&
|
||||
this.ui.activeElement === document &&
|
||||
!["TEXTAREA", "INPUT"].includes(document.activeElement.tagName)
|
||||
) {
|
||||
this._focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
_onInputSearch() {
|
||||
const onClose = () => {
|
||||
this._focusInput();
|
||||
this.inputRef.el.value = "";
|
||||
};
|
||||
const searchValue = this.compositionStart ? "/" : `/${this.inputRef.el.value.trim()}`;
|
||||
this.compositionStart = false;
|
||||
this.command.openMainPalette({ searchValue, FooterComponent }, onClose);
|
||||
}
|
||||
|
||||
_onInputBlur() {
|
||||
// if we blur search input to focus on body (eg. click on any
|
||||
// non-interactive element) restore focus to avoid IME input issue
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === document.body && this.ui.activeElement === document) {
|
||||
this._focusInput();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
_onCompositionStart() {
|
||||
this.compositionStart = true;
|
||||
}
|
||||
}
|
||||
HomeMenu.components = { ExpirationPanel };
|
||||
HomeMenu.props = {
|
||||
apps: {
|
||||
type: Array,
|
||||
element: {
|
||||
type: Object,
|
||||
shape: {
|
||||
actionID: Number,
|
||||
appID: Number,
|
||||
id: Number,
|
||||
label: String,
|
||||
parents: String,
|
||||
webIcon: {
|
||||
type: [
|
||||
Boolean,
|
||||
String,
|
||||
{
|
||||
type: Object,
|
||||
optional: 1,
|
||||
shape: {
|
||||
iconClass: String,
|
||||
color: String,
|
||||
backgroundColor: String,
|
||||
},
|
||||
},
|
||||
],
|
||||
optional: true,
|
||||
},
|
||||
webIconData: { type: String, optional: 1 },
|
||||
xmlid: String,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
HomeMenu.template = "web_enterprise.HomeMenu";
|
||||
114
web_enterprise/static/src/webclient/home_menu/home_menu.scss
Normal file
114
web_enterprise/static/src/webclient/home_menu/home_menu.scss
Normal file
@@ -0,0 +1,114 @@
|
||||
.o_home_menu_background {
|
||||
// 'Home menu background' design is shared with enterprise login
|
||||
// screens and it's located in './home_menu_background.scss'
|
||||
|
||||
// When applied on webclient (note: we do not specify the webclient class
|
||||
// here to avoid breaking studio custom style)
|
||||
&:not(.o_home_menu_background_custom) .o_main_navbar {
|
||||
background: transparent;
|
||||
border-bottom-color: transparent;
|
||||
|
||||
.dropdown-menu {
|
||||
border-color: $dropdown-bg;
|
||||
}
|
||||
|
||||
.o_dropdown_active,
|
||||
> ul > li.show > a {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_home_menu_background_custom .o_home_menu {
|
||||
background: {
|
||||
size: cover;
|
||||
repeat: no-repeat;
|
||||
position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.o_menu_systray {
|
||||
@include print-variable(o-navbar-badge-bg, $o-navbar-home-menu-badge-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.o_home_menu {
|
||||
font-size: $o-home-menu-font-size-base;
|
||||
|
||||
.container {
|
||||
@include media-breakpoint-up(md) {
|
||||
max-width: $o-home-menu-container-size !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o_app {
|
||||
&,
|
||||
.o_app_icon {
|
||||
transition: all 0.3s ease 0s;
|
||||
}
|
||||
|
||||
.o_app_icon {
|
||||
width: $o-home-menu-app-icon-max-width;
|
||||
height: $o-home-menu-app-icon-max-width;
|
||||
background: {
|
||||
size: cover;
|
||||
repeat: no-repeat;
|
||||
position: center;
|
||||
}
|
||||
|
||||
.fa {
|
||||
font-size: $o-home-menu-app-icon-max-width * 0.5;
|
||||
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.23);
|
||||
}
|
||||
}
|
||||
|
||||
.o_caption {
|
||||
color: $o-home-menu-caption-color;
|
||||
text-shadow: $o-home-menu-caption-shadow;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&.o_focused,
|
||||
&:hover {
|
||||
.o_app_icon {
|
||||
box-shadow: 0 8px 15px -10px black;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&.o_focused {
|
||||
background-color: rgba(white, 0.05);
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS iPhone list layout due to Apple AppStore review
|
||||
@include media-breakpoint-down(md) {
|
||||
&.o_ios_app {
|
||||
.o_apps {
|
||||
flex-direction: column;
|
||||
font-size: $o-home-menu-font-size-base * 1.25;
|
||||
margin-top: map-get($spacers, 1);
|
||||
padding: 0 map-get($spacers, 2);
|
||||
}
|
||||
|
||||
.o_app {
|
||||
flex-direction: row !important;
|
||||
justify-content: initial !important;
|
||||
width: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
padding: map-get($spacers, 3) map-get($spacers, 4) !important;
|
||||
}
|
||||
|
||||
.o_app_icon {
|
||||
width: $o-home-menu-app-icon-max-width * 0.75;
|
||||
height: $o-home-menu-app-icon-max-width * 0.75;
|
||||
margin-right: map-get($spacers, 4);
|
||||
}
|
||||
|
||||
.o_caption {
|
||||
text-align: start !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// = Home Menu Variables
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
$o-home-menu-caption-color: $o-black !default;
|
||||
$o-home-menu-caption-shadow: none !default;
|
||||
@@ -0,0 +1,6 @@
|
||||
$o-home-menu-font-size-base: 1.25rem;
|
||||
$o-home-menu-container-size: 850px;
|
||||
$o-home-menu-app-icon-max-width: 70px;
|
||||
|
||||
$o-home-menu-caption-color: $o-white !default;
|
||||
$o-home-menu-caption-shadow: 0 1px 1px rgba($o-black, 0.8) !default;
|
||||
46
web_enterprise/static/src/webclient/home_menu/home_menu.xml
Normal file
46
web_enterprise/static/src/webclient/home_menu/home_menu.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web_enterprise.HomeMenu" owl="1">
|
||||
<div class="o_home_menu h-100 overflow-auto" t-att-class="{ o_ios_app: state.isIosApp }">
|
||||
<div class="container">
|
||||
<input t-ref="input" type="text" class="o_search_hidden visually-hidden w-auto" data-allow-hotkeys="true" t-on-input="_onInputSearch" t-on-blur="_onInputBlur" t-on-compositionstart="_onCompositionStart"/>
|
||||
<!-- When the subscription has expired, the expiration panel is show over the whole UI instead of here -->
|
||||
<ExpirationPanel t-if="subscription.warningType and !subscription.isWarningHidden and subscription.daysLeft <= 30 and subscription.daysLeft > 0"/>
|
||||
<div t-if="displayedApps.length" role="listbox" class="o_apps row user-select-none mt-5">
|
||||
<a t-foreach="displayedApps" t-as="app"
|
||||
t-att-id="'result_app_' + app_index"
|
||||
role="option"
|
||||
t-att-aria-selected="state.focusedIndex === app_index ? 'true' : 'false'"
|
||||
class="o_app o_menuitem col-4 col-sm-3 col-md-2 p-2 mb-3 d-flex flex-column rounded justify-content-center align-items-center"
|
||||
t-att-class="{o_focused: state.focusedIndex === app_index}"
|
||||
t-att-data-menu-xmlid="app.xmlid"
|
||||
t-att-href="app.href || ('#menu_id='+app.id+'&action_id='+app.actionID)"
|
||||
t-key="app.id"
|
||||
t-on-click.prevent="() => this._onAppClick(app)"
|
||||
>
|
||||
<img t-if="app.webIconData" class="o_app_icon rounded"
|
||||
t-attf-src="{{app.webIconData}}"
|
||||
/>
|
||||
<div t-else="" class="o_app_icon rounded d-flex p-2 justify-content-center align-items-center"
|
||||
t-attf-style="background-color: {{app.webIcon.backgroundColor}};"
|
||||
>
|
||||
<i t-att-class="app.webIcon.iconClass" t-attf-style="color: {{app.webIcon.color}};"/>
|
||||
</div>
|
||||
<div class="o_caption w-100 text-center text-truncate mt-2" t-esc="app.label or app.name"/>
|
||||
</a>
|
||||
</div>
|
||||
<div t-elif="!displayedApps.length" id="result_menu_0" role="option" aria-selected="true" class="o_no_result">
|
||||
No result
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web_enterprise.HomeMenu.CommandPalette.Footer" owl="1">
|
||||
<span>
|
||||
<span class='fw-bolder text-primary'>TIP</span> — open me anywhere with <span t-esc="controlKey" class='fw-bolder text-primary'/> + <span class='fw-bolder text-primary'>K</span>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,13 @@
|
||||
// = Home Menu Background
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_home_menu_background {
|
||||
--homeMenu-bg-color: #000511;
|
||||
--homeMenu-bg-image:
|
||||
radial-gradient(at 100% 0%, #{rgba($o-gray-100, 0.6)} 0px, #{rgba($o-gray-100, 0.1)} 50%, #{rgba($o-gray-100, 0)} 100%),
|
||||
radial-gradient(at 7% 13%, #{rgba($o-gray-200, 0.6)} 0px, #{rgba($o-gray-100, 0.05)} 50%, #{rgba($o-gray-100, 0)} 100%),
|
||||
radial-gradient(at 96% 94%, #{rgba($o-gray-100, 0.5)} 0px, #{rgba($o-gray-100, 0.08)} 50%, #{rgba($o-gray-100, 0)} 100%),
|
||||
radial-gradient(at 3% 96%, #{rgba($o-gray-200, 0.6)} 0px, #{rgba($o-gray-100, 0.05)} 50%, #{rgba($o-gray-100, 0)} 100%),
|
||||
url("/web_enterprise/static/img/home-menu-bg-overlay.svg");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Shared with web client and login screen
|
||||
.o_home_menu_background, .o_web_client.o_home_menu_background {
|
||||
background: {
|
||||
size: cover;
|
||||
attachment: fixed;
|
||||
color: var(--homeMenu-bg-color, #917878);
|
||||
image: var(--homeMenu-bg-image,
|
||||
radial-gradient(at 100% 0%, hsla(289,17%,21%,0.6) 0px, transparent 50%),
|
||||
radial-gradient(at 7% 13%, hsla(268,5%,47%,0.42) 0px, transparent 50%),
|
||||
radial-gradient(at 96% 94%, hsla(267,5%,46%,0.51) 0px, transparent 50%),
|
||||
radial-gradient(at 3% 96%, hsla(289,17%,21%,0.41) 0px, transparent 50%),
|
||||
url("/web_enterprise/static/img/home-menu-bg-overlay.svg")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Mutex } from "@web/core/utils/concurrency";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { computeAppsAndMenuItems } from "@web/webclient/menus/menu_helpers";
|
||||
import { ControllerNotFoundError } from "@web/webclient/actions/action_service";
|
||||
import { HomeMenu } from "./home_menu";
|
||||
|
||||
const { Component, onMounted, onWillUnmount, xml } = owl;
|
||||
|
||||
export const homeMenuService = {
|
||||
dependencies: ["action", "router"],
|
||||
start(env) {
|
||||
let hasHomeMenu = false; // true iff the HomeMenu is currently displayed
|
||||
let hasBackgroundAction = false; // true iff there is an action behind the HomeMenu
|
||||
const mutex = new Mutex(); // used to protect against concurrent toggling requests
|
||||
|
||||
class HomeMenuAction extends Component {
|
||||
setup() {
|
||||
this.router = useService("router");
|
||||
this.menus = useService("menu");
|
||||
this.homeMenuProps = {
|
||||
apps: computeAppsAndMenuItems(this.menus.getMenuAsTree("root")).apps,
|
||||
};
|
||||
onMounted(() => this.onMounted());
|
||||
onWillUnmount(this.onWillUnmount);
|
||||
}
|
||||
async onMounted() {
|
||||
const { breadcrumbs } = this.env.config;
|
||||
hasHomeMenu = true;
|
||||
hasBackgroundAction = breadcrumbs.length > 0;
|
||||
this.router.pushState({ menu_id: undefined }, { lock: false, replace: true });
|
||||
this.env.bus.trigger("HOME-MENU:TOGGLED");
|
||||
}
|
||||
onWillUnmount() {
|
||||
hasHomeMenu = false;
|
||||
hasBackgroundAction = false;
|
||||
const currentMenuId = this.menus.getCurrentApp();
|
||||
if (currentMenuId) {
|
||||
this.router.pushState({ menu_id: currentMenuId.id }, { lock: true });
|
||||
}
|
||||
this.env.bus.trigger("HOME-MENU:TOGGLED");
|
||||
}
|
||||
}
|
||||
HomeMenuAction.components = { HomeMenu };
|
||||
HomeMenuAction.target = "current";
|
||||
HomeMenuAction.template = xml`<HomeMenu t-props="homeMenuProps"/>`;
|
||||
|
||||
registry.category("actions").add("menu", HomeMenuAction);
|
||||
|
||||
env.bus.on("HOME-MENU:TOGGLED", null, () => {
|
||||
document.body.classList.toggle("o_home_menu_background", hasHomeMenu);
|
||||
});
|
||||
|
||||
return {
|
||||
get hasHomeMenu() {
|
||||
return hasHomeMenu;
|
||||
},
|
||||
get hasBackgroundAction() {
|
||||
return hasBackgroundAction;
|
||||
},
|
||||
async toggle(show) {
|
||||
return mutex.exec(async () => {
|
||||
show = show === undefined ? !hasHomeMenu : Boolean(show);
|
||||
if (show !== hasHomeMenu) {
|
||||
if (show) {
|
||||
await env.services.action.doAction("menu");
|
||||
} else {
|
||||
try {
|
||||
await env.services.action.restore();
|
||||
} catch (err) {
|
||||
if (!(err instanceof ControllerNotFoundError)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// hack: wait for a tick to ensure that the url has been updated before
|
||||
// switching again
|
||||
return new Promise((r) => setTimeout(r));
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("home_menu", homeMenuService);
|
||||
Reference in New Issue
Block a user