Merge branch 'camille/style-new-form-elements' into 'master'

Stylise les éléments de formulaire multi-entrées

See merge request klub-dev-ens/annuaire!9
This commit is contained in:
Ludovic Stephan 2020-11-13 11:52:31 +01:00
commit e99becd89c
9 changed files with 605 additions and 63 deletions

View file

@ -207,6 +207,50 @@ body {
font-family: "Source Code Pro", sans-serif;
}
.content button,
.content input[type=button],
.content input[type=submit] {
padding: 10px;
background-color: #724162;
border: none;
font-size: 1.2rem;
color: #FFFFFF;
box-shadow: 2px 2px 0 rgba(31, 14, 25, 0.3);
transition: 25ms ease-in-out;
}
.content button:hover,
.content input[type=button]:hover,
.content input[type=submit]:hover {
background-color: #4e2d43;
}
#content-edit-profile form .form-entry > .errorlist.nonfield, #content-edit-profile form .form-sub-entry.erroneous {
padding: 10px;
background-color: #973232;
}
#content-edit-profile form .form-entry > .errorlist.nonfield, #content-edit-profile form .form-sub-entry .errorlist {
margin: 0;
padding: 0 0 10px 0;
list-style: none;
}
#content-edit-profile form .form-entry > .errorlist.nonfield li::before, #content-edit-profile form .form-sub-entry .errorlist li::before {
content: " ";
display: inline-block;
width: 24px;
height: 24px;
margin: -2px 0.5ex 0 0;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>');
background-repeat: no-repeat;
background-position: center;
background-size: 100%;
vertical-align: middle;
}
#content-edit-profile form .form-entry > .errorlist.nonfield li:not(:last-child), #content-edit-profile form .form-sub-entry .errorlist li:not(:last-child) {
margin: 0 0 0.5ex 0;
}
#content-area {
color: #FFFFFF;
}
@ -228,21 +272,6 @@ body {
border: none;
border-radius: 0;
}
.content input[type=button],
.content input[type=submit] {
padding: 10px;
background-color: #724162;
border: none;
font-size: 1.2rem;
color: #FFFFFF;
box-shadow: 2px 2px 0 rgba(31, 14, 25, 0.3);
transition: 25ms ease-in-out;
}
.content input[type=button]:hover,
.content input[type=submit]:hover {
background-color: #4e2d43;
}
#content-home form {
display: grid;
width: 400px;
@ -326,6 +355,24 @@ body {
background-color: #3b1e31;
box-shadow: 2px 2px 0 rgba(31, 14, 25, 0.3);
}
#content-view-profile .infos > *.multi-entry ul.value {
margin: 0;
padding: 0;
list-style-type: none;
}
#content-view-profile .infos > *.multi-entry ul.value li {
padding: 0;
}
#content-view-profile .infos > *.multi-entry ul.value li .type {
display: inline-block;
margin: 0 1em 0 0;
color: #DFDFDF;
font-style: italic;
}
#content-view-profile .infos > *.multi-entry ul.value li .value {
display: inline-block;
float: right;
}
#content-view-profile .free-text {
grid-area: free-text;
padding: 10px;
@ -363,6 +410,62 @@ body {
#content-edit-profile form .form-entry input, #content-edit-profile form .form-entry select {
width: 100%;
}
#content-edit-profile form .form-entry > .errorlist.nonfield {
padding: 10px;
margin: 10px 0;
}
#content-edit-profile form .form-sub-entry {
display: grid;
grid-template-rows: auto auto;
grid-template-columns: 2fr 3fr auto;
grid-template-areas: "errors errors errors" "type-input value-input remove-button";
column-gap: 10px;
margin: 0 0 10px 0;
}
#content-edit-profile form .form-sub-entry.hidden {
display: none;
}
#content-edit-profile form .form-sub-entry input:nth-child(1) {
grid-area: type-input;
}
#content-edit-profile form .form-sub-entry input:nth-child(2) {
grid-area: value-input;
}
#content-edit-profile form .form-sub-entry .remove-button {
grid-area: remove-button;
min-height: 30px;
min-width: 30px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>');
background-size: 80%;
background-position: center;
background-repeat: no-repeat;
}
#content-edit-profile form .form-sub-entry .remove-button:hover,
#content-edit-profile form .form-sub-entry .remove-button:active {
background-color: #973232;
}
#content-edit-profile form .form-sub-entry input[type=checkbox] {
display: none;
}
#content-edit-profile form .form-sub-entry.erroneous input:nth-child(2) {
grid-area: type-input;
}
#content-edit-profile form .form-sub-entry.erroneous input:nth-child(3) {
grid-area: value-input;
}
#content-edit-profile form .form-sub-entry.erroneous .remove-button:hover,
#content-edit-profile form .form-sub-entry.erroneous .remove-button:active {
background-color: #7d2727;
}
#content-edit-profile form .form-sub-entry .errorlist {
grid-area: errors;
}
#content-edit-profile form .form-sub-entry-template {
display: none;
}
#content-edit-profile form .add-sub-entry-button {
margin: 0 auto 0 auto;
}
#content-edit-profile form .form-entry.checkbox > * {
display: inline;
width: auto;

