From 4c8a8d4ac75341975e5848c87f11465c1db7a3e5 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Mon, 27 Oct 2025 13:23:29 -0500 Subject: [PATCH 1/2] 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 %} From 2845d340daef034421e55ac27b5e6e6867453a03 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Mon, 27 Oct 2025 15:57:07 -0500 Subject: [PATCH 2/2] Lots and lots of logic. --- crudkit/api/flask_api.py | 7 +- inventory/templates/settings.html | 254 +++++++++++++++++++++++++----- 2 files changed, 215 insertions(+), 46 deletions(-) diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 39e7c49..0f31264 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -60,11 +60,8 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r DELETE /api//delete?id=123[&hard=1] """ model_name = model.__name__.lower() - # bikeshed if you want pluralization; this is the least-annoying default - collection = (base_prefix or model_name).lower() - plural = collection if collection.endswith('s') else f"{collection}s" - bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}") + bp = Blueprint(model_name, __name__, url_prefix=f"/api/{model_name}") @bp.errorhandler(Exception) def _handle_any(e: Exception): @@ -105,7 +102,7 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r obj = service.create(payload) resp = jsonify(obj.as_dict()) resp.status_code = 201 - resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False) + resp.headers["Location"] = url_for(f"{bp.name}.rest_get", obj_id=obj.id, _external=False) return resp except Exception as e: return _json_error(e) diff --git a/inventory/templates/settings.html b/inventory/templates/settings.html index dfca924..7b12ffa 100644 --- a/inventory/templates/settings.html +++ b/inventory/templates/settings.html @@ -98,12 +98,12 @@ oninput="enableDTAddButton()"> + id="add-devicetype" onclick="addDTItem()" disabled>Add + id="edit-devicetype" onclick="editDTItem()">Edit + id="remove-devicetype" onclick="deleteDTItem()" disabled>Remove
{% for t in device_types %} @@ -111,7 +111,7 @@ class="d-flex justify-content-between align-items-center user-select-none dt-option"> {{ t['description'] }}
@@ -166,54 +166,226 @@ {% endblock %} {% block script %} - const brands = document.getElementById('brand'); - const dtlist = document.getElementById('device-type-list'); - const dtinput = document.getElementById('input-devicetype'); +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; +const height = getComputedStyle(brands).height; +dtlist.style.height = height; +dtlist.style.maxHeight = height; +dtlist.style.minHeight = height; - document.addEventListener('click', (ev) => { - const addButton = document.getElementById('add-devicetype'); - const editButton = document.getElementById('edit-devicetype'); - const deleteButton = document.getElementById('remove-devicetype'); +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; + 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; + // 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; + 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'); }); + // 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'); + // 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(); + // 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; + 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'); + } +}; + +window.addDTItem = async function addDTItem() { + const input = document.getElementById('input-devicetype'); + const list = document.getElementById('device-type-list'); + const addButton = document.getElementById('add-devicetype'); + const editButton = document.getElementById('edit-devicetype'); + + const value = (input.value || '').trim(); + if (!value) { + toastMessage('Type a device type first.', 'warning'); + return; + } + + addButton.disabled = true; + addButton.classList.add('disabled'); + + let res, data; + try { + const res = await fetch('{{ url_for("crudkit.devicetype.rest_create") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ description: value, target: 0 }) + }); + + const ct = res.headers.get('Content-Type') || ''; + if (ct.includes('application/json')) { + data = await res.json(); + } + + if (res.status !== 201) { + const msg = data?.error || `Create failed (${res.status})`; + toastMessage(msg, 'danger'); + return; + } + } catch (err) { + toastMessage('Network error creating device type.', 'danger'); + return; + } finally { + addButton.disabled = false; + addButton.classList.remove('disabled'); + } + + const id = data?.id ?? data?.obj?.id; + const description = String(data?.description ?? value); + + const row = document.createElement('div'); + row.id = `devicetype-option-${id}`; + row.dataset.invId = id; + row.className = 'd-flex justify-content-between align-items-center user-select-none dt-option'; + + const label = document.createElement('span'); + label.className = 'align-middle dt-label'; + label.textContent = description; + + const qty = document.createElement('input'); + qty.type = 'number'; + qty.min = '0'; + qty.max = '999'; + qty.value = '0'; + qty.id = `devicetype-target-${id}`; + qty.name = `devicetype-target-${id}`; + qty.className = 'form-control form-control-sm dt-target'; + + row.append(label, qty); + list.appendChild(row); + + list.querySelectorAll('.dt-option').forEach(n => { + n.classList.remove('selected', 'active'); + n.removeAttribute('aria-selected'); }); - window.enableDTAddButton = function enableDTAddButton() { - const addButton = document.getElementById('add-devicetype'); - if (addButton.classList.contains('d-none')) return; + input.value = ''; - addButton.disabled = dtinput.value === ''; - if (addButton.disabled) { - addButton.classList.add('disabled'); - } else { - addButton.classList.remove('disabled'); - } + row.scrollIntoView({ block: 'nearest' }); + + toastMessage(`Created new device type: ${description}`, 'success'); +}; + +window.editDTItem = async function editDTItem() { + const input = document.getElementById('input-devicetype'); + const addButton = document.getElementById('add-devicetype'); + const editButton = document.getElementById('edit-devicetype'); + const option = document.querySelector('.dt-option.selected'); + + const value = (input.value || option.dataset.invId).trim(); + if (!value) { + toastMessage('Type a device type first.', 'warning'); + return; } + + let res, data; + try { + const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ description: value }) + }); + + const ct = res.headers.get('Content-Type') || ''; + if (ct.includes('application/json')) { + data = await res.json(); + } + + if (res.status !== 200) { + const msg = data?.error || `Create failed (${res.status})`; + toastMessage(msg, 'danger'); + return; + } + } catch (err) { + toastMessage('Network error creating device type.', 'danger'); + return; + } finally { + editButton.disabled = true; + editButton.classList.add('disabled', 'd-none'); + addButton.classList.remove('d-none'); + input.value = ''; + } + + option.querySelector('.dt-label').textContent = value; + + toastMessage(`Updated device type: ${value}`, 'success'); +} + +window.deleteDTItem = async function deleteDTItem() { + const input = document.getElementById('input-devicetype'); + const addButton = document.getElementById('add-devicetype'); + const editButton = document.getElementById('edit-devicetype'); + const option = document.querySelector('.dt-option.selected'); + const deleteButton = document.getElementById('remove-devicetype'); + const value = (input.value || '').trim(); + + let res, data; + try { + const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, { + method: 'DELETE', + headers: { 'Accept': 'application/json' } + }); + + if (res.ok) { + option.remove(); + toastMessage(`Deleted ${value} successfully.`, 'success'); + editButton.disabled = true; + editButton.classList.add('disabled', 'd-none'); + deleteButton.disabled = true; + deleteButton.classList.add('disabled'); + addButton.classList.remove('d-none'); + input.value = ''; + return; + } + + let msg = 'Delete failed.'; + try { + const err = await res.json(); + msg = err?.error || msg; + } catch { + const txt = await res.text(); + if (txt) msg = txt; + } + toastMessage(msg, 'danger'); + } catch (e) { + toastMessage(`Delete failed: ${e?.message || e}`, 'danger'); + } +} {% endblock %}