In Flask-Admin, I have a view of my Structure
model called StructureView
which contains an editable foreign key field called power_unit
. The PowerUnit
model and database table contains many, many records, which are all apparently eager-loaded into the HTML, slowing down the loading time for the view.
I'd like the dropdown menu for the power_unit
field to lazy-load when the user clicks on the field to select something from the dropdown list, and not on page-load.
Is that possible?
I've read in a few places that I'm supposed to try form_ajax_refs
to make a "searchable on-demand" dropdown list, but I can't get them to work due to the following error, which only occurs when the field is "editable" in the list view:
Exception: Unsupported field type: <class 'flask_admin.model.fields.AjaxSelectField'>
Here are my models and my Flask-Admin view:
class Structure(db.Model):
__tablename__ = 'structures'
__table_args__ = {"schema": "public"}
id = db.Column(INTEGER, primary_key=True)
structure = db.Column(TEXT, nullable=False)
power_unit_id = db.Column(INTEGER, db.ForeignKey('public.power_units.id'))
power_unit = relationship('PowerUnit', back_populates='structures')
class PowerUnit(db.Model):
__tablename__ = 'power_units'
__table_args__ = {"schema": "public"}
id = db.Column(INTEGER, primary_key=True)
power_unit = db.Column(TEXT, nullable=False)
structures = relationship('Structure', back_populates='power_unit')
class StructureView(MyModelView):
"""Flask-Admin view for Structure model (public.structures table)"""
column_list = ('structure', 'power_unit')
form_columns = column_list
column_editable_list = form_columns
# I can't get these "form_ajax_refs" to work due to Exception:
# Unsupported field type: <class 'flask_admin.model.fields.AjaxSelectField'>...
form_ajax_refs = {
'power_unit': {
'fields': [PowerUnit.power_unit], # searchable fields, I think
'minimum_input_length': 0, # show suggestions, even before user input
'placeholder': 'Please select',
'page_size': 10,
},
# The following doesn't work either...
# 'power_unit': QueryAjaxModelLoader(
# 'power_unit', db.session, PowerUnit, fields=['power_unit']
# )
}
Here's a picture of the long dropdown menu when editing the power_unit
field:
When I inspect the HTML, I see a long array of name-value pairs for the dropdown menu, and this array is repeated for every power_unit
cell in the structures
table view, so it's a lot of HTML to render, which I think slows down the page loading considerably.
After much trial-and-error, I've figured it out. It would be great if Flask-Admin would support this natively, now that it works so well with Select2 and x-editable.
First create a custom widget so we don't get the following error:
Exception: Unsupported field type: <class 'flask_admin.model.fields.AjaxSelectField'>
Here's the custom widget:
from flask_admin.contrib.sqla.ajax import QueryAjaxModelLoader
from flask_admin.model.widgets import XEditableWidget
from wtforms.widgets import html_params
from flask_admin.helpers import get_url
from flask_admin.babel import gettext
from flask_admin._backwards import Markup
from jinja2 import escape
class CustomWidget(XEditableWidget):
"""WTForms widget that provides in-line editing for the list view.
Determines how to display the x-editable/ajax form based on the
field inside of the FieldList (StringField, IntegerField, etc).
"""
def __init__(self, multiple=False):
self.multiple = multiple
def __call__(self, field, **kwargs):
"""Called when rendering the Jinja2 template.
Previously 'AjaxSelectField' was not supported using form_ajax_refs
for column_editable_list cells"""
# We only need to add the AjaxSelectField and perhaps AjaxSelectMultipleField.
# For all others __call__ stays the same
if field.type not in ('AjaxSelectField', 'AjaxSelectMultipleField'):
return super().__call__(field, **kwargs)
# x-editable-ajax is a custom type I made in flask_admin_form.js for
# lazy-loading the dropdown options by AJAX
kwargs.setdefault('data-role', 'x-editable-ajax')
display_value = kwargs.pop('display_value', '')
kwargs.setdefault('data-value', display_value)
# For the POST request
kwargs.setdefault('data-url', './ajax/update/')
# For the GET request
kwargs.setdefault('data-url-lookup', get_url('.ajax_lookup', name=field.loader.name))
kwargs.setdefault('id', field.id)
kwargs.setdefault('name', field.name)
kwargs.setdefault('href', '#')
kwargs.setdefault('type', 'hidden')
kwargs['data-csrf'] = kwargs.pop("csrf", "")
if self.multiple:
result = []
ids = []
for value in field.data:
data = field.loader.format(value)
result.append(data)
ids.append(as_unicode(data[0]))
separator = getattr(field, 'separator', ',')
kwargs['value'] = separator.join(ids)
kwargs['data-json'] = json.dumps(result)
kwargs['data-multiple'] = u'1'
else:
data = field.loader.format(field.data)
if data:
kwargs['value'] = data[0]
kwargs['data-json'] = json.dumps(data)
placeholder = field.loader.options.get('placeholder', gettext('Search'))
kwargs.setdefault('data-placeholder', placeholder)
minimum_input_length = int(field.loader.options.get('minimum_input_length', 0))
kwargs.setdefault('data-minimum-input-length', minimum_input_length)
if not kwargs.get('pk'):
raise Exception('pk required')
kwargs['data-pk'] = str(kwargs.pop("pk"))
kwargs = self.get_kwargs(field, kwargs)
return Markup(
'<a %s>%s</a>' % (html_params(**kwargs),
escape(display_value))
)
def get_kwargs(self, field, kwargs):
"""Return extra kwargs based on the field type"""
if field.type in ('AjaxSelectField', 'AjaxSelectMultipleField'):
kwargs['data-type'] = 'select2'
else:
super().get_kwargs(field, kwargs)
return kwargs
Then override the get_list_form()
method in your model view, to use your CustomWidget.
from flask_admin.contrib.sqla import ModelView
class MyModelView(ModelView):
"""
Customized model view for Flask-Admin page (for database tables)
https://flask-admin.readthedocs.io/en/latest/introduction/#
"""
# Custom templates to include custom JavaScript and override the {% block tail %}
list_template = 'admin/list_custom.html'
can_create = True
can_edit = True
def get_list_form(self):
"""Override this function and supply my own CustomWidget with AJAX
for lazy-loading dropdown options"""
if self.form_args:
# get only validators, other form_args can break FieldList wrapper
validators = dict(
(key, {'validators': value["validators"]})
for key, value in iteritems(self.form_args)
if value.get("validators")
)
else:
validators = None
# Here's where I supply my custom widget!
return self.scaffold_list_form(validators=validators, widget=CustomWidget())
Now for the view, where I use form_ajax_refs
to lazy-load the options for the dropdown menus in the edit view.
class StructureView(MyModelView):
"""Flask-Admin view for Structure model (public.structures table)"""
can_create = True
can_edit = True
column_list = ('structure', 'power_unit')
form_columns = column_list
column_editable_list = column_list
# For lazy-loading the dropdown options in the edit view,
# which really speeds up list view loading time
form_ajax_refs = {
'power_unit': QueryAjaxModelLoader(
'power_unit', db.session, PowerUnit,
fields=['power_unit'], order_by='power_unit'
),
}
Here's my list_custom.html
template, for overriding the {% block tail %}
with my own flask_admin_form.js
script for my custom widget.
{% extends 'admin/model/list.html' %}
{% block tail %}
{% if filter_groups %}
<div id="filter-groups-data" style="display:none;">{{ filter_groups|tojson|safe }}</div>
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
{% endif %}
<script src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js', v='1.3.22') }}"></script>
{% if editable_columns %}
<script src="{{ admin_static.url(filename='vendor/x-editable/js/bootstrap3-editable.min.js', v='1.5.1.1') }}"></script>
{% endif %}
<!-- <script src="{ admin_static.url(filename='admin/js/form.js', v='1.0.1') }"></script> -->
<script src="{{ url_for('static', filename='js/flask_admin_form.js') }}"></script>
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ actionlib.script(_gettext('Please select at least one record.'),
actions,
actions_confirmation) }}
{% endblock %}
Finally, in the flask_admin_form.js
(my replacement for the default filename='admin/js/form.js'
), I add the following case for x-editable-ajax
(my custom role). I didn't include the whole JavaScript file here for brevity. You can find it here in the source code.
Notice the select2
I added to the $el.editable(
options:
...
switch (name) {
case "select2-ajax":
processAjaxWidget($el, name);
return true;
case "x-editable":
$el.editable({
params: overrideXeditableParams,
combodate: {
// prevent minutes from showing in 5 minute increments
minuteStep: 1,
maxYear: 2030,
},
});
return true;
case "x-editable-ajax":
var optsSelect2 = {
minimumInputLength: $el.attr("data-minimum-input-length"),
placeholder: "data-placeholder",
allowClear: $el.attr("data-allow-blank") == "1",
multiple: $el.attr("data-multiple") == "1",
ajax: {
// Special data-url just for the GET request
url: $el.attr("data-url-lookup"),
data: function (term, page) {
return {
query: term,
offset: (page - 1) * 10,
limit: 10,
};
},
results: function (data, page) {
var results = [];
for (var k in data) {
var v = data[k];
results.push({ id: v[0], text: v[1] });
}
return {
results: results,
more: results.length == 10,
};
},
},
};
// From x-editable
$el.editable({
params: overrideXeditableParams,
combodate: {
// prevent minutes from showing in 5 minute increments
minuteStep: 1,
maxYear: 2030,
},
// I added the following so the select2 dropdown will lazy-load values from the DB on-demand
select2: optsSelect2,
});
return true;
...