diff --git a/inventory/__init__.py b/inventory/__init__.py
index 7634e87..cb125ab 100644
--- a/inventory/__init__.py
+++ b/inventory/__init__.py
@@ -35,8 +35,10 @@ def create_app():
from .routes import main
from .routes.images import image_bp
+ from .ui.blueprint import bp as ui_bp
app.register_blueprint(main)
app.register_blueprint(image_bp)
+ app.register_blueprint(ui_bp)
from .routes.helpers import generate_breadcrumbs
@app.context_processor
diff --git a/inventory/models/areas.py b/inventory/models/areas.py
index 199dda4..dbf86b0 100644
--- a/inventory/models/areas.py
+++ b/inventory/models/areas.py
@@ -23,13 +23,13 @@ class Area(ValidatableMixin, db.Model):
def __repr__(self):
return f""
-
+
def serialize(self):
return {
'id': self.id,
'name': self.name
}
-
+
@classmethod
def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
"""
diff --git a/inventory/static/js/combobox.js b/inventory/static/js/combobox.js
index 814e796..f825b8b 100644
--- a/inventory/static/js/combobox.js
+++ b/inventory/static/js/combobox.js
@@ -127,3 +127,88 @@ const ComboBoxWidget = (() => {
createTempId
};
})();
+
+function ComboBox(cfg) {
+ return {
+ id: cfg.id,
+ createUrl: cfg.createUrl,
+ editUrl: cfg.editUrl,
+ deleteUrl: cfg.deleteUrl,
+ refreshUrl: cfg.refreshUrl,
+
+ query: '',
+ isEditing: false,
+ editingOption: null,
+
+ get hasSelection() { return this.$refs.list?.selectedOptions.length > 0 },
+
+ onListChange() {
+ const sel = this.$refs.list.selectedOptions;
+ if (sel.length === 1) {
+ this.query = sel[0].textContent.trim();
+ this.isEditing = true;
+ this.editingOption = sel[0];
+ } else {
+ this.cancelEdit();
+ }
+ },
+
+ cancelEdit() { this.isEditing = false; this.editingOption = null; },
+
+ async submitAddOrEdit() {
+ const name = (this.query || '').trim();
+ if (!name) return;
+
+ if (this.isEditing && this.editingOption && this.editUrl) {
+ // EDIT
+ const id = this.editingOption.value;
+ await this._post(this.editUrl, { id, name });
+ this.editingOption.textContent = name;
+ } else if (this.createUrl) {
+ // CREATE
+ const data = await this._post(this.createUrl, { name });
+ const id = (data && data.id) ? data.id : ('temp-' + Math.random().toString(36).slice(2));
+ const opt = document.createElement('option');
+ opt.value = id; opt.textContent = name;
+ this.$refs.list.appendChild(opt);
+ this._sortOptions();
+ }
+
+ this.query = '';
+ this.cancelEdit();
+ this._maybeRefresh();
+ },
+
+ async removeSelected() {
+ const opts = Array.from(this.$refs.list.selectedOptions);
+ if (!opts.length) return;
+
+ if (!confirm(`Delete ${opts.length} item(s)?`)) return;
+
+ const ids = opts.map(o => o.remove());
+
+ this.query = '';
+ this.cancelEdit();
+ this._maybeRefresh();
+ },
+
+ _sortOptions() {
+ const list = this.$refs.list;
+ const sorted = Array.from(list.options).sort((a, b) => a.text.localeCompare(b.text));
+ list.innerHTML = ''; sorted.forEach(o => list.appendChild(o));
+ },
+
+ _maybeRefresh() { if (this.refreshUrl) this.$dispatch('combobox:refresh'); },
+
+ async _post(url, payload) {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+ if (!res.ok) {
+ const msg = await res.text().catch(() => 'Error');
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/inventory/templates/fragments/_combobox_fragment.html b/inventory/templates/fragments/_combobox_fragment.html
index e13c21e..cc812ad 100644
--- a/inventory/templates/fragments/_combobox_fragment.html
+++ b/inventory/templates/fragments/_combobox_fragment.html
@@ -55,7 +55,7 @@
id: '{{ id }}',
createUrl: {{ create_url|tojson if create_url else 'null' }},
editUrl: {{ edit_url|tojson if edit_url else 'null' }},
- deleteUrl: {{ dekete_url|tojson if delete_url else 'null' }},
+ deleteUrl: {{ delete_url|tojson if delete_url else 'null' }},
refreshUrl: {{ refresh_url|tojson if refresh_url else 'null' }},
})"
hx-preserve
@@ -101,91 +101,4 @@
hx-swap="innerHTML">
{% endif %}
-
-
{% endmacro %}
diff --git a/inventory/ui/blueprint.py b/inventory/ui/blueprint.py
new file mode 100644
index 0000000..160f9a5
--- /dev/null
+++ b/inventory/ui/blueprint.py
@@ -0,0 +1,56 @@
+from flask import Blueprint, request, render_template, jsonify, abort
+from sqlalchemy.exc import IntegrityError
+
+from .defaults import (
+ default_query, default_create, default_update, default_delete, default_serialize
+)
+
+from .. import db
+
+bp = Blueprint("ui", __name__, url_prefix="/ui")
+
+def _normalize(s: str) -> str:
+ return s.replace("_", "").replace("-", "").lower()
+
+def get_model_class(model_name: str):
+ """Resolve a model class by name across SA/Flask-SA versions."""
+ target = _normalize(model_name)
+
+ # SA 2.x / Flask-SQLAlchemy 3.x path
+ registry = getattr(db.Model, "registry", None)
+ if registry and getattr(registry, "mappers", None):
+ for mapper in registry.mappers:
+ cls = mapper.class_
+ # match on class name w/ and w/o underscores
+ if _normalize(cls.__name__) == target or cls.__name__.lower() == model_name.lower():
+ return cls
+
+ # Legacy Flask-SQLAlchemy 2.x path (if someone runs old stack)
+ decl = getattr(db.Model, "_decl_class_registry", None)
+ if decl:
+ for cls in decl.values():
+ if isinstance(cls, type) and (
+ _normalize(cls.__name__) == target or cls.__name__.lower() == model_name.lower()
+ ):
+ return cls
+
+ abort(404, f"Unknown resource '{model_name}'")
+
+def call(Model, name, *args, **kwargs):
+ fn = getattr(Model, name, None)
+ return fn(*args, **kwargs) if callable(fn) else None
+
+@bp.get("//list")
+def list_items(model_name):
+ Model = get_model_class(model_name)
+ text = (request.args.get("q") or "").strip() or None
+ limit = min(int(request.args.get("limit", 100)), 500)
+ offset = int(request.args.get("offset", 0))
+ view = (request.args.get("view") or "option").strip()
+
+ rows = call(Model, "ui_query", db.session, text=text, limit=limit, offset=offset) \
+ or default_query(db.session, Model, text=text, limit=limit, offset=offset)
+
+ data = [ (call(Model, "ui_serialize", r, view=view) or default_serialize(Model, r, view=view))
+ for r in rows ]
+ return jsonify({"items": data})
diff --git a/inventory/ui/defaults.py b/inventory/ui/defaults.py
new file mode 100644
index 0000000..16d8d16
--- /dev/null
+++ b/inventory/ui/defaults.py
@@ -0,0 +1,97 @@
+from sqlalchemy import select, or_
+from sqlalchemy.inspection import inspect
+
+PREFERRED_LABELS = ("identifier", "name", "first_name", "last_name", "description")
+
+def _mapped_column(Model, attr):
+ """Return the mapped column attr on the class (InstrumentedAttribute) or None"""
+ mapper = inspect(Model)
+ if attr in mapper.columns.keys():
+ return getattr(Model, attr)
+ for prop in mapper.column_attrs:
+ if prop.key == attr:
+ return getattr(Model, prop.key)
+ return None
+
+def infer_label_attr(Model):
+ explicit = getattr(Model, 'ui_label_attr', None)
+ if explicit:
+ if _mapped_column(Model, explicit) is not None:
+ return explicit
+ raise RuntimeError(f"ui_label_attr '{explicit}' on {Model.__name__} is not a mapped column")
+
+ for a in PREFERRED_LABELS:
+ if _mapped_column(Model, a) is not None:
+ return a
+ raise RuntimeError(f"No label-like mapped column on {Model.__name__} (tried {PREFERRED_LABELS})")
+
+def default_query(session, Model, *, text=None, limit=100, offset=0, filters=None, order=None):
+ label_name = infer_label_attr(Model)
+ label_col = _mapped_column(Model, label_name) # guaranteed not None now
+
+ stmt = select(Model)
+
+ # Eager loads if class defines them (expects loader options like selectinload(...))
+ for opt in getattr(Model, "ui_eagerload", ()) or ():
+ stmt = stmt.options(opt)
+
+ # Text search across mapped columns only
+ if text:
+ cols = getattr(Model, "ui_search_cols", None) or (label_name,)
+ mapped = [ _mapped_column(Model, c) for c in cols ]
+ mapped = [ c for c in mapped if c is not None ]
+ if mapped:
+ stmt = stmt.where(or_(*[ c.ilike(f"%{text}%") for c in mapped ]))
+
+ # Filters (exact-match) across mapped columns only
+ if filters:
+ for k, v in filters.items():
+ if v is None:
+ continue
+ col = _mapped_column(Model, k)
+ if col is not None:
+ stmt = stmt.where(col == v)
+
+ # Order by mapped columns (fallback to label)
+ order_cols = order or getattr(Model, "ui_order_cols", None) or (label_name,)
+ for c in order_cols:
+ col = _mapped_column(Model, c)
+ if col is not None:
+ stmt = stmt.order_by(col)
+
+ stmt = stmt.limit(limit).offset(offset)
+ return session.execute(stmt).scalars().all()
+
+def default_create(session, Model, payload):
+ label = infer_label_attr(Model)
+ obj = Model(**{label: payload.get(label) or payload.get("name")})
+ session.add(obj)
+ session.commit()
+ return obj
+
+def default_update(session, Model, id_, payload):
+ obj = session.get(Model, id_)
+ if not obj:
+ return None
+ label = infer_label_attr(Model)
+ if (nv := payload.get(label) or payload.get("name")):
+ setattr(obj, label, nv)
+ session.commit()
+ return obj
+
+def default_delete(session, Model, ids):
+ count = 0
+ for i in ids:
+ obj = session.get(Model, i)
+ if obj:
+ session.delete(obj); count += 1
+ session.commit()
+ return count
+
+def default_serialize(Model, obj, *, view='option'):
+ label = infer_label_attr(Model)
+ data = {'id': obj.id, 'name': getattr(obj, label)}
+ for attr in getattr(Model, 'ui_extra_attrs', ()):
+ if hasattr(obj, attr):
+ data[attr] = getattr(obj, attr)
+ return data