Search code examples
pythonajaxflaskflask-sqlalchemyflask-admin

Can I use "form_ajax_refs" AND "column_editable_list" for the same column in Flask-Admin?


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: enter image description here

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. enter image description here


Solution

  • 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;
    ...