Tower: upload cetmix_tower_server 18.0.2.0.0 (was 18.0.2.0.0, via marketplace)

This commit is contained in:
2026-05-03 18:54:38 +00:00
parent 5880120a84
commit c83da26305
235 changed files with 89704 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -0,0 +1,514 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Cetmix Tower Server</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="cetmix-tower-server">
<h1 class="title">Cetmix Tower Server</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:f057dfd35f33f40155780f5855b2d2628821cd120dad4dfcf7028943a6154b47
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/cetmix/cetmix-tower/tree/18.0/cetmix_tower_server"><img alt="cetmix/cetmix-tower" src="https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github" /></a></p>
<p><a class="reference external" href="https://cetmix.com/tower">Cetmix Tower</a> offers a streamlined
solution for managing remote servers and applications via SSH or API
calls directly from <a class="reference external" href="https://odoo.com">Odoo</a>. It is designed for
versatility across different operating systems and software
environments, providing a practical option for those looking to manage
servers without getting tied down by vendor or technology constraints.</p>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed information.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
<li><a class="reference internal" href="#usage" id="toc-entry-2">Usage</a></li>
<li><a class="reference internal" href="#changelog" id="toc-entry-3">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-4">18.0.2.0.0 (2026-04-07)</a></li>
<li><a class="reference internal" href="#section-2" id="toc-entry-5">18.0.1.0.11 (2026-03-10)</a></li>
<li><a class="reference internal" href="#section-3" id="toc-entry-6">18.0.1.0.10 (2026-03-10)</a></li>
<li><a class="reference internal" href="#section-4" id="toc-entry-7">18.0.1.0.9 (2026-02-19)</a></li>
<li><a class="reference internal" href="#section-5" id="toc-entry-8">18.0.1.0.7 (2026-02-05)</a></li>
<li><a class="reference internal" href="#section-6" id="toc-entry-9">18.0.1.0.6 (2026-01-20)</a></li>
<li><a class="reference internal" href="#section-7" id="toc-entry-10">18.0.1.0.4 (2025-12-23)</a></li>
<li><a class="reference internal" href="#section-8" id="toc-entry-11">18.0.1.0.3 (2025-12-17)</a></li>
<li><a class="reference internal" href="#section-9" id="toc-entry-12">18.0.1.0.2 (2025-12-08)</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-13">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-14">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-15">Authors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-16">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed configuration
instructions.</p>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-2">Usage</a></h1>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed usage
instructions.</p>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-3">Changelog</a></h1>
<div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-4">18.0.2.0.0 (2026-04-07)</a></h2>
<ul class="simple">
<li>Features: Jets! (4700)</li>
</ul>
</div>
<div class="section" id="section-2">
<h2><a class="toc-backref" href="#toc-entry-5">18.0.1.0.11 (2026-03-10)</a></h2>
<ul class="simple">
<li>Bugfixes: Last flight plan line post-run action was not triggered
correctly. (5120)</li>
</ul>
</div>
<div class="section" id="section-3">
<h2><a class="toc-backref" href="#toc-entry-6">18.0.1.0.10 (2026-03-10)</a></h2>
<ul class="simple">
<li>Features: Improve the File using template command flow, fix the
flight plan line view layout. (5197)</li>
</ul>
</div>
<div class="section" id="section-4">
<h2><a class="toc-backref" href="#toc-entry-7">18.0.1.0.9 (2026-02-19)</a></h2>
<ul class="simple">
<li>Features: Blacklist filter for Python commands, value checker for
Vault. (5253)</li>
</ul>
</div>
<div class="section" id="section-5">
<h2><a class="toc-backref" href="#toc-entry-8">18.0.1.0.7 (2026-02-05)</a></h2>
<ul class="simple">
<li>Features: Scheduled tasks: allow to select specific days of week.
(5190)</li>
<li>Bugfixes: Ensure custom values can be updated even if not provided
initially. (5175)</li>
</ul>
</div>
<div class="section" id="section-6">
<h2><a class="toc-backref" href="#toc-entry-9">18.0.1.0.6 (2026-01-20)</a></h2>
<ul class="simple">
<li>Bugfixes: Make pre-defined messages and command help translatable
again. (5174)</li>
</ul>
</div>
<div class="section" id="section-7">
<h2><a class="toc-backref" href="#toc-entry-10">18.0.1.0.4 (2025-12-23)</a></h2>
<ul class="simple">
<li>Bugfixes: Handle malformed expressions in flight plan line conditions.
(5154)</li>
</ul>
</div>
<div class="section" id="section-8">
<h2><a class="toc-backref" href="#toc-entry-11">18.0.1.0.3 (2025-12-17)</a></h2>
<ul class="simple">
<li>Features: Parse empty or missing key values as None instead of
leaving key reference as is. (5134)</li>
<li>Features: Improve search views, implement the search panel for
selected views. (5139)</li>
<li>Bugfixes: Custom values in flight plan are lost in a skipped command
and are not available after it. (5129)</li>
</ul>
</div>
<div class="section" id="section-9">
<h2><a class="toc-backref" href="#toc-entry-12">18.0.1.0.2 (2025-12-08)</a></h2>
<ul class="simple">
<li>Bugfixes: Make variables selectable in scheduled tasks (5105)</li>
<li>Bugfixes: Save correct error message in log when SSH connection fails.
(5109)</li>
</ul>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-13">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_server%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-14">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-15">Authors</a></h2>
<ul class="simple">
<li>Cetmix</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-16">Maintainers</a></h2>
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/18.0/cetmix_tower_server">cetmix/cetmix-tower</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
/** @odoo-module **/
import {AceField} from "@web/views/fields/ace/ace_field";
import {CodeEditorTower} from "./code_editor_tower.esm";
import {registry} from "@web/core/registry";
import {_t} from "@web/core/l10n/translation";
class AceCommandField extends AceField {}
AceCommandField.template = "cetmix_tower_server.AceCommandField";
AceCommandField.components = {
CodeEditorTower,
};
registry.category("fields").add("ace_tower", {
component: AceCommandField,
displayName: _t("Ace Tower Editor"),
supportedOptions: [
{
label: _t("Mode"),
name: "mode",
type: "string",
},
],
supportedTypes: ["text", "html", "char"],
extractProps: ({options}) => ({
mode: options.mode,
}),
});
export {AceCommandField};

