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 @@ - +{% block extra_js %}{% endblock %} diff --git a/fiches/templates/fiches/fiche.html b/fiches/templates/fiches/fiche.html index 2eb0b17..7bf69fc 100644 --- a/fiches/templates/fiches/fiche.html +++ b/fiches/templates/fiches/fiche.html @@ -21,60 +21,76 @@
{% if profile.pronoun %} -

+

{% trans "Pronom(s) utilisé(s)" %} {{ profile.pronoun }} -

+
{% endif %} {% if profile.department.exists %} -

+

{% trans "Département" %}{{ profile.department.count|pluralize }} {% for dep in profile.department.all %}{{ dep }}{% if not forloop.last %}, {% endif %}{% endfor %} -

+
{% endif %} {% if profile.birth_date %} -

+

{% trans "Date de naissance" %} {{ profile.birth_date }} -

+
{% endif %} {% if profile.thurne %} -

+

{% trans "Thurne" %} {{ profile.thurne }} -

+
{% endif %} {% if profile.phone_set.exists %} -

+

{% trans "Téléphone" %}{{ profile.phone_set.count|pluralize }} - {% for p in profile.phone_set.all %}{{ p }}{% if not forloop.last %},
{% endif %}{% endfor %}
-

+
    + {% for p in profile.phone_set.all %} +
  • {{ p.name }}{{ p.number }}
  • + {% endfor %} +
+
{% endif %} {% if profile.social_set.exists %} - {% endif %} {% if profile.mail_set.exists %} -

+

{{ profile.mail_set.count|pluralize:_("Mail,Mails") }} - {% for p in profile.mail_set.all %}{{ p }}{% if not forloop.last %},
{% endif %}{% endfor %}
-

+
    + {% for p in profile.mail_set.all %} +
  • {{ p.name }}{{ p.mail }}
  • + {% endfor %} +
+
{% endif %} {% if profile.address_set.exists %} -

+

{{ profile.address_set.count|pluralize:_("Adresse,Adresses") }} - {% for p in profile.address_set.all %}{{ p }}{% if not forloop.last %},
{% endif %}{% endfor %}
-

+
    + {% for p in profile.address_set.all %} +
  • {{ p.name }}{{ p.content }}
  • + {% endfor %} +
+
{% endif %}
diff --git a/fiches/templates/fiches/fiches_modif.html b/fiches/templates/fiches/fiches_modif.html index 5f6f068..71609cc 100644 --- a/fiches/templates/fiches/fiches_modif.html +++ b/fiches/templates/fiches/fiches_modif.html @@ -1,12 +1,12 @@ {% extends "fiches/base.html" %} {% load i18n %} +{% load staticfiles %} {% block content %} -

{% trans "Modifier ma page d'annuaire" %}

- {{ form.errors }} + {{ form.non_field_errors }}
{% csrf_token %}
@@ -63,21 +63,41 @@ {{ form.thurne }}
-
+
- {{ phone_form }} + {% trans "Ajouter un numéro" as add_number %} + {% include "fiches/multientry.html" with formset=phone_form new_entry_text=add_number %}
-
- - {{ social_form }} +
+ + {% trans "Ajouter un réseau social" as add_social %} + {% include "fiches/multientry.html" with formset=social_form new_entry_text=add_social %}
-
- - {{ mail_form }} +
+ + {% trans "Ajouter un email" as add_mail %} + {% include "fiches/multientry.html" with formset=mail_form new_entry_text=add_mail %}
-
- - {{ address_form }} +
+ + {% trans "Ajouter une adresse" as add_address %} + {% include "fiches/multientry.html" with formset=address_form new_entry_text=add_address %}
@@ -97,5 +117,8 @@
- +{% endblock %} + +{% block extra_js %} + {% endblock %} diff --git a/fiches/templates/fiches/multientry.html b/fiches/templates/fiches/multientry.html new file mode 100644 index 0000000..539e6d8 --- /dev/null +++ b/fiches/templates/fiches/multientry.html @@ -0,0 +1,25 @@ +{{ formset.non_field_errors }} +{{ formset.management_form }} +{% for form in formset %} + {{ form.non_field_errors }} +
+ {% for field in form.visible_fields %} + {{field.errors}} + {{field}} + {% endfor %} + {% for field in form.hidden_fields %} + {{field}} + {% endfor %} + +
+{% endfor %} +
+ {% for field in form.visible_fields %} + {{field}} + {% endfor %} + {% for field in form.hidden_fields %} + {{field}} + {% endfor %} + +
+ \ No newline at end of file