Django forms with Bulma css framework

I've noticed that beginners have problems using django forms with bulma css framework, so here is my (quite hacky) solution that I made in 2021.
If you follow this article, you may end up with forms in your project looking like this:

django-forms-bulma

First you need some template tags

We're gonna need a few template tags, so create my_app/templatetags/forms.py looking like this:

from django import forms
from django.utils.safestring import mark_safe
from django import template
from django.utils.translation import gettext as _

register = template.Library()

Setting css classes to django form fields

Setting css classes to django form fields (or even ModelForm fields) makes people cry, so let's make a template tag that will make it easier for us:

@register.filter
def add_field_class(field, css_class):
    """adds css class to a field"""
    if len(field.errors) > 0 and 'is-danger' not in css_class:
        # automatically add .is-danger to fields with invalid values
        css_class += ' is-danger'

    if isinstance(field.field.widget, forms.SplitDateTimeWidget):
        # I personally prefer using SplitDateTimeWidget for datetime
        for subfield in field.field.widget.widgets:
            if subfield.attrs.get('class'):
                subfield.attrs['class'] += f' {css_class}'
            else:
                subfield.attrs['class'] = css_class
        return field

    if field.field.widget.attrs.get('class'):
        field.field.widget.attrs['class'] += f' {css_class}'
    else:
        field.field.widget.attrs['class'] = css_class

    return field

Checking field type

Now we need a template tag to check field type, so we can deal with the wrappers and such

@register.filter
def is_field_type(field, field_type):
    """checks field type"""
    if field_type == 'file':
        return isinstance(field.field.widget, forms.FileInput)
    elif field_type == 'radio':
        return isinstance(field.field.widget, forms.RadioSelect)
    elif field_type == 'checkbox':
        return isinstance(field.field.widget, forms.CheckboxInput)
    elif field_type == 'split_dt':
        return isinstance(field.field.widget, forms.SplitDateTimeWidget)
    elif field_type == 'input':
        return isinstance(field.field.widget, (
            forms.TextInput,
            forms.NumberInput,
            forms.EmailInput,
            forms.PasswordInput,
            forms.URLInput,
            forms.SplitDateTimeWidget,
        ))
    elif field_type == 'textarea':
        return isinstance(field.field.widget, forms.Textarea)
    elif field_type == 'select':
        return isinstance(field.field.widget, forms.Select)
    elif field_type == 'any_datetime':
        return isinstance(field.field.widget, (
            forms.DateInput,
            forms.TimeInput,
            forms.DateTimeInput,
            forms.SplitDateTimeWidget
        ))  # there is also forms.SplitHiddenDateTimeWidget, but we don't need to check that one imo
    else:
        raise ValueError(f"Unsupported field_type on |is_field_type:'{field_type}'")

Check for multiple fields

We also need a template tag to check for CheckboxSelectMultiple and SelectMultiple to set the proper bulma classes

@register.filter
def is_multiple(field):
    """checks multiple field"""
    return (
        isinstance(field.field.widget, forms.CheckboxSelectMultiple) or
        isinstance(field.field.widget, forms.SelectMultiple)
    )

Input type="date" and type="time"

Django by default sets input type="text" for DateInput, TimeInput and SplitDateTimeWidget, which is quite annoying imo. And since I'm lazy to deal with that in every Form/ModelForm, it's better to make a template tag for that too.

@register.filter
def set_input_type(field, field_type=None):
    """
    changes the type by the widget, where django puts text-input by default instead of time/date/...
    but you can also pass your own field type if you want
    """
    if field_type:
        pass
    elif isinstance(field.field.widget, forms.DateInput):
        field_type = 'date'
    elif isinstance(field.field.widget, forms.TimeInput):
        field_type = 'time'
    elif isinstance(field.field.widget, forms.SplitDateTimeWidget):
        for subfield in field.field.widget.widgets:
            if isinstance(subfield, forms.DateInput):
                subfield.input_type = 'date'
            elif isinstance(subfield, forms.TimeInput):
                subfield.input_type = 'time'
    elif isinstance(field.field.widget, forms.DateTimeInput):
        # field_type = 'datetime-local'  # can't work with passing/returning ISO format
        # field_type = 'datetime'  # is deprecated, doesn't work in many browsers
        # use widget=forms.SplitDateTimeWidget() instead
        pass

    if field_type:
        field.field.widget.input_type = field_type

    return field

Columns for SplitDateTimeWidget

