Settings page mostly complete.
This commit is contained in:
parent
e84228161a
commit
ae54277e58
9 changed files with 406 additions and 174 deletions
|
|
@ -25,158 +25,141 @@ def _link_with_params(base_url: str, **params) -> str:
|
||||||
return f"{base_url}?{urlencode(q)}"
|
return f"{base_url}?{urlencode(q)}"
|
||||||
|
|
||||||
|
|
||||||
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None):
|
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True):
|
||||||
"""
|
"""
|
||||||
RPC-ish blueprint that exposes CRUDService methods 1:1:
|
REST:
|
||||||
|
GET /api/<models>/ -> list (filters via ?q=..., sort=..., limit=..., cursor=...)
|
||||||
|
GET /api/<models>/<id> -> get
|
||||||
|
POST /api/<models>/ -> create
|
||||||
|
PATCH /api/<models>/<id> -> update (partial)
|
||||||
|
DELETE /api/<models>/<id>[?hard=1] -> delete
|
||||||
|
|
||||||
GET /api/<model>/get?id=123&... -> service.get()
|
RPC (legacy):
|
||||||
GET /api/<model>/list?... -> service.list()
|
GET /api/<model>/get?id=123
|
||||||
GET /api/<model>/seek_window?... -> service.seek_window()
|
GET /api/<model>/list
|
||||||
GET /api/<model>/page?page=2&per_page=50&... -> service.page()
|
GET /api/<model>/seek_window
|
||||||
|
GET /api/<model>/page
|
||||||
POST /api/<model>/create -> service.create(payload)
|
POST /api/<model>/create
|
||||||
|
PATCH /api/<model>/update?id=123
|
||||||
PATCH /api/<model>/update?id=123 -> service.update(id, payload)
|
DELETE /api/<model>/delete?id=123[&hard=1]
|
||||||
|
|
||||||
DELETE /api/<model>/delete?id=123[&hard=1] -> service.delete(id, hard)
|
|
||||||
|
|
||||||
Query params for filters/sorts/fields/includes all still pass straight through.
|
|
||||||
Cursor behavior for seek_window is preserved, with Link headers.
|
|
||||||
"""
|
"""
|
||||||
name = (model.__name__ if base_prefix is None else base_prefix).lower()
|
model_name = model.__name__.lower()
|
||||||
bp = Blueprint(name, __name__, url_prefix=f"/api/{name}")
|
# 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"
|
||||||
|
|
||||||
# -------- READS --------
|
bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}")
|
||||||
|
|
||||||
@bp.get("/get")
|
# ---------- REST ----------
|
||||||
def rpc_get():
|
if rest:
|
||||||
id_ = _safe_int(request.args.get("id"), 0)
|
@bp.get("/")
|
||||||
if not id_:
|
def rest_list():
|
||||||
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
args = request.args.to_dict(flat=True)
|
||||||
try:
|
# support cursor pagination transparently; fall back to limit/offset
|
||||||
item = service.get(id_, request.args)
|
|
||||||
if item is None:
|
|
||||||
abort(404)
|
|
||||||
return jsonify(item.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
|
||||||
|
|
||||||
@bp.get("/list")
|
|
||||||
def rpc_list():
|
|
||||||
# Keep legacy limit/offset behavior. Everything else passes through.
|
|
||||||
args = request.args.to_dict(flat=True)
|
|
||||||
# If the caller provides offset or page, honor normal list() pagination rules.
|
|
||||||
legacy_offset = ("offset" in args) or ("page" in args)
|
|
||||||
if not legacy_offset:
|
|
||||||
# We still allow limit to cap the result set if provided.
|
|
||||||
limit = _safe_int(args.get("limit"), 50)
|
|
||||||
args["limit"] = limit
|
|
||||||
try:
|
|
||||||
items = service.list(args)
|
|
||||||
return jsonify([obj.as_dict() for obj in items])
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
|
||||||
|
|
||||||
@bp.get("/seek_window")
|
|
||||||
def rpc_seek_window():
|
|
||||||
args = request.args.to_dict(flat=True)
|
|
||||||
|
|
||||||
# Keep keyset & cursor mechanics intact
|
|
||||||
cursor_token = args.get("cursor")
|
|
||||||
key, desc_from_cursor, backward_from_cursor = decode_cursor(cursor_token)
|
|
||||||
|
|
||||||
backward = _bool_param(args, "backward", backward_from_cursor if backward_from_cursor is not None else False)
|
|
||||||
include_total = _bool_param(args, "include_total", True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
window = service.seek_window(
|
|
||||||
args,
|
|
||||||
key=key,
|
|
||||||
backward=backward,
|
|
||||||
include_total=include_total,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
desc_flags = list(window.order.desc)
|
items = service.list(args)
|
||||||
except Exception:
|
return jsonify([o.as_dict() for o in items])
|
||||||
desc_flags = desc_from_cursor or []
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
body = {
|
@bp.get("/<int:obj_id>")
|
||||||
"items": [obj.as_dict() for obj in window.items],
|
def rest_get(obj_id: int):
|
||||||
"limit": window.limit,
|
try:
|
||||||
"next_cursor": encode_cursor(window.last_key, desc_flags, backward=False),
|
item = service.get(obj_id, request.args)
|
||||||
"prev_cursor": encode_cursor(window.first_key, desc_flags, backward=True),
|
if item is None:
|
||||||
"total": window.total,
|
abort(404)
|
||||||
}
|
return jsonify(item.as_dict())
|
||||||
resp = jsonify(body)
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
# Build Link headers preserving all non-cursor args
|
@bp.post("/")
|
||||||
base_url = request.base_url
|
def rest_create():
|
||||||
base_params = {k: v for k, v in args.items() if k not in {"cursor"}}
|
payload = request.get_json(silent=True) or {}
|
||||||
link_parts = []
|
try:
|
||||||
if body["next_cursor"]:
|
obj = service.create(payload)
|
||||||
link_parts.append(f'<{_link_with_params(base_url, **base_params, cursor=body["next_cursor"])}>; rel="next"')
|
resp = jsonify(obj.as_dict())
|
||||||
if body["prev_cursor"]:
|
resp.status_code = 201
|
||||||
link_parts.append(f'<{_link_with_params(base_url, **base_params, cursor=body["prev_cursor"])}>; rel="prev"')
|
resp.headers["Location"] = f"{request.base_url.rstrip('/')}/{obj.id}"
|
||||||
if link_parts:
|
return resp
|
||||||
resp.headers["Link"] = ", ".join(link_parts)
|
except Exception as e:
|
||||||
return resp
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
|
||||||
|
|
||||||
@bp.get("/page")
|
@bp.patch("/<int:obj_id>")
|
||||||
def rpc_page():
|
def rest_update(obj_id: int):
|
||||||
args = request.args.to_dict(flat=True)
|
payload = request.get_json(silent=True) or {}
|
||||||
page = _safe_int(args.get("page"), 1)
|
try:
|
||||||
per_page = _safe_int(args.get("per_page"), 50)
|
obj = service.update(obj_id, payload)
|
||||||
include_total = _bool_param(args, "include_total", True)
|
return jsonify(obj.as_dict())
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
try:
|
@bp.delete("/<int:obj_id>")
|
||||||
result = service.page(args, page=page, per_page=per_page, include_total=include_total)
|
def rest_delete(obj_id: int):
|
||||||
# Already includes: items, page, per_page, total, pages, order
|
hard = (request.args.get("hard") in ("1", "true", "yes"))
|
||||||
# Items come back as model instances; serialize to dicts
|
try:
|
||||||
result = {
|
obj = service.delete(obj_id, hard=hard)
|
||||||
**result,
|
if obj is None:
|
||||||
"items": [obj.as_dict() for obj in result["items"]],
|
abort(404)
|
||||||
}
|
return ("", 204)
|
||||||
return jsonify(result)
|
except Exception as e:
|
||||||
except Exception as e:
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
|
||||||
|
|
||||||
# -------- WRITES --------
|
# ---------- RPC (your existing routes) ----------
|
||||||
|
if rpc:
|
||||||
|
# your original functions verbatim, shortened here for sanity
|
||||||
|
@bp.get("/get")
|
||||||
|
def rpc_get():
|
||||||
|
id_ = int(request.args.get("id", 0))
|
||||||
|
if not id_:
|
||||||
|
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
||||||
|
try:
|
||||||
|
item = service.get(id_, request.args)
|
||||||
|
if item is None:
|
||||||
|
abort(404)
|
||||||
|
return jsonify(item.as_dict())
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
@bp.post("/create")
|
@bp.get("/list")
|
||||||
def rpc_create():
|
def rpc_list():
|
||||||
payload = request.get_json(silent=True) or {}
|
args = request.args.to_dict(flat=True)
|
||||||
try:
|
try:
|
||||||
obj = service.create(payload)
|
items = service.list(args)
|
||||||
return jsonify(obj.as_dict()), 201
|
return jsonify([obj.as_dict() for obj in items])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
@bp.patch("/update")
|
@bp.post("/create")
|
||||||
def rpc_update():
|
def rpc_create():
|
||||||
id_ = _safe_int(request.args.get("id"), 0)
|
payload = request.get_json(silent=True) or {}
|
||||||
if not id_:
|
try:
|
||||||
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
obj = service.create(payload)
|
||||||
payload = request.get_json(silent=True) or {}
|
return jsonify(obj.as_dict()), 201
|
||||||
try:
|
except Exception as e:
|
||||||
obj = service.update(id_, payload)
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
return jsonify(obj.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
# If you ever decide to throw custom exceptions, map them here like an adult.
|
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
|
||||||
|
|
||||||
@bp.delete("/delete")
|
@bp.patch("/update")
|
||||||
def rpc_delete():
|
def rpc_update():
|
||||||
id_ = _safe_int(request.args.get("id"), 0)
|
id_ = int(request.args.get("id", 0))
|
||||||
if not id_:
|
if not id_:
|
||||||
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
||||||
hard = _bool_param(request.args, "hard", False)
|
payload = request.get_json(silent=True) or {}
|
||||||
try:
|
try:
|
||||||
obj = service.delete(id_, hard=hard)
|
obj = service.update(id_, payload)
|
||||||
# 204 if actually deleted or soft-deleted; return body if you feel chatty
|
return jsonify(obj.as_dict())
|
||||||
return ("", 204) if obj is not None else abort(404)
|
except Exception as e:
|
||||||
except Exception as e:
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
|
||||||
|
@bp.delete("/delete")
|
||||||
|
def rpc_delete():
|
||||||
|
id_ = int(request.args.get("id", 0))
|
||||||
|
if not id_:
|
||||||
|
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
||||||
|
hard = (request.args.get("hard") in ("1", "true", "yes"))
|
||||||
|
try:
|
||||||
|
obj = service.delete(id_, hard=hard)
|
||||||
|
return ("", 204) if obj is not None else abort(404)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ from crudkit.core import normalize_payload
|
||||||
|
|
||||||
bp_entry = Blueprint("entry", __name__)
|
bp_entry = Blueprint("entry", __name__)
|
||||||
|
|
||||||
|
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room"]
|
||||||
|
|
||||||
def _fields_for_model(model: str):
|
def _fields_for_model(model: str):
|
||||||
fields: list[str] = []
|
fields: list[str] = []
|
||||||
fields_spec = []
|
fields_spec = []
|
||||||
|
|
@ -158,7 +160,29 @@ def _fields_for_model(model: str):
|
||||||
{"name": "ownership", "order": 10, "attrs": {"class": "row mb-2"}},
|
{"name": "ownership", "order": 10, "attrs": {"class": "row mb-2"}},
|
||||||
{"name": "timestamps", "order": 20, "attrs": {"class": "row d-flex align-items-center"}},
|
{"name": "timestamps", "order": 20, "attrs": {"class": "row d-flex align-items-center"}},
|
||||||
{"name": "updates", "order": 30, "attrs": {"class": "row"}},
|
{"name": "updates", "order": 30, "attrs": {"class": "row"}},
|
||||||
{"name": "buttons"},
|
]
|
||||||
|
elif model == "room":
|
||||||
|
fields = [
|
||||||
|
"label",
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
fields_spec = [
|
||||||
|
{"name": "label", "label": "", "type": "display", "attrs": {"class": "display-6 mb-3"},
|
||||||
|
"row": "label", "wrap": {"class": "col"}},
|
||||||
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||||
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||||
|
{"name": "name", "label": "Name", "row": "name", "attrs": {"class": "form-control"},
|
||||||
|
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col mb-3"}},
|
||||||
|
{"name": "area", "label": "Area", "row": "details", "attrs": {"class": "form-control"},
|
||||||
|
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col"}, "label_spec": "{name}"},
|
||||||
|
{"name": "room_function", "label": "Description", "label_spec": "{description}",
|
||||||
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, "row": "details",
|
||||||
|
"wrap": {"class": "col"}},
|
||||||
|
]
|
||||||
|
layout = [
|
||||||
|
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
||||||
|
{"name": "name", "order": 10, "attrs": {"class": "row"}},
|
||||||
|
{"name": "details", "order": 20, "attrs": {"class": "row"}},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (fields, fields_spec, layout)
|
return (fields, fields_spec, layout)
|
||||||
|
|
@ -192,7 +216,7 @@ def init_entry_routes(app):
|
||||||
@bp_entry.get("/entry/<model>/<int:id>")
|
@bp_entry.get("/entry/<model>/<int:id>")
|
||||||
def entry(model: str, id: int):
|
def entry(model: str, id: int):
|
||||||
cls = crudkit.crud.get_model(model)
|
cls = crudkit.crud.get_model(model)
|
||||||
if cls is None or model not in ["inventory", "worklog", "user"]:
|
if cls is None or model not in ENTRY_WHITELIST:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
fields, fields_spec, layout = _fields_for_model(model)
|
fields, fields_spec, layout = _fields_for_model(model)
|
||||||
|
|
@ -236,7 +260,7 @@ def init_entry_routes(app):
|
||||||
@bp_entry.get("/entry/<model>/new")
|
@bp_entry.get("/entry/<model>/new")
|
||||||
def entry_new(model: str):
|
def entry_new(model: str):
|
||||||
cls = crudkit.crud.get_model(model)
|
cls = crudkit.crud.get_model(model)
|
||||||
if cls is None or model not in ["inventory", "worklog", "user"]:
|
if cls is None or model not in ENTRY_WHITELIST:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
fields, fields_spec, layout = _fields_for_model(model)
|
fields, fields_spec, layout = _fields_for_model(model)
|
||||||
|
|
@ -276,7 +300,7 @@ def init_entry_routes(app):
|
||||||
@bp_entry.post("/entry/<model>")
|
@bp_entry.post("/entry/<model>")
|
||||||
def create_entry(model: str):
|
def create_entry(model: str):
|
||||||
try:
|
try:
|
||||||
if model not in ["inventory", "user", "worklog"]:
|
if model not in ENTRY_WHITELIST:
|
||||||
raise TypeError("Invalid model.")
|
raise TypeError("Invalid model.")
|
||||||
cls = crudkit.crud.get_model(model)
|
cls = crudkit.crud.get_model(model)
|
||||||
svc = crudkit.crud.get_service(cls)
|
svc = crudkit.crud.get_service(cls)
|
||||||
|
|
@ -328,7 +352,7 @@ def init_entry_routes(app):
|
||||||
@bp_entry.post("/entry/<model>/<int:id>")
|
@bp_entry.post("/entry/<model>/<int:id>")
|
||||||
def update_entry(model, id):
|
def update_entry(model, id):
|
||||||
try:
|
try:
|
||||||
if model not in ["inventory", "user", "worklog"]:
|
if model not in ENTRY_WHITELIST:
|
||||||
raise TypeError("Invalid model.")
|
raise TypeError("Invalid model.")
|
||||||
cls = crudkit.crud.get_model(model)
|
cls = crudkit.crud.get_model(model)
|
||||||
payload = normalize_payload(request.get_json(), cls)
|
payload = normalize_payload(request.get_json(), cls)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from flask import Blueprint, render_template
|
||||||
|
|
||||||
import crudkit
|
import crudkit
|
||||||
|
|
||||||
from crudkit.ui.fragments import render_form
|
from crudkit.ui.fragments import render_table
|
||||||
|
|
||||||
bp_settings = Blueprint("settings", __name__)
|
bp_settings = Blueprint("settings", __name__)
|
||||||
|
|
||||||
|
|
@ -13,10 +13,35 @@ def init_settings_routes(app):
|
||||||
brand_service = crudkit.crud.get_service(brand_model)
|
brand_service = crudkit.crud.get_service(brand_model)
|
||||||
device_type_model = crudkit.crud.get_model('devicetype')
|
device_type_model = crudkit.crud.get_model('devicetype')
|
||||||
device_type_service = crudkit.crud.get_service(device_type_model)
|
device_type_service = crudkit.crud.get_service(device_type_model)
|
||||||
|
area_model = crudkit.crud.get_model('area')
|
||||||
|
area_service = crudkit.crud.get_service(area_model)
|
||||||
|
function_model = crudkit.crud.get_model('roomfunction')
|
||||||
|
function_service = crudkit.crud.get_service(function_model)
|
||||||
|
room_model = crudkit.crud.get_model('room')
|
||||||
|
room_service = crudkit.crud.get_service(room_model)
|
||||||
|
|
||||||
brands = brand_service.list({"sort": "name", "limit": 0})
|
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})
|
||||||
|
areas = area_service.list({"sort": "name", "limit": 0})
|
||||||
|
functions = function_service.list({"sort": "description", "limit": 0})
|
||||||
|
rooms = room_service.list({
|
||||||
|
"sort": "name",
|
||||||
|
"limit": 0,
|
||||||
|
"fields": [
|
||||||
|
"name",
|
||||||
|
"area.name",
|
||||||
|
"room_function.description"
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
return render_template("settings.html", brands=brands, device_types=device_types)
|
rooms = render_table(rooms,
|
||||||
|
[
|
||||||
|
{"field": "name"},
|
||||||
|
{"field": "area.name", "label": "Area"},
|
||||||
|
{"field": "room_function.description", "label": "Description"},
|
||||||
|
],
|
||||||
|
opts={"object_class": 'room'})
|
||||||
|
|
||||||
|
return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms)
|
||||||
|
|
||||||
app.register_blueprint(bp_settings)
|
app.register_blueprint(bp_settings)
|
||||||
|
|
|
||||||
6
inventory/static/css/components/combobox.css
Normal file
6
inventory/static/css/components/combobox.css
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.combobox input:focus, select:focus {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-color: #dee2e6 !important;
|
||||||
|
background-color: inherit !important;
|
||||||
|
}
|
||||||
143
inventory/static/js/components/combobox.js
Normal file
143
inventory/static/js/components/combobox.js
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
const ComboBox = globalThis.ComboBox ?? (globalThis.ComboBox = {});
|
||||||
|
|
||||||
|
ComboBox.utilities = {
|
||||||
|
changeAdd(id) {
|
||||||
|
const input = document.getElementById(`input-${id}`);
|
||||||
|
const add = document.getElementById(`add-${id}`);
|
||||||
|
|
||||||
|
if (input.value === '') {
|
||||||
|
add.disabled = true;
|
||||||
|
add.classList.add('disabled')
|
||||||
|
} else {
|
||||||
|
add.disabled = false;
|
||||||
|
add.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSelect(id) {
|
||||||
|
const list = document.getElementById(id);
|
||||||
|
const remove = document.getElementById(`remove-${id}`);
|
||||||
|
const add = document.getElementById(`add-${id}`);
|
||||||
|
const edit = document.getElementById(`edit-${id}`);
|
||||||
|
const input = document.getElementById(`input-${id}`);
|
||||||
|
const selected = list.selectedOptions?.[0];
|
||||||
|
|
||||||
|
if (list.value === '') {
|
||||||
|
remove.disabled = true;
|
||||||
|
remove.classList.add('disabled');
|
||||||
|
add.disabled = false;
|
||||||
|
add.classList.remove('d-none', 'disabled');
|
||||||
|
edit.disabled = true;
|
||||||
|
edit.classList.add('d-none', 'disabled');
|
||||||
|
} else {
|
||||||
|
remove.disabled = false;
|
||||||
|
remove.classList.remove('disabled');
|
||||||
|
add.disabled = true;
|
||||||
|
add.classList.add('d-none', 'disabled');
|
||||||
|
edit.disabled = false;
|
||||||
|
edit.classList.remove('d-none', 'disabled');
|
||||||
|
|
||||||
|
input.value = selected ? selected.text : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addItem(id, labelAttr) {
|
||||||
|
const input = document.getElementById(`input-${id}`);
|
||||||
|
const val = input.value.trim();
|
||||||
|
|
||||||
|
const res = await fetch(`/api/${id}/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ [labelAttr]: val })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
toastMessage(data?.error || 'Create failed.', 'danger');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
toastMessage(`Created new ${id}: ${val}`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.getElementById(id);
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = data.id;
|
||||||
|
opt.textContent = data[labelAttr] ?? val;
|
||||||
|
list.appendChild(opt);
|
||||||
|
input.value = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeItem(id) {
|
||||||
|
const list = document.getElementById(id);
|
||||||
|
const selected = list?.selectedOptions?.[0];
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/${id}/${encodeURIComponent(selected.value)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
selected.remove();
|
||||||
|
toastMessage(`Deleted ${id} successfully.`, 'success');
|
||||||
|
this.changeRemove(id);
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async editItem(id, labelAttr) {
|
||||||
|
const input = document.getElementById(`input-${id}`);
|
||||||
|
const list = document.getElementById(id);
|
||||||
|
const opt = list?.selectedOptions?.[0];
|
||||||
|
if (!opt) return;
|
||||||
|
|
||||||
|
const val = opt.value; // id of the row
|
||||||
|
const newText = (input?.value ?? '').trim();
|
||||||
|
if (!newText) return;
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/${id}/${encodeURIComponent(val)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ [labelAttr]: newText })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
try { data = await res.json(); } catch { data = null; }
|
||||||
|
const updatedLabel = data?.[labelAttr] ?? newText;
|
||||||
|
opt.textContent = updatedLabel;
|
||||||
|
|
||||||
|
if (input) input.value = '';
|
||||||
|
list.selectedIndex = -1;
|
||||||
|
toastMessage(`Updated ${id} successfully.`, 'success');
|
||||||
|
ComboBox.utilities.handleSelect(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// not ok -> try to show the server error
|
||||||
|
try { data = await res.json(); } catch { data = { error: await res.text() }; }
|
||||||
|
toastMessage(`Edit failed: ${data?.error || `HTTP ${res.status}`}`, 'danger');
|
||||||
|
} catch (e) {
|
||||||
|
toastMessage(`Edit failed: ${e?.message || e}`, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
<title>{% block title %}{{ title if title else "Inventory Manager" }}{% endblock %}</title>
|
<title>{% block title %}{{ title if title else "Inventory Manager" }}{% endblock %}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||||
|
{% block styleincludes %}
|
||||||
|
{% endblock %}
|
||||||
<style>
|
<style>
|
||||||
{% block style %}
|
{% block style %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
33
inventory/templates/components/combobox.html
Normal file
33
inventory/templates/components/combobox.html
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{# templates/components/combobox.html #}
|
||||||
|
|
||||||
|
{% macro combobox(id, name, placeholder, items, value_attr='id', label_attr='name',
|
||||||
|
add_disabled=True, remove_disabled=True, edit_disabled=True) %}
|
||||||
|
<div class="col combobox">
|
||||||
|
<div class="d-flex">
|
||||||
|
<input type="text" class="form-control border-bottom-0 rounded-bottom-0 rounded-end-0"
|
||||||
|
placeholder="{{ placeholder }}" id="input-{{id}}" oninput="ComboBox.utilities.changeAdd('{{ id }}');">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-primary {{ 'disabled' if add_disabled else '' }} border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0"
|
||||||
|
{{ 'disabled' if add_disabled else '' }} id="add-{{ id }}"
|
||||||
|
onclick="ComboBox.utilities.addItem('{{ id }}', '{{ label_attr }}')">Add</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-info {{ 'disabled' if edit_disabled else '' }} border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 d-none"
|
||||||
|
{{ 'disabled' if edit_disabled else '' }} id="edit-{{ id }}"
|
||||||
|
onclick="ComboBox.utilities.editItem('{{ id }}', '{{ label_attr }}')">Edit</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-danger {{ 'disabled' if remove_disabled else '' }} border-bottom-0 rounded-bottom-0 rounded-start-0"
|
||||||
|
{{ 'disabled' if remove_disabled else '' }} id="remove-{{ id }}"
|
||||||
|
onclick="ComboBox.utilities.removeItem('{{ id }}')">Remove</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Use form-select in BS5. Support dicts or model objects. #}
|
||||||
|
<select name="{{ name }}" id="{{ id }}" size="10" class="form-select rounded-top-0"
|
||||||
|
onchange="ComboBox.utilities.handleSelect('{{ id }}')">
|
||||||
|
{% for item in items %}
|
||||||
|
{% set value = (item | attr(value_attr)) if item is not mapping else item[value_attr] %}
|
||||||
|
{% set label = (item | attr(label_attr)) if item is not mapping else item[label_attr] %}
|
||||||
|
<option value="{{ value }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{# show label unless hidden/custom #}
|
{# show label unless hidden/custom #}
|
||||||
<!-- Overridden by inventory application -->
|
|
||||||
{% if field_type != 'hidden' and field_label %}
|
{% if field_type != 'hidden' and field_label %}
|
||||||
<label for="{{ field_name }}"
|
<label for="{{ field_name }}"
|
||||||
{% if label_attrs %}{% for k,v in label_attrs.items() %}
|
{% if label_attrs %}{% for k,v in label_attrs.items() %}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,51 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% from 'components/combobox.html' import combobox %}
|
||||||
|
|
||||||
|
{% block styleincludes %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<form id="settings_form" action="POST">
|
<form id="settings_form" method="post">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<ul class="nav nav-pills nav-fill">
|
<ul class="nav nav-pills nav-fill">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active">Devices</a>
|
<button type="button" class="nav-link active" id="device-tab"
|
||||||
</li>
|
data-bs-toggle="tab" data-bs-target="#device-tab-pane">Devices</button>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a class="nav-link">Locations</a>
|
<li class="nav-item">
|
||||||
</li>
|
<button type="button" class="nav-link" id="location-tab"
|
||||||
</ul>
|
data-bs-toggle="tab" data-bs-target="#location-tab-pane">Locations</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content mt-3" id="tab-settings">
|
||||||
|
|
||||||
|
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
|
||||||
<input type="text" class="form-control border-bottom-0 rounded-bottom-0" placeholder="Enter the name of a brand.">
|
{{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
|
||||||
<select name="brands" id="brands" size="10" class="form-control rounded-top-0">
|
|
||||||
{% for brand in brands %}
|
|
||||||
<option value="{{ brand.id }}">{{ brand.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<input type="text" class="form-control border-bottom-0 rounded-bottom-0" placeholder="Enter the description of a device type.">
|
|
||||||
<select name="device_types" id="device_types" size="10" class="form-control rounded-top-0">
|
|
||||||
{% for device_type in device_types %}
|
|
||||||
<option value="{{ device_type.id }}">{{ device_type.description }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="location-tab-pane" tabindex="0">
|
||||||
|
<div class="row">
|
||||||
|
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
|
||||||
|
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col">
|
||||||
|
{{ rooms | safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scriptincludes %}
|
||||||
|
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue