Contents
1 Form submission from a modal
We've successfully injected data retrieved from the server in our modals, but did not really interact with the user yet.
When the modal body contains a form, things start to become interesting and tricky.
1.1 Handling form submission
When a form submission is involved, the modal life cycle has to be modified as follows:
First and foremost, we need to prevent the form from performing its default submit.
If not, after submission we'll be redirected to the form action, outside the context of the dialog.
We'll do this binding to the form's submit event, where we'll serialize the form's content and sent it to the view for validation via an Ajax call.
Then, upon a successufull response from the server, we'll need to further investigate the HTML received:
- if it contains any field error, the form did not validate successfully, so we update the modal body with the new form and its errors
- otherwise, user interaction is completed, and we can finally close the modal
django-frontend-forms, upon detecting a form in the content downloaded from the server, already takes care of all these needs automatically, and keeps refreshing the modal after each submission until the form validation succeedes.
1.2 Implementation
If you're curious, here below is a detailed explanation of how all this is achieved.
Form detection happens at the end of modal opening:
open(event=null, show=true) { ... // Load remote content if (self.options.url) { self._load().done(function(data, textStatus, jqXHR) { var form = self.element.find('.dialog-content .dialog-body form'); if (form.length == 1) { // Manage form self._form_ajax_submit(); } }); } }
In case, the code triggers a call to the helper method _form_ajax_submit(), which is the real workhorse.
I developed it adapting the inspiring ideas presented in this brilliant article:
Use Django's Class-Based Views with Bootstrap Modals
Here's the full code:
_form_ajax_submit() { var self = this; var content = self.element.find('.dialog-content'); var header = content.find('.dialog-header'); var body = content.find('.dialog-body'); var footer = content.find('.dialog-footer'); var form = content.find('.dialog-body form'); // use footer save button, if available var btn_save = footer.find('.btn-save'); if (btn_save) { form.find('.form-submit-row').hide(); btn_save.off().on('click', function(event) { form.submit(); }); } // Give focus to first visible form field if (self.options.autofocus_first_visible_input) { form.find('input:visible').first().focus().select(); } // bind to the form’s submit event form.on('submit', function(event) { // prevent the form from performing its default submit action event.preventDefault(); header.addClass('loading'); // serialize the form’s content and send via an AJAX call // using the form’s defined method and action var url = form.attr('action') || self.options.url; var method = form.attr('method') || 'post'; var data = form.serialize(); self._notify('submitting', {method: method, url: url, data:data}); $.ajax({ type: method, url: url, data: data, cache: false, crossDomain: true, headers: { // make sure request.is_ajax() return True on the server 'X-Requested-With': 'XMLHttpRequest' } }).done(function(xhr, textStatus, jqXHR) { // update the modal body with the new form body.html(xhr); // If the server sends back a successful response, // we need to further check the HTML received // If xhr contains any field errors, // the form did not validate successfully, // so we keep it open for further editing //if ($(xhr).find('.has-error').length > 0) { if ($(xhr).find('.has-error').length > 0 || $(xhr).find('.errorlist').length > 0) { self._notify('loaded', {url: url}); self._form_ajax_submit(); } else { // otherwise, we've done and can close the modal self._notify('submitted', {method: method, url: url, data: data}); self.close(); } }).fail(function(jqXHR, textStatus, errorThrown) { console.log('ERROR: errorThrown=%o, textStatus=%o, jqXHR=%o', errorThrown, textStatus, jqXHR); FrontendForms.display_server_error(errorThrown); }).always(function() { header.removeClass('loading'); }); }); }
We start by taking care of the submit button embedded in the form. While it's useful and necessary for the rendering of a standalone page, it's rather disturbing in the modal dialog:
Here's the relevant code:
// use footer save button, if available var btn_save = footer.find('.btn-save'); if (btn_save) { form.find('.form-submit-row').hide(); btn_save.off().on('click', function(event) { form.submit(); }); }
Then, we proceed by hijacking the form submission:
// bind to the form’s submit event form.on('submit', function(event) { // prevent the form from performing its default submit action event.preventDefault(); ... var data = form.serialize(); $.ajax({..., data: data, ...
Finally, we need to detect any form errors after submission, and either repeat the whole process or close the dialog:
}).done(function(xhr, textStatus, jqXHR) { // update the modal body with the new form body.html(xhr); if ($(xhr).find('.has-error').length > 0 || $(xhr).find('.errorlist').length > 0) { self._form_ajax_submit(); } else { self.close(); }
One last detail: during content loading, we add a "loading" class to the dialog header, to make a spinner icon visible until we're ready to either update or close the modal.
1.3 Giving a feedback after successful form submission
Sometimes, you might want to notify the user after successful form submission.
To obtain this, all you have to do, after the form has been validated and saved, is to return an HTML fragment with no forms in it; in this case:
- the popup will not close
- the "save" button will be hidden
thus giving to the user a chance to read your feedback.
Note
Code sample: Dialogs with Form validation
In these samples, a sleep of 1 sec has been included in the view to simulate network latency or a more complex elaboration which might occur in real situations
2 Creating or updating a Django Model in the front-end
We can now apply what we've built so far to edit a specific Django model from the front-end.
2.1 Creating a new model
This is the view:
from frontend_forms.decorators import check_logged_in from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from .forms import ArtistCreateForm @check_logged_in() def add_artist(request): if not request.user.has_perm('backend.add_artist'): raise PermissionDenied # Either render only the modal content, or a full standalone page if request.is_ajax(): template_name = 'frontend_forms/generic_form_inner.html' else: template_name = 'dialogs/generic_form.html' object = None if request.method == 'POST': form = ArtistCreateForm(data=request.POST) if form.is_valid(): object = form.save() if not request.is_ajax(): # reload the page message = 'The object "%s" was added successfully.' % object messages.success(request, message) next = request.META['PATH_INFO'] return HttpResponseRedirect(next) # if is_ajax(), we just return the validated form, so the modal will close else: form = ArtistCreateForm() return render(request, template_name, { 'form': form, 'object': object, })
Note that we're using a generic template from the library called frontend_forms/generic_form_inner.html:
{% load i18n frontend_forms_tags %} <div class="row"> <div class="col-sm-12"> <form action="{{ action }}" method="post" class="form {{form.form_class}}" novalidate autocomplete="off"> {% csrf_token %} {% render_form form %} <input type="hidden" name="object_id" value="{{ object.id|default:'' }}"> <div class="form-submit-row"> <input type="submit" value="Save" /> </div> </form> </div> </div>
Chances are we'll reuse it unmodified for other Models as well.
You can of course supply your own template when form rendering needs further customizations.
The form is minimal:
from django import forms class ArtistCreateForm(forms.ModelForm): class Meta: model = Artist fields = [ 'name', 'notes', ]
On successful creation, we might want to update the user interface;
in the example, for simplicity, we just reload the entire page, but before doing that we also retrieve the id of the newly created object, to enhance it after page refresh;
this could be conveniently used, instead, for in-place page updating.
<script language="javascript"> dialog_artist_add = new Dialog({ url: "{% url 'samples:artist-add-basic' %}", dialog_selector: '#dialog_generic', html: '<h1>Loading ...</h1>', width: '600px', min_height: '200px', title: '<i class="fa fa-calculator"></i> Create an Artist ...', button_save_label: "Save", button_save_initially_hidden: true, enable_trace: true, callback: function(event_name, dialog, params) { switch (event_name) { case "submitting": FrontendForms.overlay_show('.dialog-body'); break; case "loaded": FrontendForms.overlay_hide('.dialog-body'); break; case "submitted": var object_id = dialog.element.find('input[name=object_id]').val(); // Reload page, with last selection enhanced var url = new URL(document.location.href); url.searchParams.set('selected_artist', object_id); FrontendForms.gotourl(url, show_layer=true); break; } } }); </script>
2.2 Updating an existing object
We treat the update of an existing object in a similar fashion, but binding the form to the specific database record.
Urls:
path('artist/add-basic/', views.add_artist, name="artist-add-basic"), path('artist/<uuid:pk>/change-basic/', views.update_artist, name="artist-change-basic"),
The view:
from frontend_forms.decorators import check_logged_in from django.views.decorators.cache import never_cache from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from frontend_forms.utils import get_object_by_uuid_or_404 from .forms import ArtistUpdateForm @check_logged_in() def update_artist(request, pk): if not request.user.has_perm('backend.change_artist'): raise PermissionDenied # Either render only the modal content, or a full standalone page if request.is_ajax(): template_name = 'frontend_forms/generic_form_inner.html' else: template_name = 'dialogs/generic_form.html' object = get_object_by_uuid_or_404(Artist, pk) if request.method == 'POST': form = ArtistUpdateForm(instance=object, data=request.POST) if form.is_valid(): object = form.save() if not request.is_ajax(): # reload the page next = request.META['PATH_INFO'] return HttpResponseRedirect(next) # if is_ajax(), we just return the validated form, so the modal will close else: form = ArtistUpdateForm(instance=object) return render(request, template_name, { 'form': form, 'object': object, })
and the form:
class ArtistUpdateForm(forms.ModelForm): class Meta: model = Artist fields = [ 'description', 'notes', ]
The Dialog is much similar to the previous one; we just re-initialize it's url with the required object id before opening it:
<a href="{% url 'samples:artist-change-basic' artist.id %}" class="btn btn-primary" onclick="open_artist_change_dialog(event); return false;">Edit</a> ... <script language="javascript"> function open_artist_change_dialog(event) { event.preventDefault(); var url = $(event.target).attr('href'); dialog_artist_change.options.url = url; dialog_artist_change.open(event); } </script>
2.3 Possible optimizations
In the code above, we can recognize at list three redundancies:
- the two model forms are identical
- the two views are similar
- and, last but not least, we might try to generalize the views for reuse with any Django model
We'll investigate all these opportunities later on;
nonetheless, it's nice to have a simple snippet available for copy and paste to be used as a starting point anytime a specific customization is in order.
Note
Code sample: Editing a Django Model
3 Creating or updating a Django Model (revised)
Let's start our optimizations by removing some redundancies.
Sharing a single view for both creating a new specific Model or updating an existing one is now straightforward; see edit_artist() belows:
from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from frontend_forms.utils import get_object_by_uuid_or_404 from frontend_forms.decorators import check_logged_in from .forms import ArtistUpdateForm @check_logged_in() def edit_artist(request, pk=None): """ Either add a new Artist, or change an existing one """ # Retrieve object if pk is None: # "Add" mode object = None required_permission = 'backend.add_artist' else: # "Change" mode object = get_object_by_uuid_or_404(Artist, pk) required_permission = 'backend.change_artist' # Check user permissions if not request.user.is_authenticated or not request.user.has_perm(required_permission): raise PermissionDenied # Either render only the modal content, or a full standalone page if request.is_ajax(): template_name = 'frontend_forms/generic_form_inner.html' else: template_name = 'dialogs/generic_form.html' if request.method == 'POST': form = ArtistUpdateForm(instance=object, data=request.POST) if form.is_valid(): object = form.save() if not request.is_ajax(): # reload the page if pk is None: message = 'The object "%s" was added successfully.' % object else: message = 'The object "%s" was changed successfully.' % object messages.success(request, message) next = request.META['PATH_INFO'] return HttpResponseRedirect(next) # if is_ajax(), we just return the validated form, so the modal will close else: form = ArtistUpdateForm(instance=object) return render(request, template_name, { 'form': form, 'object': object, })
When "pk" is None, we act in add mode, otherwise we retrieve the corresponding object to change it.
Both "add" and "change" URL patterns point to the same view, but the first one doesn’t capture anything from the URL and the default value of None will be used for pk.
urlpatterns = [ ... path('artist/add/', views.edit_artist, name="artist-add"), path('artist/<uuid:pk>/change/', views.edit_artist, name="artist-change"), ... ]
We also share a common form:
class ArtistEditForm(forms.ModelForm): """ To be used for both creation and update """ class Meta: model = Artist fields = [ 'description', 'notes', ]
The javascript Dialog can be refactored in a completely generic way, with no reference to the specific Model in use: infact, it's just a plain dialog which submits an arbitrary form.
You only need to provide the necessary url (and probably a suitable title) before opening the dialog:
<a href="{% url 'samples:artist-add' %}" class="btn btn-primary" onclick="open_artist_edit_dialog(event, 'Create an Artist ...'); return false;">Add</a> ... {% for artist in artists %} ... <a href="{% url 'samples:artist-change' artist.id %}" class="btn btn-primary" onclick="open_artist_edit_dialog(event); return false;">Edit</a> {% endfor %} <script language="javascript"> function open_artist_edit_dialog(event, title) { event.preventDefault(); var url = $(event.target).attr('href'); dialog_edit.options.url = url; dialog_edit.options.title = title; dialog_edit.open(event); } $(document).ready(function() { dialog_edit = new Dialog({ //url: none, dialog_selector: '#dialog_generic', html: '<h1>Loading ...</h1>', width: '600px', min_height: '200px', //title: none, button_save_label: "Save", button_save_initially_hidden: true, enable_trace: true, callback: function(event_name, dialog, params) { switch (event_name) { case "submitting": FrontendForms.overlay_show('.dialog-body'); break; case "loaded": FrontendForms.overlay_hide('.dialog-body'); break; case "submitted": var object_id = dialog.element.find('input[name=object_id]').val(); // Reload page, with last selection enhanced var url = new URL(document.location.href); url.searchParams.set('selected_record', object_id); FrontendForms.gotourl(url, show_layer=true); break; } } }); }); </script>
3.1 Deleting a Model
Object deletion can be achieved preparing a view like this:
def delete_artist(request, pk): required_permission = 'backend.delete_artist' if not request.user.is_authenticated or not request.user.has_perm(required_permission): raise PermissionDenied object = get_object_by_uuid_or_404(Artist, pk) object_id = object.id object.delete() return JsonResponse({'object_id': object_id})
then invoking it via Ajax after user confirmation:
<a href="{% url 'samples:artist-delete' artist.id %}" class="btn btn-danger" onclick="delete_artist(event, 'Deleting {{artist.name}}'); return false;">Delete</a> <script> function delete_artist(event, title) { event.preventDefault(); var url = $(event.target).attr('href'); FrontendForms.confirmRemoteAction( url, { title: title, text: 'Are you sure?', confirmButtonClass: 'btn-danger', icon: 'question' }, function(data) { var row = $('tr#artist-'+data.object_id); row.remove(); Swal.fire({ text: 'Artist "' + data.object_id + '" has been deleted', icon: 'warning' }) }, data=true // set to any value to obtain POST ); } </script>
In the above snippet, we use the received object id to remove the corresponding table row after deletion.
Note
Code sample: Editing a Django Model (Revised)
4 Advanced forms
4.1 Using advanced field widgets
Nothing prevents you from using advanced widgets in the form; the only provision is to rebind all required javascript handlers to the input items after each form submission; for that, use the loaded event.