View file

@ -0,0 +1,242 @@
"use strict";
// Sub-entry of a multi-entry form entry
// (e.g. one phone number among several phone numbers grouped in a single entry)
class SubEntry {
constructor(subEntryElement, index, parentFormEntry) {
this.parentFormEntry = parentFormEntry;
this.index = index;
this.subEntryElement = subEntryElement;
this.removeButtonElement = subEntryElement.querySelector(".remove-button");
this.deletionCheckboxElement = subEntryElement.querySelector("input[id$='DELETE']");
this.addPlaceholderAttributes();
this.startHandlingSubEntryRemoveButtonClicks();
}
addPlaceholderAttributes() {
// Add the type placeholder to the first input element
// and the value placeholder to the second one
const inputElements = this.subEntryElement.querySelectorAll("input");
inputElements[0].setAttribute("placeholder", this.parentFormEntry.placeholders.type);
inputElements[1].setAttribute("placeholder", this.parentFormEntry.placeholders.value);
}
hide() {
this.subEntryElement.classList.add("hidden");
}
display() {
this.subEntryElement.classList.remove("hidden");
}
markForDeletionAndHide() {
// Check Django's deletion checkbox
this.deletionCheckboxElement.checked = true;
this.hide();
}
reset() {
// Clear all input fields
for (let inputElement of this.subEntryElement.querySelectorAll("input")) {
inputElement.value = "";
}
// Uncheck Django's deletion checkbox
this.deletionCheckboxElement.checked = false;
}
resetAndDisplay() {
this.reset();
this.display();
}
reindex(newIndex) {
// Replace -<old index>- by -<new index>- in the values of the
// id and name attributes of every child of this sub-entry's element
const attributesToUpdate = ["id", "name"];
for (let childElement of this.subEntryElement.childNodes) {
for (let attribute of attributesToUpdate) {
if (!childElement.hasAttribute(attribute)) {
continue;
}
const newValue = childElement
.getAttribute(attribute)
.replace(`-${this.index}-`, `-${newIndex}-`);
childElement.setAttribute(attribute, newValue);
}
}
this.index = newIndex;
}
startHandlingSubEntryRemoveButtonClicks() {
this.removeButtonElement.addEventListener("click", event => {
event.preventDefault();
this.parentFormEntry.removeSubEntry(this);
});
}
static fromTemplate(templateElement, index, parentFormEntry) {
// Clone the template, update its class,
// and replace __prefix__ by the index of the new sub-entry
const clonedTemplateElement = templateElement.cloneNode(true);
clonedTemplateElement.innerHTML = clonedTemplateElement.innerHTML
.replace(/__prefix__/g, index.toString());
clonedTemplateElement.className = "form-sub-entry";
return new SubEntry(clonedTemplateElement, index, parentFormEntry);
}
}
// ----------------------------------------------------------------------------
// Form entry which can contain 0+ sub-entries
// (e.g. a phone number entry which can contain several numbers)
class MultiEntryFormEntry {
constructor(formEntryElement) {
this.formEntryElement = formEntryElement;
this.subEntryTemplateElement = formEntryElement.querySelector(".form-sub-entry-template");
this.newSubEntryButtonElement = formEntryElement.querySelector(".add-sub-entry-button");
this.totalFormsElement = [...formEntryElement.querySelectorAll("input")]
.find(element => element.name && element.name.endsWith("TOTAL_FORMS"));
this.maxNumFormsElement = [...formEntryElement.querySelectorAll("input")]
.find(element => element.name && element.name.endsWith("MAX_NUM_FORMS"));
this.placeholders = {
type: this.formEntryElement.getAttribute("data-type-placeholder"),
value: this.formEntryElement.getAttribute("data-value-placeholder")
};
this.maxNbSubEntries = parseInt(this.maxNumFormsElement.value);
this.initialNbSubEntries = 0;
this.subEntries = [];
this.createInitialSubEntries();
this.startHandlingNewSubEntryButtonClicks();
}
get nbSubEntries() {
return this.subEntries
.filter(subEntry =>
!subEntry.subEntryElement.classList.contains("hidden")
)
.length;
}
get hasHiddenSubEntries() {
return this.subEntries
.findIndex(subEntry =>
subEntry.subEntryElement.classList.contains("hidden")
) >= 0;
}
createInitialSubEntries() {
const subEntryElements = this.formEntryElement.querySelectorAll(".form-sub-entry");
this.initialNbSubEntries = subEntryElements.length;
// This loop assumes sub-entries elements are
// ordered with no gap according to indices which start at 0
for (let [index, element] of [...subEntryElements].entries()) {
this.subEntries.push(
new SubEntry(element, index, this)
);
}
}
createNewSubEntry() {
if (this.nbSubEntries === this.maxNbSubEntries) {
console.log(`The max. number of sub-entries has been reached (${this.maxNbSubEntries}).`);
return;
}
// If there are hidden sub-entries,
// it means one of the initial sub-entry elements should be reset and displayed
if (this.hasHiddenSubEntries) {
// Reset and display the first hidden sub-entry
const existingSubEntry = this.subEntries.find(
subEntry => subEntry.subEntryElement.classList.contains("hidden")
);
existingSubEntry.resetAndDisplay();
}
// Otherwise, it means a new sub-entry (element) must be created
// and appended to the form entry element
else {
const newSubEntryIndex = this.nbSubEntries;
const newSubEntry = SubEntry.fromTemplate(
this.subEntryTemplateElement,
newSubEntryIndex,
this
);
this.subEntries.push(newSubEntry);
this.formEntryElement.insertBefore(
newSubEntry.subEntryElement,
this.newSubEntryButtonElement
);
// Increment Django's TOTAL_FORMS hidden form input
this.totalFormsElement.value = (parseInt(this.totalFormsElement.value) + 1).toString();
}
}
removeSubEntry(subEntry) {
// If the index of the sub-entry to remove is below the initial number of sub-entries,
// it means one of the initial sub entry elements should be marked for deletion and hidden
const removedSubEntryIndex = subEntry.index;
if (removedSubEntryIndex < this.initialNbSubEntries) {
subEntry.markForDeletionAndHide();
}
// Otherwise, delete the sub-entry (and remove its element from the DOM)
// and reindex other user-created sub-entries if need be
else {
subEntry.subEntryElement.remove();
this.subEntries.splice(subEntry.index, 1);
for (let index = this.removeSubEntryIndex; index < this.subEntries.length; index++) {
this.subEntries[index].reindex(index);
}
// Decrement Django's TOTAL_FORMS hidden form input
this.totalFormsElement.value = (parseInt(this.totalFormsElement.value) - 1).toString();
}
}
startHandlingNewSubEntryButtonClicks() {
const buttonElement = this.formEntryElement.querySelector(".add-sub-entry-button");
buttonElement.addEventListener("click", event => {
event.preventDefault();
this.createNewSubEntry();
});
}
}
// ----------------------------------------------------------------------------
// Setup the script by creating one instance of MultiEntryFormEntry
// for each form entry configured to contain several sub-entries
document.addEventListener("DOMContentLoaded", event => {
const multiEntryFormEntryElements = document.querySelectorAll(
"#content-edit-profile .form-entry.multi-entry"
);
for (let element of multiEntryFormEntryElements) {
const formEntry = new MultiEntryFormEntry(element);
}
});

