Wipe addons/: full reset for clean re-upload
This commit is contained in:
@@ -1,507 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {AceField} from "@web/views/fields/ace/ace_field";
|
||||
import {AutocompletePopup} from "./autocomplete_popup.esm";
|
||||
import {registry} from "@web/core/registry";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import {useState} from "@odoo/owl";
|
||||
|
||||
const POPUP_FALLBACK_WIDTH = 500;
|
||||
const POPUP_FALLBACK_HEIGHT = 300;
|
||||
|
||||
class AceCommandField extends AceField {
|
||||
/**
|
||||
* Initialize the component with required services and properties
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.inputListener = null;
|
||||
this.clickOutsideListener = null;
|
||||
this.inputTimeout = null;
|
||||
this.variables = [];
|
||||
this.secrets = [];
|
||||
|
||||
// Use reactive state for properties that affect rendering
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load variables from the backend using ORM service
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadVariables() {
|
||||
try {
|
||||
this.variables = await this.orm.searchRead(
|
||||
"cx.tower.variable",
|
||||
[],
|
||||
["name", "reference"]
|
||||
);
|
||||
console.log(`Loaded ${this.variables.length} variables for autocomplete`);
|
||||
} 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"]
|
||||
);
|
||||
console.log(`Loaded ${this.secrets.length} secrets for autocomplete`);
|
||||
} 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up ACE editor with custom autocompletion
|
||||
*/
|
||||
setupAce() {
|
||||
super.setupAce();
|
||||
|
||||
if (this.aceEditor) {
|
||||
this.setupCustomAutocompletion();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure custom autocompletion commands and keyboard bindings for ACE editor
|
||||
*/
|
||||
setupCustomAutocompletion() {
|
||||
// Remove any existing conflicting commands first
|
||||
this.aceEditor.commands.removeCommand("startAutocomplete");
|
||||
this.aceEditor.commands.removeCommand("expandSnippet");
|
||||
|
||||
// Only add the main autocomplete trigger command
|
||||
this.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 = this.aceEditor.getCursorPosition();
|
||||
const session = this.aceEditor.getSession();
|
||||
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,
|
||||
};
|
||||
this.aceEditor.moveCursorToPosition(newCursor);
|
||||
this.showCustomCompletions(this.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,
|
||||
};
|
||||
this.aceEditor.moveCursorToPosition(newCursor);
|
||||
this.showCustomCompletions(this.aceEditor, "secrets");
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
this.aceEditor.on("input", 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();
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", this.clickOutsideListener, true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the autocomplete popup and clean up event listeners
|
||||
*/
|
||||
hideAutocompletePopup() {
|
||||
// Remove click outside listener
|
||||
if (this.clickOutsideListener) {
|
||||
document.removeEventListener("click", this.clickOutsideListener, true);
|
||||
this.clickOutsideListener = null;
|
||||
}
|
||||
|
||||
this.state.showPopup = false;
|
||||
this.state.popupVariables = [];
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources when component is destroyed
|
||||
*/
|
||||
destroy() {
|
||||
if (this.inputTimeout) {
|
||||
clearTimeout(this.inputTimeout);
|
||||
}
|
||||
if (this.aceEditor && this.inputListener) {
|
||||
this.aceEditor.off("input", this.inputListener);
|
||||
}
|
||||
this.hideAutocompletePopup();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
AceCommandField.template = "cetmix_tower_server.AceCommandField";
|
||||
AceCommandField.components = {
|
||||
AutocompletePopup,
|
||||
};
|
||||
|
||||
registry.category("fields").add("ace_tower", AceCommandField);
|
||||
|
||||
export {AceCommandField};
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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 #ced4da;
|
||||
border-radius: 4px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="cetmix_tower_server.AceCommandField" owl="1">
|
||||
<t t-call="web.AceField" />
|
||||
<t t-if="state.showPopup">
|
||||
<AutocompletePopup
|
||||
commands="state.popupItems"
|
||||
position="state.popupPosition"
|
||||
selectedIndex="state.selectedIndex"
|
||||
type="state.popupType"
|
||||
onSelectedIndexChange="updateSelectedIndex"
|
||||
onItemClick="(command) => this.handleCommandSelection(command, this.currentEditor)"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,317 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {Component, 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]
|
||||
);
|
||||
|
||||
// Cleanup effect to clear search timeout
|
||||
useEffect(
|
||||
() => {
|
||||
return () => {
|
||||
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) {
|
||||
this.props.onSelectedIndexChange(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Filter and score commands based on search relevance
|
||||
const scoredCommands = this.props.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) {
|
||||
this.props.onSelectedIndexChange(0);
|
||||
}
|
||||
}, 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 newIndex = Math.min(
|
||||
(this.props.selectedIndex || 0) + 1,
|
||||
this.filteredCommands.length - 1
|
||||
);
|
||||
if (this.props.onSelectedIndexChange) {
|
||||
this.props.onSelectedIndexChange(newIndex);
|
||||
}
|
||||
this.scrollToSelected();
|
||||
} else if (ev.key === "ArrowUp") {
|
||||
ev.preventDefault();
|
||||
const newIndex = Math.max((this.props.selectedIndex || 0) - 1, 0);
|
||||
if (this.props.onSelectedIndexChange) {
|
||||
this.props.onSelectedIndexChange(newIndex);
|
||||
}
|
||||
this.scrollToSelected();
|
||||
} else if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
const selectedCommand =
|
||||
this.filteredCommands[this.props.selectedIndex || 0];
|
||||
if (selectedCommand) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles global keydown events for the popup
|
||||
* @param {KeyboardEvent} ev - The keyboard event
|
||||
*/
|
||||
onKeyDown(ev) {
|
||||
// Handle search input from editor keyboard events
|
||||
if (ev.key.length === 1 && ev.key.match(/[a-zA-Z0-9_]/)) {
|
||||
// Add typed character to search
|
||||
this.updateSearchFromEditor(ev.key);
|
||||
} else if (ev.key === "Backspace") {
|
||||
// Remove last character from search
|
||||
this.updateSearchFromEditor("Backspace");
|
||||
} else {
|
||||
// Use common keyboard navigation logic
|
||||
this.handleKeyboardNavigation(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 || 0)
|
||||
? "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};
|
||||
@@ -1,190 +0,0 @@
|
||||
// Define z-index variable for better management
|
||||
$z-index-autocomplete: 1050; // Above dropdowns but below modals
|
||||
|
||||
.ace-autocomplete-popup {
|
||||
position: absolute; // Keep original positioning for cursor placement
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.ace-autocomplete-search {
|
||||
padding: 8px;
|
||||
padding-right: 48px; // Add right padding to avoid overlap with close button
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #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 #ddd;
|
||||
border-radius: 3px;
|
||||
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;
|
||||
|
||||
/* Standard scrollbar styling (Firefox 64+) */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #c1c1c1 #f1f1f1;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for webkit browsers */
|
||||
.ace-autocomplete-items::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.ace-autocomplete-items::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.ace-autocomplete-items::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ace-autocomplete-items::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
.ace-autocomplete-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
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: #f5f5f5;
|
||||
}
|
||||
|
||||
.ace-autocomplete-item-selected {
|
||||
background-color: #e6f3ff;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.ace-autocomplete-item-selected:hover {
|
||||
background-color: #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: #999;
|
||||
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: #666;
|
||||
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: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
// Mobile-friendly touch target
|
||||
@media (max-width: 768px) {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
font-size: 24px;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?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"
|
||||
>
|
||||
<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>
|
||||
@@ -1,33 +0,0 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {registry} from "@web/core/registry";
|
||||
import {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];
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get showLabel() {
|
||||
return !this.props.hideLabel;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("server_status", ServerStatusField);
|
||||
@@ -1,33 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user