Making more progress on dynamic widget stuff.

This commit is contained in:
Yaro Kasear 2025-08-13 11:42:02 -05:00
parent b2231f8ef9
commit fc275c6e5d
5 changed files with 107 additions and 107 deletions

View file

@ -1,104 +1,84 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro render_combobox(id, options, label=none, placeholder=none, onAdd=none, onRemove=none, onEdit=none, data_attributes=none) %}
<!-- Combobox Widget Fragment -->
{% macro render_combobox(id, options, label=none, placeholder=none, onAdd=none, onRemove=none, onEdit=none,
data_attributes=none) %}
<!-- Combobox Widget Fragment -->
{% if label %}
<label for="{{ id }}-input" class="form-label">{{ label }}</label>
{% endif %}
<div class="combo-box-widget" id="{{ id }}-container">
<div class="input-group">
<input type="text" class="form-control rounded-bottom-0" id="{{ id }}-input"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}>
<button type="button" class="btn btn-primary rounded-bottom-0" id="{{ id }}-add" disabled>
{{ icons.render_icon('plus-lg', 16, 'icon-state') }}
</button>
<button type="button" class="btn btn-danger rounded-bottom-0" id="{{ id }}-remove" disabled>
{{ icons.render_icon('dash-lg', 16) }}
</button>
</div>
<select class="form-select border-top-0 rounded-top-0" id="{{ id }}-list" name="{{ id }}" size="10" multiple>
{% for option in options %}
<option value="{{ option.id }}"
{% if data_attributes %}
{% for key, data_attr in data_attributes.items() %}
{% if option[key] is defined %}
data-{{ data_attr }}="{{ option[key] }}"
{% endif %}
{% endfor %}
{% endif %}>
{{ option.name }}
</option>
{% endfor %}
</select>
{% if label %}
<label for="{{ id }}-input" class="form-label">{{ label }}</label>
{% endif %}
<div class="combo-box-widget" id="{{ id }}-container">
<div class="input-group">
<input type="text" class="form-control rounded-bottom-0" id="{{ id }}-input" {% if placeholder %}
placeholder="{{ placeholder }}" {% endif %}>
<button type="button" class="btn btn-primary rounded-bottom-0" id="{{ id }}-add" disabled>
{{ icons.render_icon('plus-lg', 16, 'icon-state') }}
</button>
<button type="button" class="btn btn-danger rounded-bottom-0" id="{{ id }}-remove" disabled>
{{ icons.render_icon('dash-lg', 16) }}
</button>
</div>
<select class="form-select border-top-0 rounded-top-0" id="{{ id }}-list" name="{{ id }}" size="10" multiple>
{% for option in options %}
<option value="{{ option.id }}" {% if data_attributes %} {% for key, data_attr in data_attributes.items() %} {%
if option[key] is defined %} data-{{ data_attr }}="{{ option[key] }}" {% endif %} {% endfor %} {% endif %}>
{{ option.name }}
</option>
{% endfor %}
</select>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
ComboBoxWidget.initComboBox("{{ id }}"{% if onAdd or onRemove or onEdit %}, {
{% if onAdd %}onAdd: function(newItem, list, createOption) { {{ onAdd | safe }} },{% endif %}
{% if onRemove %}onRemove: function(option) { {{ onRemove | safe }} },{% endif %}
{% if onEdit %}onEdit: function(option) { {{ onEdit | safe }} }{% endif %}
<script>
document.addEventListener('DOMContentLoaded', () => {
ComboBoxWidget.initComboBox("{{ id }}"{% if onAdd or onRemove or onEdit %}, {
{% if onAdd %}onAdd: function(newItem, list, createOption) { { { onAdd | safe } } }, {% endif %}
{% if onRemove %} onRemove: function(option) { { { onRemove | safe } } }, {% endif %}
{% if onEdit %} onEdit: function(option) { { { onEdit | safe } } } {% endif %}
}{% endif %});
});
</script>
</script>
{% endmacro %}
{% macro dynamic_combobox(
id, options, label = none, placeholder = none, data_attributes = none,
create_url = none, edit_url = none, delete_url = none, refresh_url = none
id, options, label = none, placeholder = none, data_attributes = none,
create_url = none, edit_url = none, delete_url = none, refresh_url = none
) %}
{% if label %}
<label for="{{ id }}-input" class="form-label">{{ label }}</label>
{% endif %}
<div id="{{ id }}-container"
x-data="ComboBox({
id: '{{ id }}',
createUrl: {{ create_url|tojson if create_url else 'null' }},
editUrl: {{ edit_url|tojson if edit_url else 'null' }},
deleteUrl: {{ delete_url|tojson if delete_url else 'null' }},
refreshUrl: {{ refresh_url|tojson if refresh_url else 'null' }},
})"
hx-preserve
class="combo-box-widget">
<div class="input-group">
<input type="text"
id="{{ id }}-input"
x-model.trim="query"
@keydown.enter.prevent="submitAddOrEdit()"
@keydown.escape="cancelEdit()"
class="form-control rounded-bottom-0">
{% if label %}
<label for="{{ id }}-input" class="form-label">{{ label }}</label>
{% endif %}
<div id="{{ id }}-container" x-data='ComboBox({
id: {{ id|tojson }},
createUrl: {{ create_url|tojson if create_url else "null" }},
editUrl: {{ edit_url|tojson if edit_url else "null" }},
deleteUrl: {{ delete_url|tojson if delete_url else "null" }},
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }}
})' hx-preserve class="combo-box-widget">
<div class="input-group">
<input type="text" id="{{ id }}-input" x-model.trim="query" @keydown.enter.prevent="submitAddOrEdit()"
@keydown.escape="cancelEdit()" class="form-control rounded-bottom-0">
<button id="{{ id }}-add"
:disabled="!query"
@click="submitAddOrEdit()"
class="btn btn-primary rounded-bottom-0">
<i class="bi icon-state" :class="isEditing ? 'bi-pencil' : 'bi-plus-lg'"></i>
</button>
<button id="{{ id }}-add" :disabled="!query" @click="submitAddOrEdit()"
class="btn btn-primary rounded-bottom-0">
<i class="bi icon-state" :class="isEditing ? 'bi-pencil' : 'bi-plus-lg'"></i>
</button>
<button id="{{ id }}-remove"
:disabled="!hasSelection"
@click="removeSelected()"
class="btn btn-danger rounded-bottom-0">
{{ icons.render_icon('dash-lg', 16) }}
</button>
</div>
<select id="{{ id }}-list" multiple
x-ref="list"
@change="onListChange"
class="form-select border-top-0 rounded-top-0"
name="{{ id }}" size="10">
{% for option in options %}
<option value="{{ option.id }}">{{ option.name }}</option>
{% endfor %}
</select>
{% if refresh_url %}
<div id="{{ id }}-htmx-refresh" class="d-none"
hx-get="{{ refresh_url }}"
hx-trigger="combobox:refresh from:#{{ id }}-container"
hx-target="#{{ id }}-list"
hx-swap="innerHTML"></div>
{% endif %}
<button id="{{ id }}-remove" :disabled="!hasSelection" @click="removeSelected()"
class="btn btn-danger rounded-bottom-0">
{{ icons.render_icon('dash-lg', 16) }}
</button>
</div>
{% endmacro %}
<select id="{{ id }}-list" multiple x-ref="list" @change="onListChange"
class="form-select border-top-0 rounded-top-0" name="{{ id }}" size="10">
{% for option in options if options %}
<option value="{{ option.id }}">{{ option.name }}</option>
{% endfor %}
<option disabled>Loading...</option>
</select>
{% if refresh_url %}
<div id="{{ id }}-htmx-refresh" class="d-none" hx-get="{{ refresh_url }}"
hx-trigger="load, combobox:refresh from:#{{ id }}-container" hx-target="#{{ id }}-list" hx-swap="innerHTML"></div>
{% endif %}
</div>
{% endmacro %}

