0

Short version: is it possible to define a set of default css classes that Django should use whenever rendering a form ?

Long version: The context is as follows: I would like to use the css classes defined in the w3.css framework for all my forms (http://www.w3schools.com/w3css/default.asp). I have seen that it is possible to do that in Django at form class definition or at form rendering, but it requires in both cases an explicit declaration of all the form fields. That means that I loose all the benefit of automatic form generation for ModelForms. I would like something as follows instead:

  1. Define somewhere (e.g. in the settings file) a default mapping between form fields / widgets and css classes, e.g. 'textinput': 'my_default_css_class_for_text_inputs'
  2. By default, for all automatic generation and rendering of forms, the default css classes defined in (1) are used, with no or minimal modification of the existing form classes
  3. For specific forms, I can overload the defaults with other values

As far as I've understood, such behaviour is not possible in django. The crispy-forms package seems to go in that direction, but it seems to do much more than just that, and I am not sure that I want all the extra complexity (I'm still a newbie around here). An alternative would be to use javascript to add the classes on the client side. It looks like an ugly bad practice to me.

Could anyone confirm my understanding of this issue and point me towards elegant solutions, if any ?

Thanks !

Jonathan

jxrossel
  • 81
  • 8

1 Answers1

2

I've managed to find the answer to my question and I'm posting it here for posterity. For the label class, I had some inspiration from here and here (answer from user2732686). The first link suggests to redefine the label_tag method of the BoundField class at run-time. It's a less verbose solution than the one suggested in the 2nd link, but at the cost of a project-wide hack, which I would not recommend. Here, I follow Django's subclassing mania, as suggested in the 2nd link for the labels.

In the projects settings.py, add:

# Default css classes for widgets and labels
DEFAULT_CSS = {
           'error': 'w3-panel w3-red',        # displayed in the label
           'errorlist': 'w3-padding-8 w3-red', # encloses the error list
           'required': 'w3-text-indigo',     # used in the label and label + input enclosing box. NB: w3-validate only works if the input precedes the label!
           'label': 'w3-label',
           'Textarea': 'w3-input w3-border',
           'TextInput': 'w3-input w3-border',
           'Select': 'w3-select w3-border',
           }

NB: apart from the 4 first keys, the keys must match Django's widget names.

In your forms.py (or elsewhere), add:

from django.forms import ModelForm, inlineformset_factory, Form, BoundField
from django.forms.utils import ErrorList
from django.utils.html import format_html, force_text
from django.conf import settings

class CustErrorList(ErrorList):
    # custom error list format to use defcss
    def __str__(self):
        return self.as_div()
    def as_div(self):
        if not self: 
            return ''
        return format_html('<div class="{}">{}</div>',
                           settings.DEFAULT_CSS['errorlist'],
                           ' '.join( [ force_text(e) for e in self ] )
                           )

class CustBoundField(BoundField):
    # overload label_tag to include default css classes for labels
    def label_tag(self, contents=None, attrs=None, label_suffix=None):
        newcssclass = settings.DEFAULT_CSS['label']
        if attrs is None:
            attrs = {}
        elif 'class' in attrs:
            newcssclass = ' '.join( [ attrs['class'], newcssclass ] ) # NB: order has no impact here (but it does in the style sheet)
        attrs.update( { 'class': newcssclass } )
        # return the output of the original method with the modified attrs
        return super( CustBoundField, self ).label_tag( contents, attrs, label_suffix )

def custinit(self, subclass, *args, **kwargs):
    # overload Form or ModelForm inits, to use default CSS classes for widgets
    super( subclass, self ).__init__(*args, **kwargs)
    self.error_class = CustErrorList # change the default error class

    # Loop on fields and add css classes
    # Warning: must loop on fields, not on boundfields, otherwise inline_formsets break
    for field in self.fields.values():            
        thiswidget = field.widget
        if thiswidget .is_hidden:
            continue
        newcssclass = settings.DEFAULT_CSS[ thiswidget.__class__.__name__ ]
        thisattrs = thiswidget.attrs
        if 'class' in thisattrs:
            newcssclass = ' '.join( [ thisattrs['class'], newcssclass ] ) # NB: order has no impact here (but it does in the style sheet)
        thisattrs.update( { 'class': newcssclass } )

def custgetitem(self, name):
    # Overload of Form getitem to use the custom BoundField with
    # css-classed labels. Everything here is just a copy of django's version,
    # apart from the call to CustBoundField
    try:
        field = self.fields[name]
    except KeyError:
        raise KeyError(
            "Key '%s' not found in '%s'. Choices are: %s." % (
                name,
                self.__class__.__name__,
                ', '.join(sorted(f for f in self.fields)),
            )
        )
    if name not in self._bound_fields_cache:
        self._bound_fields_cache[name] = CustBoundField( self, field, name )
        # In the original version, field.get_bound_field is called, but
        # this method only calls BoundField. It is much easier to 
        # subclass BoundField and call it directly here
    return self._bound_fields_cache[name]        

class DefaultCssModelForm(ModelForm):
    # Defines the new reference ModelForm, with default css classes
    error_css_class = settings.DEFAULT_CSS['error']
    required_css_class = settings.DEFAULT_CSS['required']

    def __init__(self, *args, **kwargs):
        custinit(self, DefaultCssModelForm, *args, **kwargs)

    def __getitem__(self, name):
        return custgetitem(self, name)

class DefaultCssForm(Form):
    # Defines the new reference Form, with default css classes

    error_css_class = settings.DEFAULT_CSS['error']
    required_css_class = settings.DEFAULT_CSS['required']

    def __init__(self, *args, **kwargs):
        custinit(self, DefaultCssForm, *args, **kwargs)

    def __getitem__(self, name):
        return custgetitem(self, name)

NB: replace <MY_PROJECT> with your project name

Then, you simply subclass DefaultCssModelForm and DefaultCssForm instead of ModelForm and Form when defining your forms. For formsets, use these classes as base classes. To illustrate:

class MyForm(DefaultCssModelForm):
    class Meta:
        model = MyModel
        fields = '__all__'

MyFormSet = inlineformset_factory( ..., ..., form=DefaultCssModelForm, ... )            
jxrossel
  • 81
  • 8