View File

@@ -0,0 +1,44 @@
// Custom styles ONLY for AceCommandField - more specific selectors
.o_field_widget.o_field_ace_tower {
display: block !important;
}
.o_field_widget[data-field-name] .o_field_ace.ace-command-field {
min-height: 200px !important;
height: auto !important;
width: 100% !important;
display: block !important;
.ace_editor {
min-height: 200px !important;
height: auto !important;
width: 100% !important;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
display: block !important;
}
.ace_content {
min-height: 200px !important;
width: 100% !important;
}
.ace_scroller {
width: 100% !important;
// Remove any scroll restrictions that might affect standard ACE
overflow: auto !important;
}
}
// Custom autocomplete popup styles
.ace-autocomplete-popup {
.ace-autocomplete-item {
&:hover {
background-color: #e6f3ff !important;
}
&:last-child {
border-bottom: none !important;
}
}
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="cetmix_tower_server.AceCommandField" owl="1">
<div class="o_field_widget oe_form_field o_ace_view_editor oe_ace_open">
<CodeEditorTower
value="state.initialValue"
mode="mode"
readonly="props.readonly"
onBlur.bind="commitChanges"
onChange.bind="handleChange"
class="'ace-view-editor'"
theme="theme"
maxLines="200"
/>
</div>
</t>
</templates>

View File

@@ -0,0 +1,296 @@
/** @odoo-module **/
import {Component, onWillDestroy, useEffect, useRef, useState} from "@odoo/owl";
class AutocompletePopup extends Component {
/**
* Component setup method that initializes refs, state, and effects
*/
setup() {
this.popupRef = useRef("popupRef");
this.searchInput = useRef("searchInput");
this.itemsContainer = useRef("itemsContainer");
// State for search functionality
this.state = useState({
searchTerm: "",
});
useEffect(
() => {
this.scrollToSelected();
},
() => [this.props.selectedIndex]
);
// Auto-focus search input when popup opens
useEffect(
() => {
if (this.searchInput.el) {
// Use setTimeout to ensure DOM is ready
const timeoutId = setTimeout(() => {
this.searchInput.el.focus();
}, 0);
return () => clearTimeout(timeoutId);
}
},
() => []
);
useEffect(
() => {
if (this.props.position) {
const timeoutId = setTimeout(() => {
if (this.popupRef.el) {
this.popupRef.el.style.left = `${this.props.position.left}px`;
this.popupRef.el.style.top = `${this.props.position.top}px`;
this.popupRef.el.style.position = "fixed";
}
}, 0);
return () => clearTimeout(timeoutId);
}
},
() => [this.props.position]
);
onWillDestroy(() => {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
});
}
/**
* Updates search term from external keyboard input (from editor)
* @param {String} char - The character typed or 'Backspace' for deletion
*/
updateSearchFromEditor(char) {
if (char === "Backspace") {
this.state.searchTerm = this.state.searchTerm.slice(0, -1);
} else if (char.length === 1) {
this.state.searchTerm += char;
}
if (this.props.onSelectedIndexChange) {
const newIndex = this.filteredCommands.length > 0 ? 0 : -1;
this.props.onSelectedIndexChange(newIndex);
}
}
/**
* Filters commands based on search term with enhanced search capabilities
* @returns {Array} Filtered and sorted array of commands matching the search term
*/
get filteredCommands() {
if (!this.state.searchTerm.trim()) {
return this.props.commands || [];
}
const searchTerm = this.state.searchTerm.toLowerCase();
const commands = this.props.commands || [];
const scoredCommands = commands
.map((command) => {
const name = (command.name || "").toLowerCase();
const reference = (command.reference || "").toLowerCase();
let score = 0;
// Exact matches get highest priority
if (name === searchTerm || reference === searchTerm) {
score = 1000;
}
// Starts with search term gets high priority
else if (
name.startsWith(searchTerm) ||
reference.startsWith(searchTerm)
) {
score = 100;
}
// Contains search term gets medium priority
else if (name.includes(searchTerm) || reference.includes(searchTerm)) {
score = 10;
}
// No match
else {
return null;
}
// Boost score for name matches over reference matches
if (name.includes(searchTerm)) {
score += 5;
}
// Boost score for shorter matches (more relevant)
score += Math.max(0, 50 - Math.min(name.length, reference.length));
return {command, score};
})
.filter((item) => item !== null)
.sort((a, b) => b.score - a.score)
.map((item) => item.command);
return scoredCommands;
}
/**
* Debounces the search filtering
* @param {String} searchTerm - The search term to set
*/
debouncedSearch(searchTerm) {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.searchTimeout = setTimeout(() => {
this.state.searchTerm = searchTerm;
if (this.props.onSelectedIndexChange) {
const newIndex = this.filteredCommands.length > 0 ? 0 : -1;
this.props.onSelectedIndexChange(newIndex);
}
}, 150);
}
/**
* Handles search input changes
* @param {Event} ev - The input event
*/
onSearchInput(ev) {
ev.stopPropagation();
this.debouncedSearch(ev.target.value);
}
/**
* Common keyboard navigation logic
* @param {KeyboardEvent} ev - The keyboard event
*/
handleKeyboardNavigation(ev) {
if (ev.key === "ArrowDown") {
ev.preventDefault();
const len = this.filteredCommands.length;
if (len === 0) return;
const current = this.props.selectedIndex ?? -1;
const newIndex = Math.min(current + 1, len - 1);
if (this.props.onSelectedIndexChange) {
this.props.onSelectedIndexChange(newIndex);
}
} else if (ev.key === "ArrowUp") {
ev.preventDefault();
const len = this.filteredCommands.length;
if (len === 0) return;
const current = this.props.selectedIndex ?? -1;
const newIndex = current <= 0 ? 0 : current - 1;
if (this.props.onSelectedIndexChange) {
this.props.onSelectedIndexChange(newIndex);
}
} else if (ev.key === "Enter") {
ev.preventDefault();
const idx = this.props.selectedIndex ?? -1;
if (idx >= 0) {
const selectedCommand = this.filteredCommands[idx];
this.onItemClick(selectedCommand);
}
} else if (ev.key === "Escape") {
ev.preventDefault();
this.props.onItemClick(null);
}
}
/**
* Handles keydown events on search input
* @param {KeyboardEvent} ev - The keyboard event
*/
onSearchKeyDown(ev) {
ev.stopPropagation();
this.handleKeyboardNavigation(ev);
}
/**
* Handles focus events on search input
* @param {FocusEvent} ev - The focus event
*/
onSearchFocus(ev) {
ev.stopPropagation();
}
/**
* Handles blur events on search input
* @param {FocusEvent} ev - The blur event
*/
onSearchBlur(ev) {
ev.stopPropagation();
}
/**
* Handles click events on search input
* @param {MouseEvent} ev - The click event
*/
onSearchClick(ev) {
ev.stopPropagation();
}
/**
* Handles mousedown events on search input
* @param {MouseEvent} ev - The mousedown event
*/
onSearchMouseDown(ev) {
ev.stopPropagation();
}
/**
* Handles item click events
* @param {Object} command - The selected command object
*/
onItemClick(command) {
this.props.onItemClick(command);
}
/**
* Handles close button click events
*/
onCloseClick() {
this.props.onItemClick(null);
}
/**
* Scrolls the selected item into view
*/
scrollToSelected() {
const itemsContainer = this.itemsContainer.el;
if (
itemsContainer &&
this.props.selectedIndex !== undefined &&
this.props.selectedIndex >= 0 &&
this.props.selectedIndex < itemsContainer.children.length
) {
const selectedItem = itemsContainer.children[this.props.selectedIndex];
if (selectedItem) {
selectedItem.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
}
/**
* Returns CSS class for autocomplete item based on selection state
* @param {Number} index - The item index
* @returns {String} CSS class string
*/
getItemClass(index) {
return index === (this.props.selectedIndex ?? -1)
? "ace-autocomplete-item ace-autocomplete-item-selected"
: "ace-autocomplete-item";
}
}
AutocompletePopup.template = "cetmix_tower_server.AutocompletePopup";
AutocompletePopup.props = {
commands: {type: Array},
onItemClick: {type: Function},
position: {type: Object},
selectedIndex: {type: Number, optional: true},
onSelectedIndexChange: {type: Function, optional: true},
type: {type: String, optional: true},
};
export {AutocompletePopup};

View File

@@ -0,0 +1,192 @@
// Define z-index variable for better management
$z-index-autocomplete: 1050 !default; // Above dropdowns but below modals
.ace-autocomplete-popup {
position: absolute;
background: white;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: $z-index-autocomplete;
min-width: 300px;
max-width: 500px;
max-height: 300px;
overflow: hidden;
font-family: monospace;
font-size: 14px;
// Mobile adaptations
@media (max-width: 768px) {
width: 90%;
min-width: unset;
max-width: unset;
left: 50% !important;
transform: translateX(-50%);
font-size: 13px;
max-height: 90vh;
}
}
.ace-autocomplete-search {
padding: 8px;
padding-right: 48px; // Add right padding to avoid overlap with close button
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg, #f8f9fa);
// Mobile: reduce padding
@media (max-width: 768px) {
padding: 6px;
padding-right: 56px;
}
}
.ace-autocomplete-search-input {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
font-size: 13px;
outline: none;
box-sizing: border-box;
}
.ace-autocomplete-search-input:focus {
border-color: #007cba;
box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.1);
}
.ace-autocomplete-items {
max-height: 240px;
overflow-y: auto;
color-scheme: light dark; /* Let the UA adapt colors when possible */
/* Standard scrollbar styling (Firefox 64+) */
scrollbar-width: thin;
scrollbar-color: var(--bs-border-color, #c1c1c1) var(--bs-tertiary-bg, #f1f1f1);
}
/* Scrollbar styling for webkit browsers */
.ace-autocomplete-items::-webkit-scrollbar {
width: 6px;
}
.ace-autocomplete-items::-webkit-scrollbar-track {
background: var(--bs-tertiary-bg, #f1f1f1);
}
.ace-autocomplete-items::-webkit-scrollbar-thumb {
background: var(--bs-border-color, #c1c1c1);
border-radius: 3px;
}
.ace-autocomplete-items::-webkit-scrollbar-thumb:hover {
background: var(--bs-secondary-color, #a8a8a8);
}
.ace-autocomplete-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--bs-border-color);
display: flex;
justify-content: space-between;
align-items: center;
// Mobile: stack items vertically with reduced padding
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
padding: 6px 12px;
}
}
.ace-autocomplete-item:hover {
background-color: var(--bs-tertiary-bg, #f5f5f5);
}
.ace-autocomplete-item-selected {
background-color: var(--bs-primary-bg-subtle, #e6f3ff);
color: var(--bs-primary, #0d6efd);
}
.ace-autocomplete-item-selected:hover {
background-color: var(--bs-primary-border-subtle, #cce7ff);
}
.command-name {
font-weight: 600;
color: #333;
flex: 1;
margin-right: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
// Mobile adaptations
@media (max-width: 768px) {
margin-right: 0;
margin-bottom: 2px; // Reduced from 4px
width: 100%;
font-size: 14px;
}
}
.command-description {
color: #666;
font-size: 12px;
font-style: italic;
flex-shrink: 0;
// Mobile adaptations
@media (max-width: 768px) {
font-size: 11px;
width: 100%;
word-break: break-word;
}
}
.ace-autocomplete-no-results {
padding: 16px 12px;
text-align: center;
color: var(--bs-secondary-color, #6c757d);
font-style: italic;
}
// Close button styles
.ace-autocomplete-close-btn {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
font-size: 20px;
font-weight: bold;
color: var(--bs-secondary-color, #6c757d);
cursor: pointer;
padding: 4px 8px;
line-height: 1;
border-radius: 3px;
z-index: 1;
min-width: 30px;
min-height: 30px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: var(--bs-secondary-bg, #f0f0f0);
color: var(--bs-body-color);
}
&:active {
background-color: var(--bs-tertiary-bg, #e0e0e0);
}
// Mobile-friendly touch target
@media (max-width: 768px) {
min-width: 40px;
min-height: 40px;
font-size: 24px;
top: 4px;
right: 4px;
}
}

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="cetmix_tower_server.AutocompletePopup" owl="1">
<div
class="ace-autocomplete-popup"
t-ref="popupRef"
tabindex="0"
role="combobox"
t-att-aria-label="props.type === 'secrets' ? 'Secret search' : 'Variable search'"
aria-expanded="true"
aria-haspopup="listbox"
t-att-aria-owns="props.type === 'secrets' ? 'secrets-list' : 'variables-list'"
>
<!-- Close button for mobile convenience -->
<button
class="ace-autocomplete-close-btn"
t-on-click="onCloseClick"
type="button"
title="Close"
t-att-aria-label="props.type === 'secrets' ? 'Close secret search' : 'Close variable search'"
>
×
</button>
<!-- Search input field -->
<div class="ace-autocomplete-search">
<input
type="text"
class="ace-autocomplete-search-input"
t-att-placeholder="props.type === 'secrets' ? 'Search secrets...' : 'Search variables...'"
t-model="state.searchTerm"
t-ref="searchInput"
t-on-input="onSearchInput"
t-on-keydown="onSearchKeyDown"
t-on-focus="onSearchFocus"
t-on-blur="onSearchBlur"
t-att-aria-label="props.type === 'secrets' ? 'Search secrets' : 'Search variables'"
t-att-aria-controls="props.type === 'secrets' ? 'secrets-list' : 'variables-list'"
aria-autocomplete="list"
t-att-aria-activedescendant="`${props.type === 'secrets' ? 'sec' : 'var'}-opt-${props.selectedIndex}`"
/>
</div>
<!-- Items list -->
<div
class="ace-autocomplete-items"
t-ref="itemsContainer"
t-att-id="props.type === 'secrets' ? 'secrets-list' : 'variables-list'"
role="listbox"
aria-live="polite"
>
<div
t-foreach="filteredCommands"
t-as="command"
t-key="command.name"
t-att-id="`${props.type === 'secrets' ? 'sec' : 'var'}-opt-${command_index}`"
t-att-class="getItemClass(command_index)"
t-on-click="() => this.onItemClick(command)"
role="option"
t-att-aria-selected="command_index === props.selectedIndex ? 'true' : 'false'"
>
<span class="command-name" t-esc="command.name" />
<span
class="command-description"
t-esc="props.type === 'secrets' ? `${command.reference}` : `{{ ${command.reference} }}`"
/>
</div>
<div
t-if="!filteredCommands.length"
class="ace-autocomplete-no-results"
>
<t t-if="props.type === 'secrets'">No secrets found</t>
<t t-else="">No variables found</t>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,514 @@
/** @odoo-module **/
import {onWillDestroy, useEffect, useState} from "@odoo/owl";
import {AutocompletePopup} from "./autocomplete_popup.esm";
import {CodeEditor} from "@web/core/code_editor/code_editor";
import {useService} from "@web/core/utils/hooks";
const POPUP_FALLBACK_WIDTH = 500;
const POPUP_FALLBACK_HEIGHT = 300;
export class CodeEditorTower extends CodeEditor {
static template = "cetmix_tower_server.CodeEditorTower";
static components = {
AutocompletePopup,
};
setup() {
super.setup();
this.orm = useService("orm");
this.inputListener = null;
this.clickOutsideListener = null;
this.inputTimeout = null;
this.clickOutsideTimeout = null;
this.variables = [];
this.secrets = [];
this.state = useState({
showPopup: false,
popupItems: [],
popupPosition: {},
selectedIndex: 0,
// Add popup type to distinguish between variables and secrets
popupType: "variables",
});
this.updateSelectedIndex = this.updateSelectedIndex.bind(this);
useEffect(
(el) => {
if (!el) {
return;
}
// Keep in closure
const aceEditor = window.ace.edit(el);
this.aceEditor = aceEditor;
const session = aceEditor.getSession();
this.setupCustomAutocompletion(aceEditor, session);
return () => {
if (aceEditor) {
aceEditor.destroy();
}
};
},
() => [this.editorRef.el]
);
onWillDestroy(() => {
if (this.inputTimeout) {
clearTimeout(this.inputTimeout);
}
if (this.clickOutsideTimeout) {
clearTimeout(this.clickOutsideTimeout);
}
if (this.aceEditor && this.inputListener) {
this.aceEditor.getSession().off("change", this.inputListener);
}
this.hideAutocompletePopup();
});
}
async loadVariables() {
try {
this.variables = await this.orm.searchRead(
"cx.tower.variable",
[],
["name", "reference"]
);
} catch (error) {
console.error("Failed to load variables for autocomplete:", error);
this.variables = [];
this.env.services.notification.add(
"Failed to load autocomplete variables",
{type: "warning"}
);
}
}
/**
* Load secrets from the backend using ORM service
* @returns {Promise<void>}
*/
async loadSecrets() {
try {
this.secrets = await this.orm.searchRead(
"cx.tower.key",
[["key_type", "=", "s"]],
["name", "reference"]
);
} catch (error) {
console.error("Failed to load secrets for autocomplete:", error);
this.secrets = [];
this.env.services.notification.add("Failed to load autocomplete secrets", {
type: "warning",
});
}
}
/**
* Configure custom autocompletion commands and keyboard bindings for ACE editor
* @param {Object} aceEditor - The ACE editor instance
* @param {Object} session - The ACE editor session
*/
setupCustomAutocompletion(aceEditor, session) {
// Remove any existing conflicting commands first
aceEditor.commands.removeCommand("startAutocomplete");
aceEditor.commands.removeCommand("expandSnippet");
// Only add the main autocomplete trigger command
aceEditor.commands.addCommand({
name: "customAutoComplete",
bindKey: {win: "Ctrl-Space", mac: null},
exec: (editor) => {
this.showCustomCompletions(editor);
return true;
},
});
// Set up input listener for {{ and #! triggers
this.inputListener = () => {
// Clear any existing timeout
if (this.inputTimeout) {
clearTimeout(this.inputTimeout);
}
// Use setTimeout to ensure the text is fully processed
this.inputTimeout = setTimeout(() => {
const cursor = aceEditor.getCursorPosition();
const line = session.getLine(cursor.row);
const textBeforeCursor = line.substring(0, cursor.column);
// Check for variables trigger {{
if (textBeforeCursor.endsWith("{{")) {
// Remove {{ symbols from editor
const startColumn = Math.max(0, cursor.column - 2);
const range = {
start: {row: cursor.row, column: startColumn},
end: {row: cursor.row, column: cursor.column},
};
session.replace(range, "");
// Update cursor position
const newCursor = {
row: cursor.row,
column: startColumn,
};
aceEditor.moveCursorToPosition(newCursor);
this.showCustomCompletions(aceEditor, "variables");
}
// Check for secrets trigger #!
else if (textBeforeCursor.endsWith("#!")) {
// Remove !# symbols from editor
const startColumn = Math.max(0, cursor.column - 2);
const range = {
start: {row: cursor.row, column: startColumn},
end: {row: cursor.row, column: cursor.column},
};
session.replace(range, "");
// Update cursor position
const newCursor = {
row: cursor.row,
column: startColumn,
};
aceEditor.moveCursorToPosition(newCursor);
this.showCustomCompletions(aceEditor, "secrets");
}
}, 10);
};
session.on("change", this.inputListener);
}
/**
* Show custom completions popup with available variables or secrets
* @param {Object} editor - ACE editor instance
* @param {String} type - Type of completion ('variables' or 'secrets')
* @returns {Promise<void>}
*/
async showCustomCompletions(editor, type = "variables") {
const cursor = editor.getCursorPosition();
const session = editor.getSession();
const line = session.getLine(cursor.row);
const textBeforeCursor = line.substring(0, cursor.column);
let items = [];
let triggerLength = 0;
if (type === "secrets") {
// Handle secrets
await this.loadSecrets();
if (!this.secrets.length) {
return;
}
items = this.secrets;
} else {
// Handle variables
await this.loadVariables();
if (!this.variables.length) {
return;
}
items = this.variables;
// Check if we're already in a variable context
const isInVariableContext = textBeforeCursor.endsWith("{{");
if (isInVariableContext) {
triggerLength = 2;
}
}
const position = this.calculatePopupPosition(editor, cursor);
// Set popup type in state
this.state.popupType = type;
await this.showAutocompletePopup(items, position, editor, triggerLength, type);
}
/**
* Calculate the optimal position for the autocomplete popup
* @param {Object} editor - ACE editor instance
* @param {Object} cursor - Cursor position object
* @returns {Object} Position object with left and top coordinates
*/
calculatePopupPosition(editor, cursor) {
const renderer = editor.renderer;
// Calculate cursor position within the editor
const cursorPixelPos = renderer.textToScreenCoordinates(
cursor.row,
cursor.column
);
// Get scroll position
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
// Calculate the cursor position relative to the viewport
const viewportLeft = cursorPixelPos.pageX - scrollLeft;
const viewportTop = cursorPixelPos.pageY - scrollTop;
// Position popup just below the cursor
const finalLeft = viewportLeft;
const finalTop = viewportTop + renderer.lineHeight;
// Ensure popup doesn't go outside viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const popup = document.querySelector(".ace-autocomplete-popup");
const popupWidth = popup ? popup.offsetWidth : POPUP_FALLBACK_WIDTH;
const popupHeight = popup ? popup.offsetHeight : POPUP_FALLBACK_HEIGHT;
let adjustedLeft = finalLeft;
let adjustedTop = finalTop;
// Adjust if popup would go off-screen horizontally
if (finalLeft + popupWidth > viewportWidth) {
adjustedLeft = finalLeft - popupWidth;
}
// Adjust if popup would go off-screen vertically
if (finalTop + popupHeight > viewportHeight) {
adjustedTop = finalTop - popupHeight - renderer.lineHeight;
}
// Make sure popup is not positioned off-screen
adjustedLeft = Math.max(0, adjustedLeft);
adjustedTop = Math.max(0, adjustedTop);
return {
left: adjustedLeft,
top: adjustedTop,
};
}
/**
* Display the autocomplete popup with variables or secrets at the specified position
* @param {Array} items - Array of available variables or secrets
* @param {Object} position - Position object with left and top coordinates
* @param {Object} editor - ACE editor instance
* @param {Number} triggerLength - Length of trigger text that should be replaced
* @param {String} type - Type of completion ('variables' or 'secrets')
* @returns {Promise<void>}
*/
async showAutocompletePopup(
items,
position,
editor,
triggerLength,
type = "variables"
) {
this.hideAutocompletePopup();
this.state.popupItems = items;
this.state.popupPosition = position;
this.state.showPopup = true;
this.state.selectedIndex = 0;
this.state.popupType = type;
this.currentEditor = editor;
this.currentTriggerLength = triggerLength;
this.currentType = type;
// Add click outside listener
this.clickOutsideListener = (event) => {
// Check if click is outside the popup and ace editor
const popupElement = document.querySelector(".ace-autocomplete-popup");
const aceElement = this.aceEditor.container;
if (
popupElement &&
!popupElement.contains(event.target) &&
aceElement &&
!aceElement.contains(event.target)
) {
this.hideAutocompletePopup();
}
};
// Store timeout ID to prevent race condition
this.clickOutsideTimeout = setTimeout(() => {
// Guard against race condition: only register if popup is still shown
if (this.state.showPopup) {
document.addEventListener("click", this.clickOutsideListener, true);
}
this.clickOutsideTimeout = null;
}, 0);
}
/**
* Hide the autocomplete popup and clean up event listeners
*/
hideAutocompletePopup() {
// Clear pending timeout to prevent race condition
if (this.clickOutsideTimeout) {
clearTimeout(this.clickOutsideTimeout);
this.clickOutsideTimeout = null;
}
// Remove click outside listener
if (this.clickOutsideListener) {
document.removeEventListener("click", this.clickOutsideListener, true);
this.clickOutsideListener = null;
}
this.state.showPopup = false;
this.state.popupItems = [];
this.currentEditor = null;
this.state.selectedIndex = 0;
// Return focus to the ACE editor
if (this.aceEditor) {
this.aceEditor.focus();
}
}
/**
* Update the selected index in the autocomplete popup
* @param {Number} index - New selected index
*/
updateSelectedIndex(index) {
if (this.state) {
this.state.selectedIndex = index;
}
}
/**
* Handle selection of a command from the autocomplete popup
* @param {Object} command - Selected command object
* @param {Object} editor - ACE editor instance
*/
handleCommandSelection(command, editor) {
if (!command || !command.reference) {
this.hideAutocompletePopup();
return;
}
const cursor = editor.getCursorPosition();
const session = editor.getSession();
const line = session.getLine(cursor.row);
const textBeforeCursor = line.substring(0, cursor.column);
// Get line length for validation
const lineLength = session.getLine(cursor.row).length;
const currentType = this.currentType || this.state.popupType;
let range = null;
let insertText = "";
if (currentType === "secrets") {
// Handle secrets insertion
// Check if we're inside a secret context (between #!cxtower.secret and !#)
const lastSecretStart = textBeforeCursor.lastIndexOf("#!cxtower.secret");
const lastSecretEnd = textBeforeCursor.lastIndexOf("!#");
// Count occurrences of start and end delimiters for more robust validation
const startCount = (textBeforeCursor.match(/#!cxtower\.secret/g) || [])
.length;
const endCount = (textBeforeCursor.match(/!#/g) || []).length;
const isInsideSecret =
startCount > endCount &&
lastSecretStart > lastSecretEnd &&
lastSecretStart !== -1;
if (isInsideSecret) {
// We're inside a secret context, replace from after #!cxtower to cursor
range = {
start: {row: cursor.row, column: lastSecretStart + 16},
end: {row: cursor.row, column: cursor.column},
};
// Clamp range to valid bounds
range.start.column = Math.max(
0,
Math.min(range.start.column, lineLength)
);
range.end.column = Math.max(
range.start.column,
Math.min(range.end.column, lineLength)
);
insertText = `${command.reference}!#`;
} else {
// We're not in a secret context, insert complete secret
const triggerLength = this.currentTriggerLength || 0;
range = {
start: {row: cursor.row, column: cursor.column - triggerLength},
end: {row: cursor.row, column: cursor.column},
};
// Clamp range to valid bounds
range.start.column = Math.max(
0,
Math.min(range.start.column, lineLength)
);
range.end.column = Math.max(
range.start.column,
Math.min(range.end.column, lineLength)
);
insertText = `#!cxtower.secret.${command.reference}!#`;
}
} else {
// Handle variables insertion (existing logic)
const lastOpenBrace = textBeforeCursor.lastIndexOf("{{");
const lastCloseBrace = textBeforeCursor.lastIndexOf("}}");
const isInsideVariable =
lastOpenBrace > lastCloseBrace && lastOpenBrace !== -1;
if (isInsideVariable) {
// We're inside a variable context, replace from after {{ to cursor
range = {
start: {row: cursor.row, column: lastOpenBrace + 2},
end: {row: cursor.row, column: cursor.column},
};
// Clamp range to valid bounds
range.start.column = Math.max(
0,
Math.min(range.start.column, lineLength)
);
range.end.column = Math.max(
range.start.column,
Math.min(range.end.column, lineLength)
);
insertText = ` ${command.reference} `;
} else {
// We're not in a variable context, insert complete variable
const triggerLength = this.currentTriggerLength || 0;
range = {
start: {row: cursor.row, column: cursor.column - triggerLength},
end: {row: cursor.row, column: cursor.column},
};
// Clamp range to valid bounds
range.start.column = Math.max(
0,
Math.min(range.start.column, lineLength)
);
range.end.column = Math.max(
range.start.column,
Math.min(range.end.column, lineLength)
);
insertText = `{{ ${command.reference} }}`;
}
}
// Replace the text
session.replace(range, insertText);
// Get the updated line length after replacement
const updatedLineLength = session.getLine(cursor.row).length;
// Position cursor after the inserted text
const newCursor = {
row: cursor.row,
column: range.start.column + insertText.length,
};
newCursor.column = Math.max(0, Math.min(newCursor.column, updatedLineLength));
editor.moveCursorToPosition(newCursor);
this.hideAutocompletePopup();
editor.focus();
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t t-name="cetmix_tower_server.CodeEditorTower">
<div t-ref="editorRef" class="w-100" t-att-class="props.class" />
<t t-if="state.showPopup">
<AutocompletePopup
commands="state.popupItems"
position="state.popupPosition"
selectedIndex="state.selectedIndex"
type="state.popupType"
onSelectedIndexChange.bind="updateSelectedIndex"
onItemClick.bind="(command) => this.handleCommandSelection(command, this.currentEditor)"
/>
</t>
</t>
</templates>

View File

@@ -0,0 +1,34 @@
/** @odoo-module */
import {registry} from "@web/core/registry";
import {
StateSelectionField,
stateSelectionField,
} from "@web/views/fields/state_selection/state_selection_field";
import {STATUS_COLORS, STATUS_COLOR_PREFIX} from "../../utils/server_utils.esm";
export class ServerStatusField extends StateSelectionField {
/**
* @override
*/
setup() {
super.setup();
this.colorPrefix = STATUS_COLOR_PREFIX;
this.colors = STATUS_COLORS;
}
/**
* @override
*/
get options() {
return [[false, "Undefined"], ...super.options];
}
}
export const serverStatusField = {
...stateSelectionField,
component: ServerStatusField,
};
registry.category("fields").add("server_status", serverStatusField);

View File

@@ -0,0 +1,33 @@
.o_server_status_bubble {
@extend .o_status;
&.o_color_server_status_bubble_info {
background-color: $o-info;
}
&.o_color_server_status_bubble_success {
background-color: $o-success;
}
&.o_color_server_status_bubble_danger {
background-color: $o-danger;
}
&.o_color_server_status_bubble_warning {
background-color: $o-warning;
}
}
.o_field_server_status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
margin: 0px 16px;
border-radius: 5px;
border: 1px solid #e5e5e5;
width: fit-content !important;
.o_status_label {
color: #4c4c4c;
font-size: 14px;
margin-left: 0.5rem !important;
display: block;
}
}

View File

@@ -0,0 +1,17 @@
/** @odoo-module */
/**
* List of colors according to the selection value
*/
export const STATUS_COLORS = {
false: "info",
stopped: "danger",
starting: "warning",
running: "success",
stopping: "warning",
restarting: "warning",
delete_error: "danger",
};
export const STATUS_COLOR_PREFIX =
"o_server_status_bubble mx-0 o_color_server_status_bubble_";