From 4c8a8d4ac75341975e5848c87f11465c1db7a3e5 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Mon, 27 Oct 2025 13:23:29 -0500 Subject: [PATCH] Custom combobox for device types. --- inventory/models/device_type.py | 3 +- inventory/routes/search.py | 2 +- inventory/routes/settings.py | 3 +- inventory/templates/settings.html | 167 +++++++++++++++++++++++++++--- 4 files changed, 160 insertions(+), 15 deletions(-) diff --git a/inventory/models/device_type.py b/inventory/models/device_type.py index 58def72..9b7ff91 100644 --- a/inventory/models/device_type.py +++ b/inventory/models/device_type.py @@ -1,6 +1,6 @@ from typing import List, Optional -from sqlalchemy import Boolean, Unicode +from sqlalchemy import Boolean, Integer, Unicode from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import expression as sql @@ -10,6 +10,7 @@ class DeviceType(Base, CRUDMixin): __tablename__ = 'item' description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True) + target: Mapped[int] = mapped_column(Integer, nullable=True, default=0) inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type') diff --git a/inventory/routes/search.py b/inventory/routes/search.py index 249f262..7a605ae 100644 --- a/inventory/routes/search.py +++ b/inventory/routes/search.py @@ -32,7 +32,7 @@ def init_search_routes(app): {"field": "location.label", "label": "Location"}, ] inventory_results = inventory_service.list({ - 'notes|label|owner.label__icontains': q, + 'notes|label|model|owner.label__icontains': q, 'fields': [ "label", "name", diff --git a/inventory/routes/settings.py b/inventory/routes/settings.py index ccd2044..1128422 100644 --- a/inventory/routes/settings.py +++ b/inventory/routes/settings.py @@ -23,7 +23,7 @@ def init_settings_routes(app): status_service = crudkit.crud.get_service(status_model) brands = brand_service.list({"sort": "name", "limit": 0}) - device_types = device_type_service.list({"sort": "description", "limit": 0}) + device_types = device_type_service.list({"sort": "description", "limit": 0, "fields": ["description", "target"]}) areas = area_service.list({"sort": "name", "limit": 0}) functions = function_service.list({"sort": "description", "limit": 0}) rooms = room_service.list({ @@ -53,6 +53,7 @@ def init_settings_routes(app): ], }) statuses = render_table(statuses, opts={"object_class": 'status'}) + print([t.as_dict() for t in device_types]) return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms, statuses=statuses) diff --git a/inventory/templates/settings.html b/inventory/templates/settings.html index e1a6227..dfca924 100644 --- a/inventory/templates/settings.html +++ b/inventory/templates/settings.html @@ -2,7 +2,66 @@ {% from 'components/combobox.html' import combobox %} {% block styleincludes %} - + +{% endblock %} + +{% block style %} + .dt-target { + width: 6ch; + -moz-appearance: textfield; + appearance: textfield; + } + + /* keep the row highlight, but keep the input looking normal */ + .dt-option.selected .dt-target { + background-color: var(--bs-body-bg); + color: var(--bs-body-color); + } + + /* nuke the blue focus ring/border inside selected rows */ + .dt-option .dt-target:focus { + border-color: var(--bs-border-color); + box-shadow: none; + outline: 0; + } + + .dt-target::-webkit-outer-spin-button, + .dt-target::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + .dt-option { + cursor: default; + } + + /* Selection styling for the row */ + @supports (background-color: AccentColor) { + .dt-option.selected { background-color: AccentColor; } + .dt-option.selected .dt-label { color: AccentColorText; } + + /* Hard force the input to be opaque and not inherit weirdness */ + .dt-option .dt-target, + .dt-option .dt-target:focus { + background-color: Field !important; /* system input bg */ + color: FieldText !important; /* system input text */ + box-shadow: none; /* not the halo issue, but be thorough */ + border-color: var(--bs-border-color); /* keep Bootstrap-ish border */ + } + } + + @supports not (background-color: AccentColor) { + .dt-option.selected { background-color: var(--bs-list-group-active-bg, #0d6efd); } + .dt-option.selected .dt-label { color: var(--bs-list-group-active-color, #fff); } + + .dt-option .dt-target, + .dt-option .dt-target:focus { + background-color: var(--bs-body-bg) !important; + color: var(--bs-body-color) !important; + box-shadow: none; + border-color: var(--bs-border-color); + } + } {% endblock %} {% block main %} @@ -10,12 +69,12 @@
@@ -25,18 +84,48 @@
- {{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }} + {{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
- {{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }} + {# { combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', + 'description') } #} + {# Going to specialize the combobox widget here. #} +
+
+ + + + +
+
+ {% for t in device_types %} +
+ {{ t['description'] }} + +
+ {% endfor %} +
+
{{ statuses | safe }}
@@ -47,18 +136,19 @@
- {{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }} + {{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
- {{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }} + {{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
{{ rooms | safe }} @@ -72,5 +162,58 @@ {% endblock %} {% block scriptincludes %} - + +{% endblock %} + +{% block script %} + const brands = document.getElementById('brand'); + const dtlist = document.getElementById('device-type-list'); + const dtinput = document.getElementById('input-devicetype'); + + const height = getComputedStyle(brands).height; + dtlist.style.height = height; + dtlist.style.maxHeight = height; + + document.addEventListener('click', (ev) => { + const addButton = document.getElementById('add-devicetype'); + const editButton = document.getElementById('edit-devicetype'); + const deleteButton = document.getElementById('remove-devicetype'); + + if (!ev.target.closest('#device-type-list')) return; + + // Do not toggle selection when interacting with the input itself + if (ev.target.closest('.dt-target')) return; + + const node = ev.target.closest('.dt-option'); + if (!node) return; + + // clear others + document.querySelectorAll('.dt-option') + .forEach(n => { n.classList.remove('selected', 'active'); n.removeAttribute('aria-selected'); }); + + // select this one + node.classList.add('selected', 'active'); + node.setAttribute('aria-selected', 'true'); + + // set the visible input to the label, not the whole row + const label = node.querySelector('.dt-label'); + dtinput.value = (label ? label.textContent : node.textContent).replace(/\s+/g, ' ').trim(); + + addButton.classList.add('d-none'); + editButton.classList.remove('d-none'); + deleteButton.classList.remove('disabled'); + deleteButton.disabled = false; + }); + + window.enableDTAddButton = function enableDTAddButton() { + const addButton = document.getElementById('add-devicetype'); + if (addButton.classList.contains('d-none')) return; + + addButton.disabled = dtinput.value === ''; + if (addButton.disabled) { + addButton.classList.add('disabled'); + } else { + addButton.classList.remove('disabled'); + } + } {% endblock %}