24. Dialog Model Forms¶
Together with Dialog Forms, django-formset also offers dialog model forms. These forms are, as one might expect, bound to a Django model. They are very useful for a common use case:
A form with a select element to choose an entity from a foreign key relationship.
We typically use the Selectize Widget to offer a selection method for choosing an entity from a foreign relation. However, if that foreign entity does not yet exist, the user may want to add it on the fly without leaving the current form. This is a widespread pattern in many web applications, and django-formset provides a way to handle this as well.
Assume that we have a form for an issue tracker. This form has a field for the issue’s reporter,
which is a foreign key to a model named Reporter. If that reporter does not exist, we would have
to switch to a different form, create a reporter object there, return back to the current form and
then select that newly created reporter from the dropdown list. This is not very user-friendly, and
instead we would like to be able to add a new reporter on the fly using a dialog form popping out
of our current “Issue” form.
from django.forms.fields import IntegerField
from django.forms.widgets import HiddenInput
from formset.dialog import DialogModelForm
from formset.formfields.activator import Activator
from formset.widgets import Button
from testapp.models import Reporter
class ChangeReporterDialogForm(DialogModelForm):
title = "Add/Edit Reporter"
induce_open = 'issue.edit_reporter:active || issue.add_reporter:active'
induce_close = '.change:active || .cancel:active'
id = IntegerField(
widget=HiddenInput,
required=False,
help_text="Primary key of Reporter object. Leave empty to create a new object.",
)
cancel = Activator(
widget=Button(action='activate("clear")'),
)
change = Activator(
widget=Button(
action='submitPartial -> setFieldValue(issue.reporter, ^reporter_id) -> activate("clear")',
),
)
class Meta:
model = Reporter
fields = ['id', 'full_name']
def is_valid(self):
if self.partial:
return super().is_valid()
self._errors = {}
return True
Here we create the dialog form ChangeReporterDialogForm. It inherits from DialogModelForm
and is a combination of the well known Django ModelForm and Dialog Forms from the previous
chapter. In class Meta we specify the model and the form fields. Since we also want to edit
existing entities from our model Reporter, we need a hidden identifier as a reference. Here we
use the hidden field named id, which points to the primary key of an editable Reporter
object.
With the attributes induce_open and induce_close we declare the conditions when the dialog
shall be opened or closed respectively. The buttons to close the dialog are part of the dialog form
itself. Here one wants to close the dialog, when either the button named change or cancel is
activated. In order to open this dialog, the user must activate either the buttons named
edit_reporter or add_reporter. They are declared as Activator fields in the form
IssueForm (see below).
The action queue added to the button named change is specified as:
submitPartial -> setFieldValue(issue.reporter, ^reporter_id) -> activate("clear")
Let’s go through it in detail:
submitPartial
This submits the complete collection of forms but tells the accepting Django endpoint, to only
validate the current form, ie. ChangeReporterDialogForm. Check method form_collection_valid
in view IssueCollectionView on how this validated form is further processed (see below). The
response of this action then is handled over to the next action in the queue:
setFieldValue(issue.reporter, ^reporter_id)
This takes the field reporter_id from the response and applies it to the field named
issue.reporter. Here we must use the caret symbol ^ so that django-formset can
distinguish a server side response from another field in this collection of forms.
activate("clear")
This action just activates the button, so that induce_close is triggered to close the dialog.
The parameter “clear” then implies to clear all the fields.
from django.forms.fields import CharField
from django.forms.models import ModelChoiceField, ModelForm
from formset.formfields.activator import Activator
from formset.widgets import Button, Selectize
from testapp.models import IssueModel
class IssueForm(ModelForm):
title = CharField()
reporter = ModelChoiceField(
queryset=Reporter.objects.all(),
widget=Selectize(
search_lookup='full_name__icontains',
),
)
edit_reporter = Activator(
widget=Button(
action='activate(prefillPartial(issue.reporter))',
attrs={'df-disable': '!issue.reporter'},
),
)
add_reporter = Activator(
widget=Button(action='activate')
)
class Meta:
model = IssueModel
fields = ['title', 'reporter']
This is the main form of the collection and is used to edit the “Issue”-related fields. For
demonstration purposes it just offers one field named title, a real issue-tracker application
would of course offer many more fields.
In addition to its lonely title field, this form offers the two activators as mentioned in the
previous section. They are named edit_reporter and add_reporter. When clicked, they induce
the opening of the dialog form as already explained. However, the button edit_reporter is when
clicked, configured to “prefill” the dialog form’s content using the value of the field
issue.reporter.
prefillPartial(path.to.field)
The action prefillPartial typically is used inside an activate(…)-invocation. It is used to
prefill a dialog form with data fetched from the server. Fetching this data is done by sending the
value of the field path.to.field to the server. Implicitly this fetch operation also adds the
path to the dialog form to be filled. The server then responds with the related data, here with a
dictionary containing the values for the fields id and full_name. This response then is
applied to the given dialog form filling the fields with the values sent by the server.
This feature allows a user to first select a reporter, and immediately edit its content using a
dialog form. Here we also add the attribute df-disable=!issue.reporter to the button labeled
“Edit Reporter” in order to disable it when no reporter is selected.
from django.forms.models import construct_instance
from formset.collection import FormCollection
class EditIssueCollection(FormCollection):
change_reporter = ChangeReporterDialogForm()
issue = IssueForm()
def construct_instance(self, main_object):
assert not self.partial
instance = construct_instance(self.valid_holders['issue'], main_object)
instance.save()
return instance
This form collection combines our issue editing form with the dialog form to edit or add a reporter.
Note that in this collection, method construct_instance has been overwritten. On submission, it
just constructs an instance of type IssueModel but ignores any data related to the Reporter-
model. The latter is handled in method form_collection_valid as explained in the next section:
from django.http import JsonResponse, HttpResponseBadRequest
from formset.views import EditCollectionView
class IssueCollectionView(EditCollectionView):
model = IssueModel
collection_class = EditIssueCollection
def form_collection_valid(self, form_collection):
if form_collection.partial:
if not (valid_holder := form_collection.valid_holders.get('change_reporter')):
return HttpResponseBadRequest("Form data is missing.")
if id := valid_holder.cleaned_data['id']:
reporter = Reporter.objects.get(id=id)
construct_instance(valid_holder, reporter)
else:
reporter = construct_instance(valid_holder, Reporter())
reporter.save()
return JsonResponse({'reporter_id': reporter.id})
return super().form_collection_valid(form_collection)
This view handles our form collection consisting of the two forms ChangeReporterDialogForm and
IssueForm. On a complete submission of this view, method form_collection_valid behaves
as implemented by default. However, since the dialog form is submitted partially, we use that
information to modify the default behavior:
If the hidden field named id has a value, the dialog form is opened to edit a reporter.
Therefore we fetch that object from the database and change it using the modified form’s content.
If the hidden field named id has no value, the dialog form is opened to add a reporter.
Here we can just construct a new instance using an empty Reporter object.
In both cases, the primary key of the edited or added Reporter object is sent back to the
client using the statement JsonResponse({'reporter_id': reporter.id}). Remember the button’s
action setFieldValue(issue.reporter, ^reporter_id) as mentioned in the first section. This takes
that response value from reporter_id and applies it to the field named issue.reporter. The
latter is implemented using the Selectize Widget, which in consequence fetches the server to receive
the new value for the edited or added Reporter object.