Generic CRUD works now!

This commit is contained in:
Yaro Kasear 2025-08-13 13:48:11 -05:00
parent 462b86c583
commit e149a8d117
4 changed files with 97 additions and 22 deletions

View file

@ -140,10 +140,16 @@ function ComboBox(cfg) {
isEditing: false, isEditing: false,
editingOption: null, editingOption: null,
get hasSelection() { return this.$refs.list?.selectedOptions.length > 0 }, // NEW: keep selection reactive
selectedIds: [],
// Button disable uses reactive data now
get hasSelection() { return this.selectedIds.length > 0 },
onListChange() { onListChange() {
const sel = this.$refs.list.selectedOptions; const sel = Array.from(this.$refs.list.selectedOptions);
this.selectedIds = sel.map(o => o.value); // <-- reactive update
if (sel.length === 1) { if (sel.length === 1) {
this.query = sel[0].textContent.trim(); this.query = sel[0].textContent.trim();
this.isEditing = true; this.isEditing = true;
@ -160,13 +166,11 @@ function ComboBox(cfg) {
if (!name) return; if (!name) return;
if (this.isEditing && this.editingOption && this.editUrl) { if (this.isEditing && this.editingOption && this.editUrl) {
// EDIT
const id = this.editingOption.value; const id = this.editingOption.value;
await this._post(this.editUrl, { id, name }); const ok = await this._post(this.editUrl, { id, name });
this.editingOption.textContent = name; if (ok) this.editingOption.textContent = name;
} else if (this.createUrl) { } else if (this.createUrl) {
// CREATE const data = await this._post(this.createUrl, { name }, true);
const data = await this._post(this.createUrl, { name });
const id = (data && data.id) ? data.id : ('temp-' + Math.random().toString(36).slice(2)); const id = (data && data.id) ? data.id : ('temp-' + Math.random().toString(36).slice(2));
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = id; opt.textContent = name; opt.value = id; opt.textContent = name;
@ -180,13 +184,20 @@ function ComboBox(cfg) {
}, },
async removeSelected() { async removeSelected() {
const opts = Array.from(this.$refs.list.selectedOptions); const ids = [...this.selectedIds]; // <-- capture IDs before DOM changes
if (!opts.length) return; if (!ids.length) return;
if (!confirm(`Delete ${ids.length} item(s)?`)) return;
if (!confirm(`Delete ${opts.length} item(s)?`)) return; let ok = true;
if (this.deleteUrl) ok = !!(await this._post(this.deleteUrl, { ids }));
if (!ok) return;
const ids = opts.map(o => o.remove()); // Remove matching options from DOM
const all = Array.from(this.$refs.list.options);
all.forEach(o => { if (ids.includes(o.value)) o.remove(); });
// Clear selection reactively
this.selectedIds = [];
this.query = ''; this.query = '';
this.cancelEdit(); this.cancelEdit();
this._maybeRefresh(); this._maybeRefresh();
@ -200,7 +211,8 @@ function ComboBox(cfg) {
_maybeRefresh() { if (this.refreshUrl) this.$dispatch('combobox:refresh'); }, _maybeRefresh() { if (this.refreshUrl) this.$dispatch('combobox:refresh'); },
async _post(url, payload) { async _post(url, payload, expectJson = false) {
try {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -208,6 +220,17 @@ function ComboBox(cfg) {
}); });
if (!res.ok) { if (!res.ok) {
const msg = await res.text().catch(() => 'Error'); const msg = await res.text().catch(() => 'Error');
alert(msg);
return false;
}
if (expectJson) {
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) return await res.json();
}
return true;
} catch (e) {
alert('Network error');
return false;
} }
} }
} }

View file

@ -4,6 +4,9 @@
{{ combos.dynamic_combobox( {{ combos.dynamic_combobox(
id='play', id='play',
label='Breakfast!', label='Breakfast!',
refresh_url=url_for('ui.list_items', model_name='brand') create_url=url_for('ui.create_item', model_name='brand'),
edit_url=url_for('ui.update_item', model_name='brand'),
refresh_url=url_for('ui.list_items', model_name='brand'),
delete_url=url_for('ui.delete_item', model_name='brand')
) }} ) }}
{% endblock %} {% endblock %}

View file

@ -57,3 +57,52 @@ def list_items(model_name):
if want_html: if want_html:
return render_template("fragments/_option_fragment.html", options=items) return render_template("fragments/_option_fragment.html", options=items)
return jsonify({"items": items}) return jsonify({"items": items})
@bp.post("/<model_name>/create")
def create_item(model_name):
Model = get_model_class(model_name)
payload = request.get_json(silent=True) or {}
if not payload:
return jsonify({"error": "Payload required"}), 422
try:
obj = call(Model, 'ui_create', db.session, payload=payload) \
or default_create(db.session, Model, payload)
except IntegrityError:
db.session.rollback()
return jsonify({"error": "Duplicate"}), 409
data = call(Model, 'ui_serialize', obj) or default_serialize(Model, obj)
want_html = (request.args.get('view') == 'option') or ("HX-Request" in request.headers)
if want_html:
return "Yo."
return jsonify(data), 201
@bp.post("/<model_name>/update")
def update_item(model_name):
Model = get_model_class(model_name)
payload = request.get_json(silent=True) or {}
try:
id_ = int(payload.get("id"))
except Exception:
return jsonify({"error": "Invalid id"}), 422
obj = call(Model, 'ui_update', db.session, id_=id_, payload=payload) \
or default_update(db.session, Model, id_, payload)
if not obj:
return jsonify({"error": "Note found"}), 404
return ("", 204)
@bp.post("/<model_name>/delete")
def delete_item(model_name):
Model = get_model_class(model_name)
payload = request.get_json(silent=True) or {}
ids = payload.get("ids") or []
try:
ids = [int(x) for x in ids]
except Exception:
return jsonify({"error": "Invalid ids"}), 422
try:
deleted = call(Model, 'ui_delete', db.session, ids=ids) \
or default_delete(db.session, Model, ids)
except IntegrityError as e:
db.session.rollback()
return jsonify({"error": "Constraint", "detail": str(e)}), 409
return jsonify({"deleted": deleted}), 200