Because SplitDateTimeWidget is actually made of two inputs, we need to wrap it with columns. Also I wanted a button to clear the values for non-required fields, so that needs a column too.

@register.filter
def wrap_columns(field):
    """
    easiest way to wrap date and time with columns
    it's kinda hack, but good enough for now :)
    """
    r = field.as_widget()
    if isinstance(field.field.widget, forms.SplitDateTimeWidget):
        r = ''.join(['<div class="column"><{}></div>'.format(i) for i in r.strip(' ><').split('><')])
    else:
        r = '<div class="column">{}</div>'.format(r)

    if not field.field.required:
        r += '<div class="column is-1">'
        r += '<button type="button" class="button is-danger is-outlined clear-field-value svg-no-margin" title="{}">'.format(_('Clear value'))
        r += '<i class="fas fa-times"></i>'
        r += '</button>'
        r += '</div>'

    r = '<div class="columns is-form-field {}">{}</div>'.format(field.field.widget.__class__.__name__, r)

    return mark_safe(r)

Django messages framework and Bulma

Django messages framework uses type "error" while bulma uses "danger" in css classes, so let's make one more template tag for that.

@register.filter
def bulma_message_tag(tag):
    """
    messages use type "error", while bulma use class "danger"
    """
    return {
        'error': 'danger'
    }.get(tag, tag)

That should be enough of template tags. Time to make some templates.

Form template

I wanted my forms in modal windows, so there are some extra wrappers.

{% load i18n forms %}

<div class="modal">
  <div class="modal-background"></div>
  <form class="modal-card" action="{{ form_action }}" method="post">
    {% csrf_token %}
    {% for field in form.hidden_fields %}
      {{ field }}
    {% endfor %}
    <header class="modal-card-head">
      <p class="modal-card-title">{{ modal_title }}</p>
      <button type="button" class="delete close-modal" aria-label="close"></button>
    </header>
    <section class="modal-card-body"><div class="content">

    {% if form.non_field_errors %}
      <div class="message is-danger">
        <div class="message-header">
          <button class="delete" aria-label="delete"></button>
        </div>
        <div class="message-body">
          {% for non_field_error in form.non_field_errors %}
            {{ non_field_error }}
          {% endfor %}
        </div>
      </div>
    {% endif %}

    {% for field in form.visible_fields %}

      <div class="field" data-field-name="{{ field.name }}">
        {% if field|is_field_type:'checkbox' %}

          <div class="control">
            <div class="columns"><div class="column">
            {% if field.auto_id %}
              <label class="checkbox{% if field.field.required %} {{ form.required_css_class }}{% endif %}">
                {{ field }} {{ field.label }}
              </label>
            {% endif %}
            </div></div>
            {% for error in field.errors %}
              <span class="help is-danger {{ form.error_css_class }}">{{ error }}</span>
            {% endfor %}

            {% if field.help_text %}
              <p class="help">
                {{ field.help_text|safe }}
              </p>
            {% endif %}
          </div>

        {% elif field|is_field_type:'radio' %}

          {% if field.auto_id %}
            <label class="label{% if field.field.required %} {{ form.required_css_class }}{% endif %}">{{ field.label }}</label>
          {% endif %}
          <div class="control">
            <div class="columns"><div class="column">
            {% for choice in field %}
              <label class="radio">
                {{ choice.tag }}
                {{ choice.choice_label }}
              </label>
            {% endfor %}
            </div></div>

            {% for error in field.errors %}
              <span class="help is-danger {{ form.error_css_class }}">{{ error }}</span>
            {% endfor %}

            {% if field.help_text %}
              <p class="help">
                {{ field.help_text|safe }}
              </p>
            {% endif %}
          </div>

        {% elif field|is_field_type:'input' %}

          <label class="label{% if field.field.required %} {{ form.required_css_class }}{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
          <div class="control">
            {% if field|is_field_type:'split_dt' %}
              {{ field|set_input_type|add_field_class:'input'|wrap_columns }}
            {% elif field|is_field_type:'any_datetime' %}
              {{ field|set_input_type|add_field_class:'input'|wrap_columns }}
            {% else %}
              {{ field|add_field_class:'input'|wrap_columns }}
            {% endif %}
            {% for error in field.errors %}
              <span class="help is-danger {{ form.error_css_class }}">{{ error }}</span>
            {% endfor %}
            {% if field.help_text %}
              <p class="help">
                {{ field.help_text|safe }}
              </p>
            {% endif %}
          </div>

        {% elif field|is_field_type:'textarea' %}

          <label class="label{% if field.field.required %} {{ form.required_css_class }}{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
          <div class="control">
            {{ field|add_field_class:'textarea'|wrap_columns }}
            {% for error in field.errors %}
              <span class="help is-danger {{ form.error_css_class }}">{{ error }}</span>
            {% endfor %}
            {% if field.help_text %}
              <p class="help">
                {{ field.help_text|safe }}
              </p>
            {% endif %}
          </div>

        {% elif field|is_field_type:'select' %}

          <label class="label{% if field.field.required %} {{ form.required_css_class }}{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
          <div class="control">
            <div class="columns is-form-field Select">
              <div class="column">
                <span class="select{% if field|is_multiple %} is-multiple{% endif %}{% if field.errors|length > 0 %} is-danger{% endif %}">
                  {{ field }}
                </span>
              </div>
            </div>
            {% for error in field.errors %}
              <span class="help is-danger {{ form.error_css_class }}">{{ error }}</span>
            {% endfor %}
            {% if field.help_text %}
              <p class="help">
                {{ field.help_text|safe }}
              </p>
            {% endif %}
          </div>

        {% elif field|is_field_type:'file' %}

          <label class="label{% if field.field.required %} {{ form.required_css_class }}{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
          <div class="control">

            <label class="file-label">
              {{ field|add_field_class:'file-input'|wrap_columns }}
              <span class="file-cta">
                <span class="file-icon">
                  <i class="fas fa-upload"></i>
                </span>
                <span class="file-label">
                  Choose a fileā€¦
                </span>
              </span>
            </label>

            {% for error in field.errors %}
                <span class="help is-danger {{ form.error_css_class }}">{{ error }}</span>
              {% endfor %}
              {% if field.help_text %}
                <p class="help">
                  {{ field.help_text|safe }}
                </p>
              {% endif %}
          </div>

        {% else %}

          {% if field.auto_id %}
            <label class="label{% if field.field.required %} {{ form.required_css_class }}{% endif %}" for="{{ field.auto_id }}">{{ field.label }}</label>
          {% endif %}

          <div class="control{% if field|is_multiple %} multiple-checkbox{% endif %}">
            {{ field|wrap_columns }}

            {% for error in field.errors %}
              <span class="help is-danger {{ form.error_css_class }}">{{ error }}</span>
            {% endfor %}

            {% if field.help_text %}
              <p class="help">
                {{ field.help_text|safe }}
              </p>
            {% endif %}
          </div>

        {% endif %}
      </div>

    {% endfor %}

    </div></section>
    <footer class="modal-card-foot">
      <button type="button" class="button is-dark close-modal">{% translate 'Cancel' %}</button>
      <button type="submit" class="button is-success" name="save" value="1">{% translate 'Save' %}</button>
    </footer>
  </form>