View file

@ -0,0 +1,6 @@
{% for option in options %}
<option value="{{ option.id }}"
{% for k,v in option.items() if k not in ('id','name') and v is not none %}
data-{{ k }}="{{ v }}"
{% endfor %}>{{ option.name }}</option>
{% endfor %}

View file

@ -2,17 +2,8 @@
{% block content %}
{{ combos.dynamic_combobox(
'play',
[
{
'id': 1,
'name': 'Beans'
},
{
'id': 2,
'name': 'Toast'
},
],
'Breakfast!'
id='play',
label='Breakfast!',
refresh_url=url_for('ui.list_items', model_name='brand', view='option')
) }}
{% endblock %}

View file

@ -50,7 +50,9 @@ def list_items(model_name):
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)
items = [call(Model, 'ui_serialize', r, view=view) or default_serialize(Model, r, view=view)
for r in rows]
data = [ (call(Model, "ui_serialize", r, view=view) or default_serialize(Model, r, view=view))
for r in rows ]
return jsonify({"items": data})
if view=="option":
return render_template('fragments/_option_fragment.html', options=items)
return jsonify({"items": items})

View file

@ -89,9 +89,30 @@ def default_delete(session, Model, ids):
return count
def default_serialize(Model, obj, *, view='option'):
label = infer_label_attr(Model)
data = {'id': obj.id, 'name': getattr(obj, label)}
# 1. Explicit config wins
label_attr = getattr(Model, 'ui_label_attr', None)
# 2. Otherwise, pick the first PREFERRED_LABELS that exists (can be @property or real column)
if not label_attr:
for candidate in PREFERRED_LABELS:
if hasattr(obj, candidate):
label_attr = candidate
break
# 3. Fallback to str(obj) if literally nothing found
if not label_attr:
name_val = str(obj)
else:
try:
name_val = getattr(obj, label_attr)
except Exception:
name_val = str(obj)
data = {'id': obj.id, 'name': name_val}
# Include extra attrs if defined
for attr in getattr(Model, 'ui_extra_attrs', ()):
if hasattr(obj, attr):
data[attr] = getattr(obj, attr)
return data