diff --git a/fiches/static/fiches/css/annuaire.css b/fiches/static/fiches/css/annuaire.css
index a6ecceb..d6f4784 100644
--- a/fiches/static/fiches/css/annuaire.css
+++ b/fiches/static/fiches/css/annuaire.css
@@ -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,');
+ 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,');
+ 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;
diff --git a/fiches/static/fiches/js/forms.js b/fiches/static/fiches/js/forms.js
new file mode 100644
index 0000000..dd7f8ab
--- /dev/null
+++ b/fiches/static/fiches/js/forms.js
@@ -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 -- by -- 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);
+ }
+});
+
diff --git a/fiches/static/fiches/scss/_buttons.scss b/fiches/static/fiches/scss/_buttons.scss
new file mode 100644
index 0000000..a0f5526
--- /dev/null
+++ b/fiches/static/fiches/scss/_buttons.scss
@@ -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;
+}
\ No newline at end of file
diff --git a/fiches/static/fiches/scss/_content.scss b/fiches/static/fiches/scss/_content.scss
index 07e394f..a387227 100644
--- a/fiches/static/fiches/scss/_content.scss
+++ b/fiches/static/fiches/scss/_content.scss
@@ -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,');
+ 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 {
diff --git a/fiches/static/fiches/scss/_errors.scss b/fiches/static/fiches/scss/_errors.scss
new file mode 100644
index 0000000..1eaaec2
--- /dev/null
+++ b/fiches/static/fiches/scss/_errors.scss
@@ -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,');
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: 100%;
+ vertical-align: middle;
+ }
+
+ li:not(:last-child) {
+ margin: 0 0 0.5ex 0;
+ }
+}
\ No newline at end of file
diff --git a/fiches/templates/fiches/base.html b/fiches/templates/fiches/base.html
index 184c276..3c8234c 100644
--- a/fiches/templates/fiches/base.html
+++ b/fiches/templates/fiches/base.html
@@ -9,7 +9,6 @@
{% block title_onglet %}{% trans "Annuaire des élèves de l'ENS" %}{% endblock %}
-
@@ -75,5 +74,5 @@