Custom combobox for device types.

This commit is contained in:
Yaro Kasear 2025-10-27 13:23:29 -05:00
parent c20d085ab5
commit 4c8a8d4ac7
4 changed files with 160 additions and 15 deletions

View file

@ -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')

View file

@ -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",

View file

@ -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)

View file

@ -2,7 +2,66 @@
{% from 'components/combobox.html' import combobox %}
{% block styleincludes %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
{% 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 @@
<div class="container">
<ul class="nav nav-pills nav-fill">
<li class="nav-item">
<button type="button" class="nav-link active" id="device-tab"
data-bs-toggle="tab" data-bs-target="#device-tab-pane">Devices</button>
<button type="button" class="nav-link active" id="device-tab" data-bs-toggle="tab"
data-bs-target="#device-tab-pane">Devices</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" id="location-tab"
data-bs-toggle="tab" data-bs-target="#location-tab-pane">Locations</button>
<button type="button" class="nav-link" id="location-tab" data-bs-toggle="tab"
data-bs-target="#location-tab-pane">Locations</button>
</li>
</ul>
@ -29,14 +88,44 @@
</div>
<div class="col">
<label for="devicetype" class="form-label">Device Types</label>
{{ 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. #}
<div class="combobox">
<div class="d-flex">
<input type="text" class="form-control border-bottom-0 rounded-bottom-0 rounded-end-0"
placeholder="Enter the description of a device type." id="input-devicetype"
oninput="enableDTAddButton()">
<button type="button"
class="btn btn-primary border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 disabled"
id="add-devicetype" onclick="console.log('No implementation yet.')" disabled>Add</button>
<button type="button"
class="btn btn-info border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 d-none"
id="edit-devicetype" onclick="console.log('No implementation yet.')">Edit</button>
<button type="button" class="btn btn-danger border-bottom-0 rounded-bottom-0 rounded-start-0 disabled"
id="remove-devicetype" onclick="console.log('No implementation yet.')" disabled>Remove</button>
</div>
<div class="border h-100 ps-3 pe-0 overflow-auto" id="device-type-list">
{% for t in device_types %}
<div id="devicetype-option-{{ t['id'] }}" data-inv-id="{{ t['id'] }}"
class="d-flex justify-content-between align-items-center user-select-none dt-option">
<span class="align-middle dt-label">{{ t['description'] }}</span>
<input type="number"
class="form-control form-control-sm border border-top-0 {% if loop.index == device_types|length %}border-bottom-0{% endif %} rounded-0 dt-target"
id="devicetype-target-{{ t['id'] }}" name="devicetype-target-{{ t['id'] }}"
value="{{ t['target'] if t['target'] else 0 }}" min="0" max="999">
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col">
<label for="status" class="form-label">
Conditions
<a href="{{ url_for('entry.entry_new', model='status') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a>
<a href="{{ url_for('entry.entry_new', model='status') }}"
class="link-success link-underline-opacity-0"><small>[+]</small></a>
</label>
{{ statuses | safe }}
</div>
@ -58,7 +147,8 @@
<div class="row mt-3">
<label for="rooms" class="form-label">
Rooms
<a href="{{ url_for('entry.entry_new', model='room') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a>
<a href="{{ url_for('entry.entry_new', model='room') }}"
class="link-success link-underline-opacity-0"><small>[+]</small></a>
</label>
<div class="col">
{{ rooms | safe }}
@ -72,5 +162,58 @@
{% endblock %}
{% block scriptincludes %}
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
{% 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 %}