View file

@ -0,0 +1,15 @@
@use "colors";
%button {
padding: 10px;
background-color: colors.$page-button-background;
border: none;
font-size: 1.2rem;
color: colors.$page-button-text;
box-shadow: 2px 2px 0 colors.$shadow;
transition: 25ms ease-in-out;
}
%button:hover {
background-color: colors.$page-button-background-hover;
}

View file

@ -1,4 +1,6 @@
@use "colors";
@use "buttons";
@use "errors";
#content-area {
color: colors.$content-text;
@ -23,20 +25,10 @@
border-radius: 0;
}
button,
input[type="button"],
input[type="submit"] {
padding: 10px;
background-color: colors.$page-button-background;
border: none;
font-size: 1.2rem;
color: colors.$page-button-text;
box-shadow: 2px 2px 0 colors.$shadow;
transition: 25ms ease-in-out;
}
input[type="button"]:hover,
input[type="submit"]:hover {
background-color: colors.$page-button-background-hover;
@extend %button;
}
}
@ -151,6 +143,28 @@
padding: 10px;
background-color: colors.$content-frame-background;
box-shadow: 2px 2px 0 colors.$shadow;
&.multi-entry ul.value {
margin: 0;
padding: 0;
list-style-type: none;
li {
padding: 0;
.type {
display: inline-block;
margin: 0 1em 0 0;
color: colors.$page-text-secondary;
font-style: italic;
}
.value {
display: inline-block;
float: right;
}
}
}
}
}
@ -197,6 +211,83 @@
input, select {
width: 100%;
}
> .errorlist.nonfield {
@extend %error-list-container;
@extend %error-list;
padding: 10px;
margin: 10px 0;
}
}
.form-sub-entry {
display: grid;
grid-template-rows: auto auto;
grid-template-columns: 2fr 3fr auto;
grid-template-areas:
"errors errors errors"
"type-input value-input remove-button";
column-gap: 10px;
margin: 0 0 10px 0;
&.hidden {
display: none;
}
// Since the different labels and inputs are not obvious to identifiate using CSS selectors,
// they are selected one after the other using their natural order in the DOM
// TODO: make this more robust by giving proper class names to each sub-entry element
input:nth-child(1) { grid-area: type-input; }
input:nth-child(2) { grid-area: value-input; }
.remove-button {
grid-area: remove-button;
min-height: 30px;
min-width: 30px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>');
background-size: 80%;
background-position: center;
background-repeat: no-repeat;
}
.remove-button:hover,
.remove-button:active {
background-color: #973232;
}
input[type="checkbox"] {
display: none;
}
&.erroneous {
@extend %error-list-container;
// TODO: this is not robust and should be replaced by better selectors
// In case of error, increment the child indices
// to take the additional list of errors (.errorlist)
// prepended by Django to the sub-entry
input:nth-child(2) { grid-area: type-input; }
input:nth-child(3) { grid-area: value-input; }
// Use a darker red color when the sub-entry background is already red
.remove-button:hover,
.remove-button:active {
background-color: #7d2727;
}
}
.errorlist {
@extend %error-list;
grid-area: errors;
}
}
.form-sub-entry-template {
display: none;
}
.add-sub-entry-button {
margin: 0 auto 0 auto;
}
.form-entry.checkbox {

View file

@ -0,0 +1,28 @@
%error-list-container {
padding: 10px;
background-color: #973232;
}
%error-list {
margin: 0;
padding: 0 0 10px 0;
list-style: none;
li::before {
content: " ";
display: inline-block;
width: 24px;
height: 24px;
// A small top-margin is used to vertically center the icon with the text
margin: -2px 0.5ex 0 0;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>');
background-repeat: no-repeat;
background-position: center;
background-size: 100%;
vertical-align: middle;
}
li:not(:last-child) {
margin: 0 0 0.5ex 0;
}
}

View file

@ -9,7 +9,6 @@
<title>
{% block title_onglet %}{% trans "Annuaire des élèves de l'ENS" %}{% endblock %}
</title>
<link rel="stylesheet" type="text/css" href="view_fiche_files/style.css">
<link rel="stylesheet" type="text/css" href="{% static "fiches/css/normalize.css" %}" />
<link rel="stylesheet" type="text/css" href="{% static "fiches/css/annuaire.css" %}" />
</head>
@ -75,5 +74,5 @@
</div>
</div>
</body>
{% block extra_js %}{% endblock %}
</html>

View file

@ -21,60 +21,76 @@
<div class="infos">
{% if profile.pronoun %}
<p class="pronouns">
<div class="pronouns">
<span class="label">{% trans "Pronom(s) utilisé(s)" %}</span>
<span class="separator"></span>
<span class="value">{{ profile.pronoun }}</span>
</p>
</div>
{% endif %}
{% if profile.department.exists %}
<p class="department">
<div class="department">
<span class="label">{% trans "Département" %}{{ profile.department.count|pluralize }}</span>
<span class="separator"></span>
<span class="value">{% for dep in profile.department.all %}{{ dep }}{% if not forloop.last %}, {% endif %}{% endfor %}</span>
</p>
</div>
{% endif %}
{% if profile.birth_date %}
<p class="birthdate">
<div class="birthdate">
<span class="label">{% trans "Date de naissance" %}</span>
<span class="separator"></span>
<span class="value">{{ profile.birth_date }}</span>
</p>
</div>
{% endif %}
{% if profile.thurne %}
<p class="room">
<div class="room">
<span class="label">{% trans "Thurne" %}</span>
<span class="separator"></span>
<span class="value">{{ profile.thurne }}</span>
</p>
</div>
{% endif %}
{% if profile.phone_set.exists %}
<p class="phone">
<div class="phone multi-entry">
<span class="label">{% trans "Téléphone" %}{{ profile.phone_set.count|pluralize }}</span>
<span class="separator"></span>
<span class="value">{% for p in profile.phone_set.all %}{{ p }}{% if not forloop.last %},<br>{% endif %}{% endfor %}</span>
</p>
<ul class="value">
{% for p in profile.phone_set.all %}
<li><span class="type">{{ p.name }}</span><span class="value">{{ p.number }}</span></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if profile.social_set.exists %}
<p class="social">
<div class="social multi-entry">
<span class="label">{{ profile.social_set.count|pluralize:_("Réseau social,Réseaux sociaux") }}</span>
<span class="separator"></span>
<span class="value">{% for p in profile.social_set.all %}{{ p }}{% if not forloop.last %},<br>{% endif %}{% endfor %}</span>
</p>
<ul class="value">
{% for p in profile.social_set.all %}
<li><span class="type">{{ p.name }}</span><span class="value">{{ p.content }}</span></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if profile.mail_set.exists %}
<p class="mail">
<div class="mail multi-entry">
<span class="label">{{ profile.mail_set.count|pluralize:_("Mail,Mails") }}</span>
<span class="separator"></span>
<span class="value">{% for p in profile.mail_set.all %}{{ p }}{% if not forloop.last %},<br>{% endif %}{% endfor %}</span>
</p>
<ul class="value">
{% for p in profile.mail_set.all %}
<li><span class="type">{{ p.name }}</span><span class="value">{{ p.mail }}</span></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if profile.address_set.exists %}
<p class="address">
<div class="address multi-entry">
<span class="label">{{ profile.address_set.count|pluralize:_("Adresse,Adresses") }}</span>
<span class="separator"></span>
<span class="value">{% for p in profile.address_set.all %}{{ p }}{% if not forloop.last %},<br>{% endif %}{% endfor %}</span>
</p>
<ul class="value">
{% for p in profile.address_set.all %}
<li><span class="type">{{ p.name }}</span><span class="value">{{ p.content }}</span></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>

