django-frontend-forms example project

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.

/static/images/form_validation_1.png

A form in the modal dialog

/static/images/form_validation_2.png

While the form does not validate, we keep the dialog open

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:

/static/images/form_validation_extra_button.png

Can we hide the "Send" button and use the "Save" button from the footer instead ?

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.

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.