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 %} {% 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) %} {% macro render_combobox(id, options, label=none, placeholder=none, onAdd=none, onRemove=none, onEdit=none,
<!-- Combobox Widget Fragment --> data_attributes=none) %}
<!-- Combobox Widget Fragment -->
{% if label %} {% if label %}
<label for="{{ id }}-input" class="form-label">{{ label }}</label> <label for="{{ id }}-input" class="form-label">{{ label }}</label>
{% endif %} {% endif %}
<div class="combo-box-widget" id="{{ id }}-container"> <div class="combo-box-widget" id="{{ id }}-container">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control rounded-bottom-0" id="{{ id }}-input"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}> <input type="text" class="form-control rounded-bottom-0" id="{{ id }}-input" {% if placeholder %}
<button type="button" class="btn btn-primary rounded-bottom-0" id="{{ id }}-add" disabled> placeholder="{{ placeholder }}" {% endif %}>
{{ icons.render_icon('plus-lg', 16, 'icon-state') }} <button type="button" class="btn btn-primary rounded-bottom-0" id="{{ id }}-add" disabled>
</button> {{ icons.render_icon('plus-lg', 16, 'icon-state') }}
<button type="button" class="btn btn-danger rounded-bottom-0" id="{{ id }}-remove" disabled> </button>
{{ icons.render_icon('dash-lg', 16) }} <button type="button" class="btn btn-danger rounded-bottom-0" id="{{ id }}-remove" disabled>
</button> {{ icons.render_icon('dash-lg', 16) }}
</div> </button>
<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> </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> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
ComboBoxWidget.initComboBox("{{ id }}"{% if onAdd or onRemove or onEdit %}, { ComboBoxWidget.initComboBox("{{ id }}"{% if onAdd or onRemove or onEdit %}, {
{% if onAdd %}onAdd: function(newItem, list, createOption) { {{ onAdd | safe }} },{% endif %} {% if onAdd %}onAdd: function(newItem, list, createOption) { { { onAdd | safe } } }, {% endif %}
{% if onRemove %}onRemove: function(option) { {{ onRemove | safe }} },{% endif %} {% if onRemove %} onRemove: function(option) { { { onRemove | safe } } }, {% endif %}
{% if onEdit %}onEdit: function(option) { {{ onEdit | safe }} }{% endif %} {% if onEdit %} onEdit: function(option) { { { onEdit | safe } } } {% endif %}
}{% endif %}); }{% endif %});
}); });
</script> </script>
{% endmacro %} {% endmacro %}
{% macro dynamic_combobox( {% macro dynamic_combobox(
id, options, label = none, placeholder = none, data_attributes = none, id, options, label = none, placeholder = none, data_attributes = none,
create_url = none, edit_url = none, delete_url = none, refresh_url = none create_url = none, edit_url = none, delete_url = none, refresh_url = none
) %} ) %}
{% if label %} {% if label %}
<label for="{{ id }}-input" class="form-label">{{ label }}</label> <label for="{{ id }}-input" class="form-label">{{ label }}</label>
{% endif %} {% endif %}
<div id="{{ id }}-container" <div id="{{ id }}-container" x-data='ComboBox({
x-data="ComboBox({ id: {{ id|tojson }},
id: '{{ id }}', createUrl: {{ create_url|tojson if create_url else "null" }},
createUrl: {{ create_url|tojson if create_url else 'null' }}, editUrl: {{ edit_url|tojson if edit_url else "null" }},
editUrl: {{ edit_url|tojson if edit_url else 'null' }}, deleteUrl: {{ delete_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" }}
refreshUrl: {{ refresh_url|tojson if refresh_url else 'null' }}, })' hx-preserve class="combo-box-widget">
})" <div class="input-group">
hx-preserve <input type="text" id="{{ id }}-input" x-model.trim="query" @keydown.enter.prevent="submitAddOrEdit()"
class="combo-box-widget"> @keydown.escape="cancelEdit()" class="form-control rounded-bottom-0">
<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" <button id="{{ id }}-add" :disabled="!query" @click="submitAddOrEdit()"
:disabled="!query" class="btn btn-primary rounded-bottom-0">
@click="submitAddOrEdit()" <i class="bi icon-state" :class="isEditing ? 'bi-pencil' : 'bi-plus-lg'"></i>
class="btn btn-primary rounded-bottom-0"> </button>
<i class="bi icon-state" :class="isEditing ? 'bi-pencil' : 'bi-plus-lg'"></i>
</button>
<button id="{{ id }}-remove" <button id="{{ id }}-remove" :disabled="!hasSelection" @click="removeSelected()"
:disabled="!hasSelection" class="btn btn-danger rounded-bottom-0">
@click="removeSelected()" {{ icons.render_icon('dash-lg', 16) }}
class="btn btn-danger rounded-bottom-0"> </button>
{{ 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 %}
</div> </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 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 %} {% 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 %} {% block content %}
{{ combos.dynamic_combobox( {{ combos.dynamic_combobox(
'play', id='play',
[ label='Breakfast!',
{ refresh_url=url_for('ui.list_items', model_name='brand', view='option')
'id': 1,
'name': 'Beans'
},
{
'id': 2,
'name': 'Toast'
},
],
'Breakfast!'
) }} ) }}
{% endblock %} {% 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) \ 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) 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)) if view=="option":
for r in rows ] return render_template('fragments/_option_fragment.html', options=items)
return jsonify({"items": data}) return jsonify({"items": items})

View file

@ -89,9 +89,30 @@ def default_delete(session, Model, ids):
return count return count
def default_serialize(Model, obj, *, view='option'): def default_serialize(Model, obj, *, view='option'):
label = infer_label_attr(Model) # 1. Explicit config wins
data = {'id': obj.id, 'name': getattr(obj, label)} 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', ()): for attr in getattr(Model, 'ui_extra_attrs', ()):
if hasattr(obj, attr): if hasattr(obj, attr):
data[attr] = getattr(obj, attr) data[attr] = getattr(obj, attr)
return data return data