Skip to content

Commit

Permalink
feat: added custom autocomplete to upload inputs
Browse files Browse the repository at this point in the history
  • Loading branch information
jstucke committed Dec 10, 2024
1 parent 05db75e commit d42c5be
Show file tree
Hide file tree
Showing 6 changed files with 180 additions and 108 deletions.
8 changes: 5 additions & 3 deletions src/test/unit/web_interface/test_app_re_analyze.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from helperFunctions.data_conversion import make_bytes
from test.common_helper import TEST_FW

Expand All @@ -12,11 +14,11 @@ def test_app_re_analyze_get_valid_firmware(self, test_client):
assert b'<h3 class="mb-1">update analysis of</h3>' in rv.data
assert b'<h5 class="mb-3">TEST_FW_HID</h5>' in rv.data
assert (
b'value="default_plugin" checked' not in rv.data
re.search(rb'value="default_plugin"\s+checked>', rv.data) is None
), 'plugins that did not run for TEST_FW should not be checked'
assert b'value="mandatory_plugin"' not in rv.data, 'mandatory plugins should not be listed'
assert (
b'value="optional_plugin" checked' in rv.data
assert re.search(
rb'value="optional_plugin"\s+checked>', rv.data
), 'optional plugins that did run for TEST_FW should be checked'

def test_app_re_analyze_post_valid(self, test_client, intercom_task_list):
Expand Down
5 changes: 3 additions & 2 deletions src/test/unit/web_interface/test_app_upload.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import re
from io import BytesIO


class TestAppUpload:
def test_app_upload_get(self, test_client):
rv = test_client.get('/upload')
assert b'<h3 class="mb-3">Upload Firmware</h3>' in rv.data
assert b'value="default_plugin" checked' in rv.data
assert re.search(rb'value="default_plugin"\s+checked>', rv.data)
assert b'value="mandatory_plugin"' not in rv.data
assert b'value="optional_plugin" >' in rv.data
assert re.search(rb'value="optional_plugin"\s+>', rv.data)

def test_app_upload_invalid_firmware(self, test_client, intercom_task_list):
rv = test_client.post(
Expand Down
26 changes: 20 additions & 6 deletions src/web_interface/static/css/upload.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
.search {
text-align: right;
.upload-label {
justify-content: center;
width: 135px;
}
.upload-icon {
justify-content: center;
width: 45px;
}
.autocomplete-items {
position: absolute;
z-index: 99;
top: 100%;
left: 135px; /* upload-label width */
right: 35px; /* dropdown button width */
font-family: monospace;
}
.autocomplete-items li {
cursor: pointer;
padding: 5px 5px 5px 10px;
border: none;
}
.btn-file {
position: relative;
overflow: hidden;
}
151 changes: 117 additions & 34 deletions src/web_interface/static/js/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,30 @@ $(() => {
});
});

function add_device_class_options(selected_device_class, selected_vendor, data) {
const deviceClassButton = document.getElementById("device_class_select_button");
let deviceNameList = $('#device_name_list');
deviceNameList.empty();
if (data.hasOwnProperty(selected_device_class)) {
if (data[selected_device_class].hasOwnProperty(selected_vendor)) {
let device_classes = data[selected_device_class][selected_vendor];
// remove duplicates
device_classes = [...new Set(device_classes)];
device_classes.sort();
if (device_classes.length > 0) {
for (let index in device_classes) {
deviceNameList.append(`
<a class="dropdown-item" href="#" onClick="updateInput('device_name', this)">
${device_classes[index]}
</a>
`);
}
deviceClassButton.disabled = false;
function addDeviceNameOptions(selected_device_class, selected_vendor) {
const dropdownButton = document.getElementById("device_name-button");
if (allDeviceNames.hasOwnProperty(selected_device_class)) {
if (allDeviceNames[selected_device_class].hasOwnProperty(selected_vendor)) {
// update the global variable but without overwriting it
deviceNames.length = 0; // empty existing array
deviceNames.push(...new Set(allDeviceNames[selected_device_class][selected_vendor])); // remove duplicates
if (deviceNames.length > 0) {
deviceNames.sort();
dropdownButton.disabled = false;
return;
}
}
}
deviceClassButton.disabled = true;
dropdownButton.disabled = true;
}

function update_device_names() {
function updateDeviceNames() {
const deviceClassInput = document.getElementById("device_class");
const vendorInput = document.getElementById("vendor");
const vendor = vendorInput.value.trim();
const device_class = deviceClassInput.value.trim();
if (vendor.length > 0 && device_class.length > 0) {
add_device_class_options(device_class, vendor, device_names);
addDeviceNameOptions(device_class, vendor);
}
}

Expand All @@ -52,21 +43,113 @@ function change_selected_plugins(preset_name) {
}
}

function updateInput(input_id, element, do_update = false) {
const input = document.getElementById(input_id);
input.value = element.innerText;
if (do_update) {
update_device_names();
function autocompleteInput(inputId, options) {
const input = document.getElementById(inputId);
const dropdownButton = document.getElementById(`${inputId}-button`);
let currentFocus;

input.addEventListener("input", populateAutocomplete);
dropdownButton.addEventListener("click", () => {
input.value = "";
populateAutocomplete();
});

input.addEventListener("keydown", (event) => {
if (event.ctrlKey && event.code === "Space") {
populateAutocomplete();
return;
} else if (event.code === "Tab") {
closeAll();
return;
}
let list = document.getElementById(input.id + "-autocomplete-list");
if (!list) return;
let listElements = list.getElementsByTagName("li");
if (event.code === "ArrowDown") {
currentFocus++;
setActive(listElements);
} else if (event.code === "ArrowUp") {
currentFocus--;
setActive(listElements);
} else if (event.code === "Enter") {
event.preventDefault(); // prevent the form from being submitted
if (currentFocus > -1) {
listElements[currentFocus].click(); // simulate a click on the item
}
}
});

function populateAutocomplete() {
closeAll();
currentFocus = -1;
const list = document.createElement("ul");
list.setAttribute("id", input.id + "-autocomplete-list");
list.setAttribute("class", "autocomplete-items list-group border");
input.parentNode.appendChild(list);

let listItem, target;
options.forEach(option => {
let index = option.toLowerCase().indexOf(input.value.toLowerCase());
if (!input.value || index !== -1) {
listItem = document.createElement("li");
listItem.setAttribute("class", "list-group-item list-group-item-action");
listItem.innerHTML = (
option.slice(0, index) +
// display the matched part in bold (with the class click-through, the event.target in the
// eventListener below will not be the <strong/> element)
`<strong class="click-through">${option.slice(index, index + input.value.length)}</strong>` +
option.slice(index + input.value.length)
);
listItem.__value = option;
listItem.__inputId = inputId;
listItem.addEventListener("click", (event) => {
input.value = event.target.__value;
closeAll();
if (["device_class", "vendor"].includes(event.target.__inputId)) {
updateDeviceNames();
}
});
list.appendChild(listItem);
}
});
}

function setActive(elements) {
if (elements === null || elements.length === 0) return false;
Array.from(elements).forEach(element => {
element.classList.remove("active");
});
if (currentFocus >= elements.length) {
currentFocus = 0;
} else if (currentFocus < 0) {
currentFocus = (elements.length - 1);
}
elements[currentFocus].classList.add("active");
}
}

function filterFunction(input) {
let filter = input.value.toLowerCase();
input.parentElement.querySelectorAll(".dropdown-item").forEach(element => {
if (!element.innerText.toLowerCase().includes(filter)) {
element.style.display = "none";
} else {
element.style.display = "";
function closeAll(currentElement) {
if (currentElement && currentElement.id.endsWith("-button")) {
// if we clicked on the dropdown button we don't want to close the autocomplete list (we just opened)
currentElement = getAutocompleteElement(currentElement.parentNode.parentNode);
} else if (currentElement && currentElement.tagName.toLowerCase() === "input") {
// also don't close the autocomplete that belongs to an input we just clicked
currentElement = getAutocompleteElement(currentElement.parentNode);
}
const elements = document.getElementsByClassName("autocomplete-items");
Array.from(elements).forEach((element) => {
if (currentElement !== element) {
element.parentNode.removeChild(element);
}
});
}

function getAutocompleteElement(parentElement) {
const autocompletes = parentElement.querySelectorAll("ul");
if (autocompletes.length === 1) return autocompletes[0];
return null;
}

document.addEventListener("click", (event) => {
closeAll(event.target);
});
32 changes: 9 additions & 23 deletions src/web_interface/templates/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,38 +22,24 @@ <h5 class="card-title mb-3"><i class="fas fa-{{ icon }}"></i> {{ panel_title }}<
</tr>
{%- endmacro %}

{% macro upload_input(key, label) %}
{% macro upload_input(key, label, dropdown=False) %}
<div class="input-group mb-3">
<div class="input-group-prepend">
<label class="input-group-text upload-label" for="{{ label_target }}">
<label class="input-group-text upload-label" for="{{ key }}">
{{ label }}
</label>
</div>

{{ caller() }}
</div>
{%- endmacro %}

{% macro upload_dropdown(input_id, options, update_device_list=False, include_filter=False) %}
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="sr-only">Toggle Dropdown</span>
</button>
{% if include_filter %}
<div class="dropdown-menu" style="padding-top: 0;">
<input class="form-control-sm" type="text" placeholder="filter..."
onkeyup="filterFunction(this)">
{% else %}
<div class="dropdown-menu">
{% if dropdown %}
<div class="input-group-append">
<button type="button" id="{{ key }}-button" class="btn btn-outline-secondary"
tabindex="-1" {% if key == "device_name" %}disabled{% endif %}>
<i class="fas fa-caret-down click-through"></i>
</button>
</div>
{% endif %}
{% for option in options | sort %}
<a class="dropdown-item" href="#"
onclick="updateInput('{{ input_id }}', this, {{ update_device_list | tojson }})">
{{ option }}
</a>
{% endfor %}
</div>
</div>
{%- endmacro %}

Expand Down
Loading

0 comments on commit d42c5be

Please sign in to comment.