Reduce javascript UI code with Django forms
Typically, I try to implement a dynamic UI feature without javascript on the first pass. Then, I layer in a little javascript goodness to make it more responsive. Whenever I stray from this practice, I inevitably end up re-discovering why I started doing this in the first place. At some point, I become horrified at the amount of javascript I have written, and disheartened at how long it's taking to modify. In short, things have gotten complicated.
Everything should be made as simple as possible, but no simpler. - Albert Einstein
Here is an example from just the other day. The requirements specified a form behavior that we didn't have already on the site. On a list of job records, there should be a close job link. Clicking the link reveals a new form that must be filled out successfully before the job is actually closed. When the form is successfully posted, hide the form and grey out the row. Here is the workflow:
Clicking close job actually just reveals the real form.
Attempting to post that form may throw validation exceptions.
When the form is successfully posted, it goes away and the row gets grayed out.
Of course, this is not an overly complicated example, but there are a few moving pieces. Nevertheless, I ended up with almost 100 lines of javascript code, even with the validation logic for the inner form being server side.
$(document).ready(function() { // hitting the initial close link, GETs the form from the back-end and shows it $(".jobs-record-list a.toggle-job-close").click(function (){ var link = $(this); var record = link.parents(".record"); var form_box = record.find(".job-close-form-box"); var job_link = record.find(".title a"); $.ajax({ url: link.attr("href") + "?ajax", type: "GET", success: function (data) { form_box.html(data); bind_job_close_form(form_box); }, error: function (jqXHR, textStatus, errorThrown) { // fallback to closing the job from the job overview page window.location = form.attr(job_link.attr("href")); } }); link.remove(); return false; }); // process the inner form submit to actually close the job, then put the open link back on the page function bind_job_close_form(form_box) { var form = form_box.find("form"); var parentRow = form.parents(".record"); form.submit(function () { $.ajax({ url: form.attr("action") + "?ajax", type: "POST", data: form.serialize(), success: function (data) { form_box.hide(); parentRow.find(".content").toggleClass("record-disabled"); parentRow.find(".block").toggleClass("record-disabled"); var link = form.find("a"); link.text(link.text() == "close job" ? "open job" : "close job"); }, error: function (jqXHR, textStatus, errorThrown) { form_box.html(jqXHR.response); bind_job_close_form(form_box); // rebind event handler } }); return false; }); } // if the page is refresh after the inner form is shown, re-show it $("form.toggle-job-open-closed").submit(function () { var form = $(this); var questions = form.find(".questions"); if (questions.is(":hidden")) { questions.show(); return false; } return true; }); // re-opening a job, POST to the server then gray out the row $("a.toggle-job-open").submit(function () { var form = $(this); var parentRow = form.parents(".record"); $.ajax({ url: form.attr("action") + "?ajax", data: form.serialize(), type: form.attr("method"), success: function (data) { parentRow.find(".content").toggleClass("record-disabled"); parentRow.find(".block").toggleClass("record-disabled"); } }); return false; }); });
At this stage, my form is rather simple, but I have a view that's trying to do a lot; processing both the Ajax and non-javascript versions of requests this page. I'm also doing some craziness to pass the current form around in the session scope. This is so that if the user refreshes, the form is still in the same state. Here is just the form:
class OpenCloseJobForm(Html5Form): candidate = forms.ChoiceField(required=True) def __init__(self, *args, **kwargs): kwargs = helpers.copy_to_self(self, "job", kwargs) super(OpenCloseJobForm, self).__init__(*args, **kwargs) self.fields["candidate"].label = "Who did you hire for this job?" self.fields["candidate"].required = False self.fields["candidate"].choices = [("", "-------------")] candidates = Candidate.objects.filter(job=self.job) self.fields["candidate"].choices += [("Candidates", [(c.id, c) for c in candidates] + [("other", "Someone else" if candidates else "Candidate not in %s" % settings.SITE_NAME)])] self.fields["candidate"].choices += [("none", "No hire made")] def clean_candidate(self): candidate = self.cleaned_data.get("candidate") if not candidate: raise forms.ValidationError("This field is required.") return candidate def save(self): choice = self.data.get("candidate") if not choice: return None Hire.objects.filter(job=self.job).delete() if choice == "none": return None candidate_id = choice candidate = None if choice == "other" else Candidate.objects.get(id=candidate_id) hire = Hire(job=self.job, candidate=candidate) hire.save() return hire
At this point, I decide to refactor to move as much of the UI logic as possible into the form itself, and reduce the amount of javsacript. I end up with a widget and a form that implements the show/hide inner form behavior. That part grows from 40 lines to 90 lines.
class ToggleLinkWidget(widgets.HiddenInput): button_value = None def __init__(self, attrs=None, check_test=bool, button_value="toggle"): super(ToggleLinkWidget, self).__init__(attrs) self.button_value = button_value def render(self, name, value, attrs=None): html = super(ToggleLinkWidget, self).render(name, value, attrs) link = "" if not value: button_value = self.button_value return "<input class='button-link' type='submit' name='%(name)s' value='%(button_value)s'>" % locals() return html class OpenCloseJobForm(Html5Form): job_id = fields.CharField(required=True, widget=widgets.HiddenInput()) expanded = fields.BooleanField(required=False, initial=False, widget=ToggleLinkWidget(button_value="close")) candidate = fields.ChoiceField(required=True) def __init__(self, *args, **kwargs): super(OpenCloseJobForm, self).__init__(*args, **kwargs) self.job = Job.objects.get(id=self.data.get("job_id")) self.fields["candidate"].label = "Who did you hire for this job?" self.fields["candidate"].required = False self.fields["candidate"].choices = [("", "-------------")] candidates = Candidate.objects.filter(job=self.job) self.fields["candidate"].choices += [("Candidates", [(c.id, c) for c in candidates] + [("other", "Someone else" if candidates else "Candidate not in %s" % settings.SITE_NAME)])] self.fields["candidate"].choices += [("none", "No hire made")] # the close button is NOT toggled on; hide thother fields if not self.is_expanded(): del self.fields["candidate"] # don't show validation errors when first expanding the form if not self.data.get("inner-submit"): self._errors = {} def is_valid(self): if self.data.get("inner-submit"): return super(OpenCloseJobForm, self).is_valid() return False def is_expanded(self): return self.data and self.data.get("expanded") == "close" def as_ul(self): html = super(OpenCloseJobForm, self).as_ul() if self.is_expanded(): button = "<input type='submit' name='inner-submit' value='Close Job'>" return mark_safe("<div class='expanded-box'>%(html)s %(button)s</div>" % locals()) return html def clean_candidate(self): candidate = self.cleaned_data.get("candidate") if not candidate: raise forms.ValidationError("This field is required.") return candidate def save(self, request): self.job.is_open = not self.job.is_open if self.job.is_open: Hire.objects.filter(job=self.job).delete() messages.success(request, "%s was set to open and is publicly available." % self.job) else: messages.success(request, "%s was closed and is no longer available publicly." % self.job) self.job.save() choice = self.data.get("candidate") if not choice: return None Hire.objects.filter(job=self.job).delete() if choice == "none": return None candidate_id = choice candidate = None if choice == "other" else Candidate.objects.get(id=candidate_id) hire = Hire(job=self.job, candidate=candidate) hire.save() return hire
But look at the javascript, which has shrunk from 90 lines to 40.
$(document).ready(function() { function submit_handler() { var button = $(this); var record = button.parents(".record"); var form = record.find(".job-prompt-hire-form"); var form_box = form.parents(".form-box"); var job_link = record.find(".title a"); var form_data = form.serialize(); // also record which input button was pressed if (button.is(".button-link")) { form_data = form_data += "&expanded=close"; } else { form_data = form_data += "&inner-submit=Close Job"; } $.ajax({ url: form.attr("action") + "?ajax", data: form_data, type: form.attr("method"), success: function (data) { form_box.html(data); if (!data) { record.find(".content").toggleClass("record-disabled"); record.find(".block").toggleClass("record-disabled"); } }, error: function (jqXHR, textStatus, errorThrown) { // fallback to closing the job from the job overview page window.location = form.attr(job_link.attr("href")); } }); return false; } // jQuery does not support live submit handling in IE // also, need to record actual pressed element $(".jobs-record-list .job-prompt-hire-form .button-link").live("click", submit_handler); $(".jobs-record-list .job-prompt-hire-form input[type=submit]").live("click", submit_handler); });
Interestingly, the total line count has remained around the same. This suggests that for my style, the code density of python and jQuery is about the same. Personally, I find the python code to be more maintainable. In particular, I very much like that I'm minimizing the number of times jQuery is changing the DOM, and rebinding events. All of the state transitions are happening in the form. As a side effect, the show/hide behavior is totally functional without javascript enabled.