I know I’ve been posting here a lot lately, so I hope it hasn’t been too annoying…
I have used FormSet
s in the past with great success, even though I didn’t really know what I was doing. I mainly was utilizing code found in some articles and feeling my way around. I feel like I understand it a great deal more now after having gone through that, but I still think that there’s more I don’t understand about it than what I do understand about it. So, here I am about to demonstrate how I don’t know what I’m doing, lol…
I have a new task of creating a page for some data upload. The concept is fairly simple:
- Have a file drag and drop area (with a button to optionally use a file-picker)
# This is established code that works well in multiple contexts
- Next to the drop area, there should be some metadata fields where a user can supply default metadata that is associated with every file dropped/picked
- Both of the above items are a “template form” that is not submitted. Each time files are dropped/picked, a form row is added below it containing a single file input field and the metadata, so that the user can edit the default entries
I got everything to work, but the problem is that the implementation is pretty ugly/bad. It also lacks some niceties, like invalid form submissions clear out what the user entered and errors aren’t displayed near the fields. I would like it to be much better. It initially looks nice though. Here is an example. (Note, I implemented a widget to provide autocomplete dropdowns.)
I went through multiple iterations before getting its essential functionality working.
The main design issue is how to render the template version of the form (the one that’s not submitted). I initially tried to use FormSet.empty_form
, which I would clone using javascript. There were a few problems with that strategy:
- Invalid form submissions cleared the template form
- I could not set initial values for the
empty_form
instance - Safari apparently tries to autofill any text input element whose name attribute includes a dash (and not
-#-
) with phone numbers from the user’s contact card:
I solved the initial value problem by subclassing FormSet
:
class EmptyInitialFormSet(BaseFormSet):
"""This formset extends BaseFormSet to be able to set initial values for BaseFormSet.empty_form so that every
replicated form has whatever initial value you set when you replicate an empty form.
Based on:
https://stackoverflow.com/a/73145095
Example:
MyFormSetClass = formset_factory(MyForm, formset=EmptyInitialFormSet)
initial = {"myfield": "default value"}
MyFormSet = MyFormSetClass(empty_initial=initial)
"""
def __init__(self, *args, **kwargs):
if "empty_initial" in kwargs:
self._empty_initial = kwargs.pop("empty_initial")
super().__init__(*args, **kwargs)
def get_form_kwargs(self, index):
"""Extends BaseFormSet.get_form_kwargs() to return the empty initial data when the index is None,
which is the case when creating empty_form.
"""
kwargs = super().get_form_kwargs(index)
if index is None and hasattr(self, "_empty_initial") and self._empty_initial is not None:
kwargs["initial"] = self._empty_initial
return kwargs
However, I realized that that was pretty useless when I couldn’t solve the other issues.
I couldn’t find much about empty_form
that pertained to how I was trying to use it. In the past, I was using it in a hidden element, and that worked fine.
The only way I was able to avoid the Safari autofill issue was to only send a Form
instance to the template (not a FormSet
) and edit the cloned inputs’ names and IDs using javascript onsubmit (and add the total and initial form inputs).
The problems with that are that errors can’t be associated with fields and I can’t repopulate invalid submissions. So I’m struggling with how to design this. What I have works well enough for now, but I think it needs to be completely refactored.
I was thinking that perhaps I should use 2 separate FormSet
s (one for the template and one for the actual data). Safari has no problem with actual numbered forms, so that wouldn’t be a problem. And everything could get repopulated if there was an invalid submission.
I’m wondering though if there’s a way to reduce the javascript footprint. Would there be a way to clone the template form and include whatever the use has entered without using javascript? The template has a hidden field that needs unhidden.
Let me provide some snippets that might help.
Here’s the template form:
<form class="tbform-control">
<table>
<tr>
<td>
<div id="drop-area">
<!-- Hidden form that does not submit - just used as a target to drop files, from which, just their names are extracted to populate the other form's hidden character input -->
<label class="btn btn-secondary">
<input type="file" multiple id="drop-area-input" onchange="handleFiles(this.files);"/>
Choose Files
</label> <span class="drop-area-message">(Drag and drop here)</span>
</div>
</td>
<td style="vertical-align: middle;">
<table>
<tr style="vertical-align: middle;" name="drop-annot-metadata-row" id="drop-annot-metadata-row-template">
<td style="display: none;" id="hiddenColumn">
<!-- These are necessary for valid forms, but only the first row's values will be used, since it's only the peak annotation file details that need to be replicated. -->
{{ form.mode }}
{{ form.study_doc }}
</td>
<td style="display: none;" id="fileColumn">
{{ form.peak_annotation_file }}
</td>
<td>
{{ form.operator }}
</td>
<td>
{{ form.instrument }}
</td>
<td>
{{ form.protocol }}
</td>
<td>
{{ form.run_date }}
</td>
</tr>
</table>
</td>
</tr>
</table>
</form>
And <!-- HERE -->
is where the cloned rows go:
<form action="{% url 'submission' %}" id="submission-validation" method="POST" enctype="multipart/form-data" class="tbform-control" onsubmit="onSubmit();">
{% csrf_token %}
<table id="peak-annot-forms-elem">
<!-- HERE -->
</table>
<span class="text-danger">{{ form.peak_annotation_file.errors }}</span>
<span class="text-danger">{{ form.operator.errors }}</span>
<span class="text-danger">{{ form.instrument.errors }}</span>
<span class="text-danger">{{ form.protocol.errors }}</span>
<span class="text-danger">{{ form.run_date.errors }}</span>
<span class="text-danger">{{ form.non_field_errors }}</span>
<button type="submit" class="btn btn-primary" id="submit">Download Template</button>
<button type="reset" class="btn btn-secondary" id="clear" onclick="document.getElementById('drop-area-input').value = null;document.getElementById('submission-validation').reset();clearPeakAnnotFiles();disablePeakAnnotForm();">Clear</button>
</form>
And this is the javascript that massages the form into a formset:
function onSubmit() {
numForms = prepareFormsetForms();
insertFormsetManagementInputs(numForms);
console.log(dataSubmissionForm.innerHTML)
}
function prepareFormsetForms() {
formRows = getFormRows();
for (let r = 0; r < formRows.length; r++) {
formRow = formRows[r];
inputElems = formRow.querySelectorAll('input');
// Prepend attributes 'id', 'name', and 'for' with "form-0-", as is what django expects from a formset
const prefix = 'form-' + r.toString() + '-';
for (let i = 0; i < inputElems.length; i++) {
inputElem = inputElems[i];
// If this input element contains the form-control class (i.e. we're using the presence of the form-control class
// to infer that the element is an explicitly added input element (not some shadow element)
if (inputElem.classList.contains('form-control')) {
if (inputElem.for) {
inputElem.for = prefix + inputElem.for;
}
if (inputElem.id) {
inputElem.id = prefix + inputElem.id;
}
if (inputElem.name) {
inputElem.name = prefix + inputElem.name;
}
}
}
}
return formRows.length;
}
function insertFormsetManagementInputs(numForms) {
// '<input type="hidden" name="form-TOTAL_FORMS" value="' + numForms.toString() + '" id="id_form-TOTAL_FORMS">';
const totalInput = document.createElement('input');
totalInput.setAttribute("type", "hidden");
totalInput.setAttribute("name", "form-TOTAL_FORMS");
totalInput.setAttribute("value", numForms.toString());
totalInput.setAttribute("id", "id_form-TOTAL_FORMS");
dataSubmissionForm.appendChild(totalInput);
// '<input type="hidden" name="form-INITIAL_FORMS" value="0" id="id_form-INITIAL_FORMS">';
const initialInput = document.createElement('input');
initialInput.setAttribute("type", "hidden");
initialInput.setAttribute("name", "form-INITIAL_FORMS");
initialInput.setAttribute("value", "0");
initialInput.setAttribute("id", "id_form-INITIAL_FORMS");
dataSubmissionForm.appendChild(initialInput);
}