Got a perfect _server side_ solution for accepting forms with nested

formsets, automatically generated from a reusable app foreign key relations
to their section model. Georgeous!

However, the client side javascript is, even after a complete refactoring,
still an utter mess. After the template generates the HTML tree it's a chore
to use javascript to show, hide and duplicate nodes based on user
interaction.

Maybe I should look into client-side templating?
main
Jaap Joris Vens 2020-03-09 18:24:39 +01:00
rodzic da6cff19f6
commit 3cabdda617
11 zmienionych plików z 140 dodań i 332 usunięć

Wyświetl plik

@ -47,6 +47,9 @@ class SectionForm(forms.ModelForm):
self.fields['type'].choices = self._meta.model.TYPES
self.fields['type'].initial = self._meta.model.TYPES[0][0]
self.fields['type'].widget.attrs['class'] = 'type'
self.fields['type'].widget.attrs['data-form'] = self.prefix
def delete(self):
instance = super().save()
instance.delete()
@ -83,18 +86,13 @@ class SectionForm(forms.ModelForm):
#SectionFormSet = inlineformset_factory(Page, Section, form=SectionForm, extra=1)
def get_view(section):
if section:
return section.__class__.view_class()
class BaseSectionFormSet(forms.BaseInlineFormSet):
'''Potentially nested formset based on
https://www.yergler.net/2013/09/03/nested-formsets-redux/
'''If a swappable Section model defines one-to-many fields, (i.e. has
foreign keys pointing to it) formsets will be generated for the
related models and stored in the form.formsets array.
If a Section subclass provides a 'formset_class' attribute, the
section form generated for the edit page will be given a 'formset'
attribute. This way, sections can customize their edit form to
request additional information.
Based on this logic for nested formsets:
https://www.yergler.net/2013/09/03/nested-formsets-redux/
Typical usecases could be:
- an images section that displays multiple images
@ -106,22 +104,23 @@ class BaseSectionFormSet(forms.BaseInlineFormSet):
def add_fields(self, form, index):
super().add_fields(form, index)
section = form.instance
view = get_view(section)
if hasattr(view, 'formset_class'):
form.formset = view.formset_class(
instance=section,
data=form.data if self.is_bound else None,
files=form.files if self.is_bound else None,
prefix=f'{form.prefix}-{view.formset_class.get_default_prefix()}')
#raise ValueError(form.formset)
form.formsets = []
for field in section._meta.get_fields():
if field.one_to_many:
formset = forms.inlineformset_factory(Section, field.related_model, fields='__all__', extra=1)(
instance=section,
data=form.data if self.is_bound else None,
files=form.files if self.is_bound else None,
prefix=f'{form.prefix}-{field.name}')
formset.name = field.name
form.formsets.append(formset)
def is_valid(self):
result = super().is_valid()
if self.is_bound:
for form in self.forms:
if hasattr(form, 'formset'):
result = result and form.formset.is_valid()
for formset in form.formsets:
result = result and formset.is_valid()
return result
def save(self, commit=True):
@ -131,6 +130,12 @@ class BaseSectionFormSet(forms.BaseInlineFormSet):
form.formset.save(commit=commit)
return result
def get_form_kwargs(self, index):
kwargs = super().get_form_kwargs(index)
kwargs.update({'label_suffix': ''})
return kwargs
SectionFormSet = forms.inlineformset_factory(
parent_model = Page,
model = Section,
@ -138,3 +143,9 @@ SectionFormSet = forms.inlineformset_factory(
formset = BaseSectionFormSet,
extra=1,
)
# class ImagesForm(forms.ModelForm):
# class Meta:
# model = SectionImage
# exclude = ['section']
# ImagesFormSet = forms.inlineformset_factory(Section, SectionImage, form=ImagesForm, extra=3)

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 183 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 85 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 216 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 263 KiB

Wyświetl plik

@ -1,75 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1"
viewBox="0 0 48 48"
enable-background="new 0 0 48 48"
id="svg6"
sodipodi:docname="edit.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1075"
id="namedview8"
showgrid="true"
inkscape:zoom="13.906433"
inkscape:cx="58.141489"
inkscape:cy="18.442256"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg6">
<inkscape:grid
type="xygrid"
id="grid887" />
</sodipodi:namedview>
<circle
fill="#4CAF50"
cx="24"
cy="24"
r="21"
id="circle2" />
<g
id="g895"
transform="matrix(0.49467333,-0.05078122,-0.05078122,0.49467333,-9.7836852,14.587579)"
style="fill:#ffffff;fill-opacity:1">
<path
inkscape:connector-curvature="0"
id="path889"
d="M 50,48 H 60 L 85,23 75,13 50,38 Z"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path891"
d="M 78,10 88,20 94,14 84,4 Z"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.3 KiB

Wyświetl plik

@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1"
viewBox="0 0 48 48"
enable-background="new 0 0 48 48"
id="svg828"
sodipodi:docname="ok.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata834">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs832" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1075"
id="namedview830"
showgrid="false"
inkscape:zoom="4.9166667"
inkscape:cx="-11.389831"
inkscape:cy="24"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg828" />
<circle
fill="#4CAF50"
cx="24"
cy="24"
r="21"
id="circle824" />
<polygon
fill="#CCFF90"
points="34.6,14.6 21,28.2 15.4,22.6 12.6,25.4 21,33.8 37.4,17.4"
id="polygon826"
style="fill:#ffffff;fill-opacity:1" />
</svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.7 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 310 KiB

Wyświetl plik

@ -4,131 +4,104 @@
{% block title %}{% trans 'Edit' %} {{form.instance}}{% endblock %}
{% block content %}
<form method="POST" enctype="multipart/form-data" class="cms">
{% csrf_token %}
{{form.media}}
<div class="wrapper">
{% if form %}
<section>
<div class="wrapper">
{% if form.non_field_errors %}
<div class="global_error">
{{form.non_field_errors}}
</div>
{% elif form.errors or formset.errors %}
<div class="global_error">
{% trans 'Please correct the error(s) below and save again' %}
</div>
{% endif %}
{% for field in form %}
{% if field.field.widget.input_type == 'checkbox' %}
{% include 'cms/formfield_checkbox.html' with field=field %}
{% else %}
{% include 'cms/formfield.html' with field=field %}
{% endif %}
{% endfor %}
<form method="POST" enctype="multipart/form-data" class="cms">
{% csrf_token %}
{{form.media}}
{% if form.errors or formset.errors %}
<div class="global_error">
{% trans 'Please correct the error(s) below and save again' %}
</div>
</section>
{% endif %}
{% endif %}
{% for field in form %}
{% include 'cms/formfield.html' with field=field %}
{% endfor %}
{% if formset %}
<div id="formset">
<div id="{{formset.prefix}}">
{{formset.management_form}}
{% for form in formset %}
<div class="formset_form">
<div class="formset_form" id="{{form.prefix}}" {% if forloop.last %}style="display:none"{% endif %}>
{{form.media}}
{% for field in form.hidden_fields %}
{{field}}
{% endfor %}
<section>
<div class="wrapper">
<div class="subform">
{% for field in form.visible_fields %}
{% if field.name == 'DELETE' and not form.instance.pk %}
{% elif field.field.widget.input_type == 'checkbox' %}
{% include 'cms/formfield_checkbox.html' with field=field counter=forloop.parentloop.counter0 %}
{% else %}
{% include 'cms/formfield.html' with field=field counter=forloop.parentloop.counter0 %}
{% endif %}
{% endfor %}
</div>
{{form.formset.management_form}}
{% for form in form.formset %}
<div class="anothersubform" style="padding: 10px; border:2px dotted black;margin-bottom: 10px">
{% for field in form.visible_fields %}
<div class="field {{field.name}}">
{% include 'cms/formfield.html' with field=field %}
</div>
{% endfor %}
{% for formset in form.formsets %}
<div class="field {{formset.name}}" id="{{formset.prefix}}">
{{formset.management_form}}
{% for form in formset %}
<div class="subform">
{{form.media}}
{% for field in form.hidden_fields %}
{{field}}
{% endfor %}
{% for field in form.visible_fields %}
{% if field.name == 'DELETE' and not form.instance.pk %}
{% elif field.field.widget.input_type == 'checkbox' %}
{% include 'cms/formfield_checkbox.html' with field=field counter=forloop.parentloop.counter0 %}
{% else %}
{% include 'cms/formfield.html' with field=field counter=forloop.parentloop.counter0 %}
{% endif %}
{% include 'cms/formfield.html' with field=field %}
{% endfor %}
</div>
{% endfor %}
<img onclick="addForm(this, '{{formset.prefix}}')" src="{% static 'cms/add_small.png' %}" width="50">
</div>
</section>
{% endfor %}
</div>
{% endfor %}
<img onclick="addForm(this, '{{formset.prefix}}')" src="{% static 'cms/add.png' %}" width="75" style="display:block;clear:both">
</div>
<style>
div.formset_form:last-child {
display: none;
}
</style>
<section>
<div class="wrapper">
<a class="button" href="#" onclick="addForm(); return false">+</a>
</div>
</section>
{% endif %}
<div class="edit page">
<button><img src="{% static 'cms/ok.svg' %}"></button>
</div>
</form>
<div class="edit page">
<button><img src="{% static 'cms/save.png' %}"></button>
</div>
</form>
</div>
{% endblock %}
{% block extrabody %}
<script type="text/javascript" src="/static/admin/js/urlify.js"></script>
<script>
NodeList.prototype.last = function(){
NodeList.prototype.last = function() {
return this[this.length - 1];
};
var formset = document.getElementById('formset');
var counter = formset.firstElementChild;
var re = /(.+)-(\d)-(.+)/;
function updateIndex(el) {
matches = el.name.match(re);
let re = /^(.+)-(\d)-(.+)$/;
let matches = el.name.match(re);
if (matches) {
prefix = matches[1];
suffix = matches[3];
index = parseInt(matches[2]) + 1;
let prefix = matches[1];
let suffix = matches[3];
let index = parseInt(matches[2]) + 1;
el.name = `${prefix}-${index}-${suffix}`;
}
}
function addForm() {
final_form = document.querySelectorAll('div.formset_form').last();
extra_form = final_form.cloneNode(true);
inputs = extra_form.querySelectorAll("input, select, textarea");
function addForm(node, parent_id) {
let base = node.previousElementSibling;
let parent = document.getElementById(parent_id);
let counter = parent.firstElementChild;
let extra_form = base.cloneNode(true);
let inputs = extra_form.querySelectorAll("input, select, textarea");
for (input of inputs) {
updateIndex(input);
}
formset.appendChild(extra_form);
extra_form.style.display = 'block';
node.remove();
parent.appendChild(extra_form);
parent.appendChild(node);
counter.value = parseInt(counter.value) + 1;
setEventHandlers();
resizeTextareas();
}
function resizeTextareas() {
var tx = document.getElementsByTagName('textarea');
for (var i = 0; i < tx.length; i++) {
let tx = document.getElementsByTagName('textarea');
for (let i = 0; i < tx.length; i++) {
tx[i].setAttribute('style', 'height:0;overflow-y:hidden;');
tx[i].style.height = (tx[i].scrollHeight) + 'px';
tx[i].addEventListener('input', function() {
@ -138,76 +111,32 @@
}
}
document.addEventListener("DOMContentLoaded", function(event) {
resizeTextareas();
// My own implementation of Django's prepopulate.js
var slugfield = document.getElementById('id_slug');
var titlefield = document.getElementById('id_title');
if (slugfield && titlefield) {
var virgin = slugfield.value === '';
if (virgin) {
titlefield.addEventListener('input', function(event) {
if (virgin) {
slugfield.value = URLify(titlefield.value);
}
});
slugfield.addEventListener('input', function(event) {
virgin = false;
});
function showRelevantFields(form, type) {
let fields_per_type = {{fields_per_type|safe}};
for (let field of form.querySelectorAll('div.field')) {
field.style.display = 'none';
}
for (let name of fields_per_type[type]) {
for (let field of form.querySelectorAll('div.field.' + name)) {
field.style.display = 'block';
}
}
}
// Auto-hide non-relevant fields, based on type field
{% if fields_per_type %}
var typefield = document.getElementById('id_type');
fields_per_type = {{fields_per_type|safe}};
if (typefield) {
function show_relevant_fields(type) {
for (el of document.querySelectorAll('div.formfield')) {
el.style.display = 'none';
}
for (field of fields_per_type[type]) {
el = document.getElementById(field);
el.style.display = 'block';
}
}
typefield.addEventListener('input', function(event) {
show_relevant_fields(typefield.value.toLowerCase());
function setEventHandlers() {
for (let typefield of document.querySelectorAll('select.type')) {
let form = document.getElementById(typefield.dataset.form);
let type = typefield.value.toLowerCase();
showRelevantFields(form, type);
typefield.addEventListener('input', function() {
type = typefield.value.toLowerCase();
showRelevantFields(form, type);
resizeTextareas();
});
show_relevant_fields(typefield.value.toLowerCase());
}
}
subforms = document.querySelectorAll('div.subform');
for (let i = 0; i < subforms.length; ++i) {
let typefield = document.getElementById('id_sections-' + i + '-type');
function show_relevant_fields(type) {
for (field of subforms[i].querySelectorAll('div.formfield')) {
field.style.display = 'none';
}
delete_checkbox = document.getElementById('DELETE' + i);
if (delete_checkbox) {
delete_checkbox.style.display = 'block';
}
for (field of fields_per_type[type]) {
el = document.getElementById(field + i);
el.style.display = 'block';
}
}
typefield.addEventListener('input', function(event) {
(show_relevant_fields(typefield.value.toLowerCase()));
});
show_relevant_fields(typefield.value.toLowerCase());
}
{% endif %}
});
setEventHandlers();
resizeTextareas();
</script>
{% endblock %}

Wyświetl plik

@ -1,14 +1,26 @@
<div class="formfield{% if field.errors %} error{% endif %}{% if field.field.required %} required{% endif %} {{field.name}}" id="{{field.name}}{{counter}}">
<div class="errors">
{{field.errors}}
</div>
<div class="label">
{{field.label_tag}}
</div>
<div class="input">
{{field}}
</div>
<div class="helptext">
{% if field.name == 'DELETE' and not form.instance.pk %}
{% else %}
<div class="formfield{% if field.errors %} error{% endif %}{% if field.field.required %} required{% endif %} {{field.name}}">
<div class="errors">
{{field.errors}}
</div>
{% if field.field.widget.input_type == 'checkbox' %}
<div class="input">
{{field}} {{field.label_tag}}
</div>
{% else %}
<div class="label">
{{field.label_tag}}
</div>
<div class="input">
{{field}}
</div>
{% endif %}
<div class="helptext">
{{field.help_text}}
</div>
</div>
</div>
{% endif %}

Wyświetl plik

@ -1,11 +0,0 @@
<div class="formfield{% if field.errors %} error{% endif %}{% if field.field.required %} required{% endif %} {{field.name}}" id="{{field.name}}{{counter}}">
<div class="errors">
{{field.errors}}
</div>
<div class="input">
{{field}} {{field.label_tag}}
</div>
<div class="helptext">
{{field.help_text}}
</div>
</div>

Wyświetl plik

@ -157,8 +157,7 @@ class EditPage(UserPassesTestMixin, edit.ModelFormMixin, base.TemplateResponseMi
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if 'formset' not in context:
context['formset'] = SectionFormSet(instance=self.object, form_kwargs={'label_suffix': ''})
# context['formset'] = SectionFormSet(instance=self.object, form_kwargs={'label_suffix': ''})
fields_per_type = {}
for model, _ in Section.TYPES:
ctype = ContentType.objects.get(
@ -180,10 +179,13 @@ class EditPage(UserPassesTestMixin, edit.ModelFormMixin, base.TemplateResponseMi
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return self.render_to_response(self.get_context_data())
formset = self.get_formset()
return self.render_to_response(self.get_context_data(formset=formset))
def get_formset(self):
return SectionFormSet(self.request.POST, self.request.FILES, instance=self.object)
if self.request.POST:
return SectionFormSet(self.request.POST, self.request.FILES, instance=self.object)
return SectionFormSet(instance=self.object)
def post(self, request, *args, **kwargs):
self.object = self.get_object()