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

0

Issue

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

Answered By – Sean McCarthy

This Answer collected from stackoverflow, is licensed under cc by-sa 2.5 , cc by-sa 3.0 and cc by-sa 4.0

Leave A Reply

Your email address will not be published.

This website uses cookies to improve your experience. We'll assume you're ok with this, but you can opt-out if you wish. Accept Read More