Handling dependencies between form fields in CubicWeb

This post considers the issue of building an edition form of a CubicWeb entity with dependencies on its fields. It's a quite common issue that needs to be handled client-side, based on user interaction.

Consider the following example schema:

from yams.buildobjs import EntityType, RelationDefinition, String, SubjectRelation
from cubicweb.schema import RQLConstraint

_ = unicode

class Country(EntityType):
    name = String(required=True)

class City(EntityType):
    name = String(required=True)

class in_country(RelationDefinition):
    subject = 'City'
    object = 'Country'
    cardinality = '1*'

class Citizen(EntityType):
    name = String(required=True)
    country = SubjectRelation('Country', cardinality='1*',
                              description=_('country the citizen lives in'))
    city = SubjectRelation('City', cardinality='1*',
                           constraints=[
                               RQLConstraint('S country C, O in_country C')],
                           description=_('city the citizen lives in'))

The main entity of interest is Citizen which has two relation definitions towards Country and City. Then, a City is bound to a Country through the in_country relation definition.

In the automatic edition form of Citizen entities, we would like to restrict the choices of cities depending on the selected Country, to be determined from the value of the country field. (In other words, we'd like the constraint on city relation defined above to be fulfilled during form rendering, not just validation.) Typically, in the image below, cities not in Italy should be available in the city select widget:

Example of Citizen entity edition form.

The issue will be solved by little customization of the automatic entity form, some uicfg rules and a bit of Javascript. In the following, the country field will be referred to as the master field whereas the city field as the dependent field.

So here the code of the views.py module:

from cubicweb.predicates import is_instance
from cubicweb.web.views import autoform, uicfg
from cubicweb.uilib import js

_ = unicode


class CitizenAutoForm(autoform.AutomaticEntityForm):
    """Citizen autoform handling dependencies between Country/City form fields
    """
    __select__ = is_instance('Citizen')

    needs_js = autoform.AutomaticEntityForm.needs_js + ('cubes.demo.js', )

    def render(self, *args, **kwargs):
        master_domid = self.field_by_name('country', 'subject').dom_id(self)
        dependent_domid = self.field_by_name('city', 'subject').dom_id(self)
        self._cw.add_onload(js.cw.cubes.demo.initDependentFormField(
            master_domid, dependent_domid))
        super(CitizenAutoForm, self).render(*args, **kwargs)


def city_choice(form, field):
    """Vocabulary function grouping city choices by country."""
    req = form._cw
    vocab = [(req._('<unspecified>'), '')]
    for eid, name in req.execute('Any X,N WHERE X is Country, X name N'):
        rset = req.execute('Any N,E ORDERBY N WHERE'
                           ' X name N, X eid E, X in_country C, C eid %(c)s',
                           {'c': eid})
        if rset:
            # 'optgroup' tag.
            oattrs = {'id': 'country_%s' % eid}
            vocab.append((name, None, oattrs))
            for label, value in rset.rows:
                # 'option' tag.
                vocab.append((label, str(value)))
    return vocab


uicfg.autoform_field_kwargs.tag_subject_of(('Citizen', 'city', '*'),
                                           {'choices': city_choice, 'sort': False})

The first thing (reading from the bottom of the file) is that we've added a choices function on city relation of the Citizen automatic entity form via uicfg. This function city_choice essentially generates the HTML content of the field value by grouping available cities by respective country through the addition of some optgroup tags.

Then, we've overridden the automatic entity form for Citizen entity type by essentially calling a piece of Javascript code fed with the DOM ids of the master and dependent fields. Fields are retrieved by their name (field_by_name method) and respective id using the dom_id method.

Now the Javascript part of the picture:

cw.cubes.demo = {
    // Initialize the dependent form field select and bind update event on
    // change on the master select.
    initDependentFormField: function(masterSelectId,
                                     dependentSelectId) {
        var masterSelect = cw.jqNode(masterSelectId);
        cw.cubes.demo.updateDependentFormField(masterSelect, dependentSelectId);
        masterSelect.change(function(){
            cw.cubes.demo.updateDependentFormField(this, dependentSelectId);
        });
    },

    // Update the dependent form field select.
    updateDependentFormField: function(masterSelect,
                                       dependentSelectId) {
        // Clear previously selected value.
        var dependentSelect = cw.jqNode(dependentSelectId);
        $(dependentSelect).val('');
        // Hide all optgroups.
        $(dependentSelect).find('optgroup').hide();
        // But the one corresponding to the master select.
        $('#country_' + $(masterSelect).val()).show();
    }
}

It consists of two functions. The initDependentFormField is called during form rendering and it essentially bind the second function updateDependentFormField to the change event of the master select field. The latter "update" function retrieves the dependent select field, hides all optgroup nodes (i.e. the whole content of the select widget) and then only shows dependent options that match with selected master option, identified by a custom country_<eid> set by the vocabulary function above.