pallets-eco / flask-admin

Simple and extensible administrative interface framework for Flask
https://flask-admin.readthedocs.io
BSD 3-Clause "New" or "Revised" License
5.8k stars 1.58k forks source link

Editable field with "form_ajax_refs" causes Exception: Unsupported field type: <class 'flask_admin.model.fields.AjaxSelectField'> #2063

Open mccarthysean opened 3 years ago

mccarthysean commented 3 years ago

Here's my StackOverflow question that made me realize this was an issue.

Apparently form_ajax_refs does not work for editable fields in the list view. I was having performance issues eager-loading relationship values for editable fields in the list view, so I tried form_ajax_refs to lazy-load it on-demand, but I kept getting the following error:

Exception: Unsupported field type: <class 'flask_admin.model.fields.AjaxSelectField'>

It happens when the form_ajax_refs field is also in the column_editable_list.

Here's my setup:

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.

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
            'minimum_input_length': 0,
            '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. This is my motivation for using form_ajax_refs in the first place: 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

mccarthysean commented 3 years ago

After much trial-and-error, I've found a solution. It would be great if we could 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"""

        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", "")
        minimum_input_length = int(field.loader.options.get('minimum_input_length', 0))
        kwargs.setdefault('data-minimum-input-length', minimum_input_length)

        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)

        allow_blank = getattr(field, 'allow_blank', False)
        if allow_blank and not self.multiple:
            kwargs['data-allow-blank'] = u'1'

        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'
            # kwargs['data-source'] = []

            if field.type == 'QuerySelectMultipleField':
                # kwargs['data-role'] = 'x-editable-ajax'
                kwargs['data-role'] = 'x-editable-select2-multiple'

        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;
...
felix1m commented 3 years ago

is there anything i can do to help getting this into main?

mccarthysean commented 3 years ago

I was kind of hoping someone would offer to help, if I left the solution here. I'm not a git expert so I don't know the sequence of events to get this into the main flask admin package.

If you want to help you can either copy and paste the solution I've put here, or explain to me the exact sequence of events so I can put it in myself. Thanks