View file

@ -1,12 +1,12 @@
{% extends "fiches/base.html" %}
{% load i18n %}
{% load staticfiles %}
{% block content %}
<div id="content-edit-profile" class="content">
<h2>{% trans "Modifier ma page d'annuaire" %}</h2>
{{ form.errors }}
{{ form.non_field_errors }}
<form method="post" action="" enctype="multipart/form-data">
{% csrf_token %}
<div class="form-entry">
@ -63,21 +63,41 @@
<label for="id_thurne">{% trans "Thurne :" %}</label>
{{ form.thurne }}
</div>
<div class="form-entry">
<div
class="form-entry multi-entry"
data-type-placeholder="{% trans "Personnel" %}"
data-value-placeholder="{% trans "0612345678" %}"
>
<label for="id_phone">{% trans "Numéro(s) de téléphone :" %}</label>
{{ phone_form }}
{% trans "Ajouter un numéro" as add_number %}
{% include "fiches/multientry.html" with formset=phone_form new_entry_text=add_number %}
</div>
<div class="form-entry">
<label for="id_social">{% trans "Réseaux sociaux :" %}</label>
{{ social_form }}
<div
class="form-entry multi-entry"
data-type-placeholder="{% trans "InstaTok" %}"
data-value-placeholder="{% trans "mon_profil_instatok" %}"
>
<label for="id_social">{% trans "Réseaux sociaux :" %}</label>
{% trans "Ajouter un réseau social" as add_social %}
{% include "fiches/multientry.html" with formset=social_form new_entry_text=add_social %}
</div>
<div class="form-entry">
<label for="id_mail">{% trans "Mail(s):" %}</label>
{{ mail_form }}
<div
class="form-entry multi-entry"
data-type-placeholder="{% trans "Professionelle" %}"
data-value-placeholder="{% trans "moi@ens.fr" %}"
>
<label for="id_mail">{% trans "Mail(s):" %}</label>
{% trans "Ajouter un email" as add_mail %}
{% include "fiches/multientry.html" with formset=mail_form new_entry_text=add_mail %}
</div>
<div class="form-entry">
<label for="id_address">{% trans "Adresse(s):" %}</label>
{{ address_form }}
<div
class="form-entry multi-entry"
data-type-placeholder="{% trans "Bureau" %}"
data-value-placeholder="{% trans "45 rue d'Ulm" %}"
>
<label for="id_address">{% trans "Adresse(s):" %}</label>
{% trans "Ajouter une adresse" as add_address %}
{% include "fiches/multientry.html" with formset=address_form new_entry_text=add_address %}
</div>
<div class="form-entry">
<label for="id_text_field">{% trans "Champ libre :" %}</label>
@ -97,5 +117,8 @@
</form>
</div>
{% endblock %}
{% block extra_js %}
<script type="text/javascript" src="{% static "fiches/js/forms.js" %}"></script>
{% endblock %}

View file

@ -0,0 +1,25 @@
{{ formset.non_field_errors }}
{{ formset.management_form }}
{% for form in formset %}
{{ form.non_field_errors }}
<div class="form-sub-entry {% if form.errors %}erroneous{% endif %}">
{% for field in form.visible_fields %}
{{field.errors}}
{{field}}
{% endfor %}
{% for field in form.hidden_fields %}
{{field}}
{% endfor %}
<button type=button" class="remove-button"></button>
</div>
{% endfor %}
<div class="form-sub-entry-template">
{% for field in form.visible_fields %}
{{field}}
{% endfor %}
{% for field in form.hidden_fields %}
{{field}}
{% endfor %}
<button type="button" class="remove-button"></button>
</div>
<button type="button" class="add-sub-entry-button">{{ new_entry_text }}</button>