</div>

So now if you want to put a form in your template, you just do:

{% include "path/to/the/form/template.html" with form=your_form form_action="your_url" modal_title="Edit form" %}

Messages after submitting forms

If you use the django messages framework, you can use the bulma_message_tag we prepared before.

{% if messages %}
    {% for msg in messages %}
        <div class="notification is-{{ msg.level_tag|bulma_message_tag }}">
            <button class="delete"></button>
            {{ msg.message|safe }}
        </div>
    {% endfor %}
{% endif %}

JS to clear the values

As I said before, I added buttons to clear the values for non-required fields. So here is a jquery code to do the job.

$(form).on('click', '.clear-field-value', function(event) {
    let columns = $(this).closest('.columns');

    $('input, select, textarea', columns).each(function() {
        let field = $(this);
        if(!field.prop('disabled')) {
            if(field.is('select, textarea')) {
                field.val('');
            } else {
                if(field.attr('type') === 'text') {
                    field.val('');
                } else {  // input type=date and time are pain to clear
                    let type = field.attr('type');
                    field.attr('type', 'text').val('');
                    field.attr('type', type);
                }
            }
        }
    }).first().trigger('focus');

    event.stopPropagation();
    return false;
});

Summary

These are the basic hacks you'll need to work with django forms and bulma, you can modify it as you wish. Since we made the form template a snippet you can use it across your whole project.
It wasn't so hard, was it? Oh right, it really was... Django forms are really pain to work with. I would actually recommend to rather use iommi to avoid all the suffering and throw away all this code from your project :-) .