Skip to content

Instantly share code, notes, and snippets.

@prestontimmons
Last active August 29, 2015 14:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save prestontimmons/24a2a835bea590afb70b to your computer and use it in GitHub Desktop.
Save prestontimmons/24a2a835bea590afb70b to your computer and use it in GitHub Desktop.
Template-based widget rendering proposal

Template-based widget rendering

Goal

Enable form widgets to be rendered with user customizable template engines.

Requirements

  • Rendering should be customizable. User's should be able to use Jinja2, DTL or any custom loader they wish. It should be easily configurable.
  • It should be possible to override widget templates with application templates or templates in project template directories.
  • Form rendering should be customizable per form class, instance, or render call.
  • Avoid implicit coupling between django.forms and django.template.

Problems

There are two sides to implementing this feature:

  1. Converting individual widgets to be rendered with templates.
  2. Deciding how to instantiate a user-customizable template engine.

Both will be addressed below.

The basics

Widgets are updated in the following manner:

Each widget defines a template_name attribute. For example:

class TextInput(Input):
    input_type = 'text'
    template_name = 'djangoforms/text.html'

Widget.get_context is added. This returns a dictionary representation of the Widget for the template context. By default, this contains the following values:

def get_context(self, name, value, attrs=None):
    context = {} 
    context['widget'] = {
        'name': name,
        'is_hidden': self.is_hidden,
        'required': self.is_required,
        'value': self.format_value(value),
        'attrs': self.build_attrs(self.attrs, attrs),
        'template_name': self.template_name,
    }    
    return context

Widget subclasses can override get_context to provide additional information to the template. For example, Input elements can add the input type to widget['type'], and MultiWidget and add it's subwidgets to widget['subwidgets'].

Widget.render() is updated to render the specified template with the result of get_context. This uses the rendering API as defined below.

Renderers

Add a high-level render class in django.forms.renderers. The requirement of this class is to define a render() method that takes template_name, context, and request.

The default renderer provided by Django will look something like this:

class TemplateRenderer(object):

    @cached_property
    def engine(self):
        if templates_configured():
            return
        return self.default_engine()

    @staticmethod
    def default_engine():
        return Jinja2({
            'APP_DIRS': False,
            'DIRS': [ROOT],
            'NAME': 'djangoforms',
            'OPTIONS': {},
        })

    @property
    def loader(self):
        engine = self.engine
        if engine is None:
            return get_template
        else:
            return engine.get_template

    def render(self, template_name, context, request=None):
        template = self.loader(template_name)
        return template.render(context, request=request).strip()

This class first checks if the project has defined a template loader with APP_DIRS=True and django.forms in INSTALLED_APPS. If so, that engine is used. Otherwise, A default Jinja2 backend is instantiated. This backend makes minimal assumptions and only loads templates from the django.forms directory.

Users can specify a custom loader by updating their TEMPLATES setting:

Note on floppyforms

django-floppyforms is a 3rd-party package that enables template-based widget rendering for DTL. The approach it takes doesn't work so well for Django, though.

  1. floppyforms assumes a DjangoTemplates backend is configured with APP_DIRS set to True. It does not provide support for Jinja2.

  2. It's approach would create a framework-ey dependence in django.forms.widgets on django.template. It is better if the render mechanism is explicitly passed into the widget.

  3. floppyforms doesn't support the documented iteration API for BoundField widgets. See RadioSelect for example.

Form

First, Form would be updated to be aware of the render class.

This can be specified explicitly in multiple ways:

# On the class definition
class MyForm(forms.Form):
    default_renderer = TemplateRenderer()

# In Form.__init__
form = MyForm(renderer=CustomRenderer())

# Or as an argument to render:
form.render(renderer=CustomRenderer())

Second, Form would instantiate a default renderer from settings if none of the above is specified. This is explained further below in the settings section.

BoundField

BoundField.as_widget() is updated to pass self.form.renderer to Widget.render().

Since the render object is an opaque API, django.forms.widget doesn't need to know about the underlying template implementation.

BoundField.__iter__() is updated to return BoundWidget instances. These are like Widget instances but self-renderable.

The implementation looks roughly as follows:

@html_safe
@python_2_unicode_compatible
class BoundWidget(object):
    """
    A container class used when iterating over widgets. This is useful for
    widgets that have choices. For example, the following can be used in a
    template:

    {% for radio in myform.beatles %}
      <label for="{{ radio.id_for_label }}">
        {{ radio.choice_label }}
        <span class="radio">{{ radio.tag }}</span>
      </label>
    {% endfor %}
    """

    def __init__(self, parent_widget, data, renderer):
        self.parent_widget = parent_widget
        self.data = data
        self.renderer = renderer

    def __str__(self):
        return self.tag(wrap_label=True)

    def tag(self, wrap_label=False):
        context = {
            'widget': self.data,
            'wrap_label': wrap_label,
        }
        return self.parent_widget._render(
            self.template_name, context, self.renderer,
        )

    @property
    def template_name(self):
        if 'template_name' in self.data:
            return self.data['template_name']
        return self.parent_widget.template_name

    @property
    def id_for_label(self):
        return 'id_%s_%s' % (self.data['name'], self.data['index'])

    @property
    def choice_label(self):
        return self.data['label']

This approach simplifies the old iteration API classes, allowing us to remove classes like ChoiceInput, ChoiceFieldRenderer, and RendererMixin.

Templates

Add Jinja2 and DTL templates for each built-in widget. These would live in django/forms/templates and django/forms/jinja2.

Settings

It's not practical or backwards-compatible to require every form to specify a renderer explicitly. Because of this, the Form class create a default renderer if none is specified. This would be controlled by a new setting:

FORM_RENDERER = 'django.forms.renderers.TemplateRenderer'

The renderer would be loaded by a cached function like so:

@lru_cache.lru_cache()
def get_default_renderer():
    from django.conf import settings
    return load_renderer(settings.FORM_RENDERER)

Backwards-compatibility

In general, this change will be backwards-compatible. 3rd-party widgets that define a custom render method will continue to work until they implement template-based rendering, although they will eventually need to be updated to accept the renderer keyword argument.

Certain built-in widgets, like ClearableFileInput and RadioSelect, will change enough that subclasses of these widgets will break if they depend on the widget internals. I don't think this is very common.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment