Skip to content

Commit

Permalink
Add documentation for dialog model forms
Browse files Browse the repository at this point in the history
  • Loading branch information
jrief committed Jun 3, 2024
1 parent 6df87d1 commit 8bf6055
Show file tree
Hide file tree
Showing 3 changed files with 209 additions and 13 deletions.
219 changes: 207 additions & 12 deletions docs/source/dialog-model-forms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,215 @@ Dialog Model Forms

.. versionadded:: 1.5

Together with :ref:`dialog-forms`, **django-formset** also offers dialog model forms. These forms
then are, as one might expect, bound to a Django model. They are very useful for a common use case:
Together with :ref:`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 a model object as a foreign key.
*A form with a select element to choose an object from a foreign key relationship.*

But if
that object yet does not exist, you 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 do
this.
This is a very common use case and we typically use the :ref:`selectize` to offer a selection method
to the user. However, if that foreign relation 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 we we have a form for an issue tracker. This form has a field for the issue's assignee, which
is a foreign key to the user model. If that user does not exist, we would have to leave the form and
create it somewhere else, then come back to the current form and select the user from the dropdown
list. This is not very user-friendly, and we would like to be able to add a new user on the fly.
Assume we have a form for an issue tracker. This form has a field for the issue's reporter, which
is a foreign key to the user model. If that user does not exist, we would have to switch to a
different form, create the reporter object there, return back to the current form and then select
the newly created user from the dropdown list. This is not very user-friendly, and we would like to
be able to add a new user on the fly using a dialog form.

.. django-view:: 1_reporter_dialog
:caption: dialog.py
:hide-view:

We want to be able to add a new user on the fly, without leaving
from django.forms.fields import IntegerField
from django.forms.widgets import HiddenInput
from formset.dialog import DialogModelForm
from formset.fields 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 the :ref:`dialog-forms` from the
previous chapter. In class ``Meta`` we specify the model and the form fields. Since we also want
to edit existing objects from our model ``Reporter``, we need a hidden identifier for reference.
Here we use the hidden field named ``id``, which points to the primary key of an editable
``Reporter`` object.

.. _ModelForm: https://docs.djangoproject.com/en/stable/topics/forms/modelforms/

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:

.. code-block:: javascript
submitPartial -> setFieldValue(issue.reporter, ^reporter_id) -> activate("clear")
Let's go through it in detail:

.. rubric:: ``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 view then is handled over to the next action in the queue:

.. rubric:: ``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 collections of forms.

.. rubric:: ``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.

.. django-view:: 2_issue_form
:caption: form.py
:hide-view:

from django.forms.fields import CharField
from django.forms.models import ModelChoiceField, ModelForm
from formset.fields 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("prefill", 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. It just offers
one field named ``title``; this is just for demonstration purpose, a real 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
to open the dialog form as already explained. However, the button ``edit_reporter`` is when clicked,
configured to "prefill" the form's content using the value of the field ``issue.reporter``.
Prefilling is done by fetching the form's related data from the server and changing the field's
values accordingly. Here the fields named ``id`` and ``full_name`` are filled with data fetched from
the server.

This feature allows a user to first select a reporter, and then edit its content using the given
dialog form.

Here we also add the attribute ``df-disable=!issue.reporter`` to the button in order to disable
it when no reporter is selected.

.. django-view:: 3_issue_collection
:caption: collection.py
:hide-view:

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:

.. django-view:: 4_issue_view
:view-function: type('IssueCollectionView', (SessionModelFormViewMixin, dialog_model_forms.IssueCollectionView), {}).as_view(template_name='form-collection.html', extra_context={'framework': 'bootstrap', 'pre_id': 'issue-result'}, collection_kwargs={'renderer': FormRenderer(field_css_classes='mb-2')})
:swap-code:
:caption: views.py

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 behaviour:

If the hidden field named ``id`` has a value, the dialog form was 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 was 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 send 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 :ref:`selectize`, which in consequence fetches the server to receive
the new value for the edited or added ``Reporter`` object.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Django's counterparts.
selectize
dual-selector
dialog-forms
dialog-model-forms
richtext
richtext-extensions
slug-input
Expand Down
2 changes: 1 addition & 1 deletion docs/source/model-collections.rst
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ of the model instances ``Department`` or ``Team``. Since newly created instances
key yet, it is marked with ``required=False`` to make it optional.

Finally, our ``CompanyCollection`` must be made editable and served by a Django view class. Here we
can use the the view class :ref:`formset.views.EditCollectionView` as in the previous example.
can use the the view class :class:`formset.views.EditCollectionView` as in the previous example.

.. django-view:: company_view
:view-function: type('CompanyCollectionView', (SessionFormCollectionViewMixin, model_collections.CompanyCollectionView), {}).as_view(extra_context={'framework': 'bootstrap', 'pre_id': 'company-collection'}, collection_kwargs={'renderer': FormRenderer(field_css_classes='mb-2')})
Expand Down

0 comments on commit 8bf6055

Please sign in to comment.