Skip to content

Commit 04015c9

Browse files
authored
Rewrite confirmation modals (#1985)
This removes our Django template `confirmation_modal.html` and instead provides the `confirmation-modal` custom element. The new modal is used like a submit button for HTML forms. Alternatively, if a modal does not have an associated form, it will dispatch a custom "confirmed" event on itself. Additionally, I added "custom-success" forms. By specifying the `custom-success` tag on an HTML form, the form will not use the default behavior when a submit is requested, but instead submit the form data with `fetch` and dispatch a custom event afterwards. One common use case is making a form reload the current site on success - this way, we don't need the views to know what sites usually request it. I added the `reload-on-success` tag for this special case. I changed all confirmation modals, except for the delegation modal on the contributor index site. This modal was already a special case before and we can port it later.
1 parent 8145c09 commit 04015c9

30 files changed

+846
-665
lines changed

evap/contributor/templates/contributor_evaluation_form.html

+12-19
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,18 @@ <h5 class="card-title me-auto">{% trans 'Evaluation data' %}</h5>
9292
{% if editable %}
9393
<button name="operation" value="preview" type="submit" class="btn btn-light">{% trans 'Preview' %}</button>
9494
<button name="operation" value="save" type="submit" class="btn btn-primary">{% trans 'Save' %}</button>
95-
{# webtest does not allow submission with value "approve" if no such button exists #}
96-
<button style="display: none" name="operation" value="approve" type="submit"></button>
97-
<button type="button" onclick="approveEvaluationModalShow(0, '');" class="btn btn-success">{% trans 'Save and approve' %}</button>
95+
96+
<confirmation-modal type="submit" name="operation" value="approve">
97+
<span slot="title">{% trans 'Approve evaluation' %}</span>
98+
<span slot="action-text">{% trans 'Approve evaluation' %}</span>
99+
<span slot="question">
100+
{% blocktrans trimmed %}
101+
Do you want to approve this evaluation? This will allow the evaluation team to proceed with the preparation, but you won't be able to make any further changes.
102+
{% endblocktrans %}
103+
</span>
104+
105+
<button slot="show-button" type="button" class="btn btn-success">{% trans 'Save and approve' %}</button>
106+
</confirmation-modal>
98107
{% endif %}
99108
<a href="{% url 'contributor:index' %}" class="btn btn-light">{% if edit %}{% trans 'Cancel' %}{% else %}{% trans 'Back' %}{% endif %}</a>
100109
</div>
@@ -123,22 +132,6 @@ <h5 class="modal-title" id="previewModalLabel">{% trans 'Preview' %}</h5>
123132

124133
{% block modals %}
125134
{{ block.super }}
126-
{% trans 'Approve evaluation' as title %}
127-
{% blocktrans asvar question%}Do you want to approve this evaluation? This will allow the evaluation team to proceed with the preparation, but you won't be able to make any further changes.{% endblocktrans %}
128-
{% trans 'Approve evaluation' as action_text %}
129-
{% include 'confirmation_modal.html' with modal_id='approveEvaluationModal' title=title question=question action_text=action_text btn_type='primary' %}
130-
<script type="text/javascript">
131-
function approveEvaluationModalAction(dataId) {
132-
const input = document.createElement("input");
133-
input.type = "hidden";
134-
input.name = "operation";
135-
input.value = "approve";
136-
137-
const form = document.getElementById("evaluation-form");
138-
form.appendChild(input);
139-
form.requestSubmit();
140-
};
141-
</script>
142135

143136
{% blocktrans asvar title with evaluation_name=evaluation.full_name %}Request account creation for {{ evaluation_name }}{% endblocktrans %}
144137
{% trans 'Please tell us which new account we should create. We need the name and email for all new accounts.' as teaser %}

evap/contributor/templates/contributor_index.html

+21-16
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,13 @@
157157
<span class="fas fa-pencil"></span>
158158
</a>
159159
{% if not evaluation|has_nonresponsible_editor %}
160-
<a href="#" class="btn btn-sm btn-dark" data-bs-toggle="tooltip"
160+
<button class="btn btn-sm btn-dark" data-bs-toggle="tooltip"
161161
data-bs-placement="top" title="{% trans 'Delegate preparation' %}"
162-
onclick="delegateSelectionModalShow(`{{ evaluation.full_name }}`, `{% url 'contributor:evaluation_direct_delegation' evaluation.id %}`);return false;"
162+
data-evaluation-name="{{ evaluation.full_name }}"
163+
data-delegation-url="{% url 'contributor:evaluation_direct_delegation' evaluation.id %}"
163164
>
164165
<span class="fas fa-hand-point-left"></span>
165-
</a>
166+
</button>
166167
{% endif %}
167168
{% elif evaluation.state == evaluation.State.EDITOR_APPROVED or evaluation.state == evaluation.State.APPROVED %}
168169
<a href="{% url 'contributor:evaluation_view' evaluation.id %}" class="btn btn-sm btn-light"
@@ -226,22 +227,26 @@ <h5 class="modal-title" id="{{ modal_id }}Label">{% trans 'Delegate preparation'
226227
</div>
227228
</div>
228229

229-
<script type="text/javascript">
230-
function {{ modal_id }}Show(evaluationName, action) {
231-
const modal = document.getElementById("{{ modal_id }}");
232-
// set form's action location
233-
modal.querySelectorAll("form").forEach(form => form.action = action);
230+
<script type="module">
231+
document.querySelectorAll("[data-evaluation-name][data-delegation-url]").forEach(showButton => {
232+
showButton.addEventListener("click", event => {
233+
event.stopPropagation();
234234

235-
// put the correct evaluation name in the modal
236-
modal.querySelectorAll('[data-label=""]').forEach(el => el.innerText = evaluationName);
235+
const modal = document.getElementById("{{ modal_id }}");
236+
// set form's action location
237+
modal.querySelectorAll("form").forEach(form => form.action = showButton.dataset.delegationUrl);
237238

238-
// unselect any previously selected options in the modal
239-
modal.querySelectorAll("select").forEach(select => select.tomselect.clear());
239+
// put the correct evaluation name in the modal
240+
modal.querySelectorAll('[data-label=""]').forEach(el => el.innerText = showButton.dataset.evaluationName);
240241

241-
// show modal
242-
var {{ modal_id }} = new bootstrap.Modal(document.getElementById('{{ modal_id }}'));
243-
{{ modal_id }}.show();
244-
}
242+
// unselect any previously selected options in the modal
243+
modal.querySelectorAll("select").forEach(select => select.tomselect.clear());
244+
245+
// show modal
246+
var {{ modal_id }} = new bootstrap.Modal(document.getElementById('{{ modal_id }}'));
247+
{{ modal_id }}.show();
248+
});
249+
});
245250
</script>
246251
{% endwith %}
247252
{% endblock %}

evap/contributor/tests/test_views.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
from model_bakery import baker
66

77
from evap.evaluation.models import Contribution, Course, Evaluation, Questionnaire, UserProfile
8-
from evap.evaluation.tests.tools import WebTestWith200Check, create_evaluation_with_responsible_and_editor, render_pages
8+
from evap.evaluation.tests.tools import (
9+
WebTestWith200Check,
10+
create_evaluation_with_responsible_and_editor,
11+
render_pages,
12+
submit_with_modal,
13+
)
914

1015

1116
class TestContributorDirectDelegationView(WebTest):
@@ -197,7 +202,7 @@ def test_contributor_evaluation_edit(self):
197202
self.evaluation = Evaluation.objects.get(pk=self.evaluation.pk)
198203
self.assertEqual(self.evaluation.state, Evaluation.State.PREPARED)
199204

200-
form.submit(name="operation", value="approve")
205+
submit_with_modal(page, form, name="operation", value="approve")
201206
self.evaluation = Evaluation.objects.get(pk=self.evaluation.pk)
202207
self.assertEqual(self.evaluation.state, Evaluation.State.EDITOR_APPROVED)
203208

evap/evaluation/templates/base.html

+8
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
<body>
2929
<script type="text/javascript" src="{% static 'bootstrap/dist/js/bootstrap.bundle.min.js' %}"></script>
3030

31+
{% include "custom_elements.html" %}
32+
3133
{% block modals %}
3234
{% if user.is_authenticated %}
3335
{% trans 'Feedback' as title %}
@@ -199,6 +201,12 @@
199201

200202
</script>
201203

204+
<script type="module">
205+
import { setupForms } from "{% static 'js/custom-success-form.js' %}";
206+
207+
setupForms();
208+
</script>
209+
202210
{% block additional_javascript %}{% endblock %}
203211
</body>
204212
</html>

evap/evaluation/templates/confirmation_modal.html

-33
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{% load static %}
2+
3+
<template id="confirmation-modal-template">
4+
<link rel="stylesheet" href="{% static 'css/evap.css' %}" />
5+
6+
<dialog class="evap-modal-dialog">
7+
<form method="dialog">
8+
<div class="evap-modal-container">
9+
<header>
10+
<h5 class="mb-0"><slot name="title"></slot></h5>
11+
<button class="btn-close"></button>
12+
</header>
13+
<section class="question-area">
14+
<slot name="question"></slot>
15+
<slot name="extra-inputs"></slot>
16+
</section>
17+
<section class="button-area">
18+
<button class="btn btn-light" autofocus>{% trans 'Cancel' %}</button>
19+
<slot name="submit-group">
20+
<button class="btn ms-2" data-event-type="confirm">
21+
<slot name="action-text"></slot>
22+
</button>
23+
</slot>
24+
</section>
25+
</div>
26+
</form>
27+
</dialog>
28+
29+
<slot name="show-button"></slot>
30+
31+
{# All children without the "slot" attribute go into this unnamed slot #}
32+
<slot></slot>
33+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{% load static %}
2+
3+
{% include "confirmation_modal_template.html" %}
4+
5+
<script type="module">
6+
import { ConfirmationModal } from "{% static 'js/confirmation-modal.js' %}";
7+
8+
customElements.define("confirmation-modal", ConfirmationModal);
9+
</script>

evap/evaluation/tests/test_misc.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from model_bakery import baker
1010

1111
from evap.evaluation.models import CourseType, Degree, Semester, UserProfile
12-
from evap.evaluation.tests.tools import make_manager
12+
from evap.evaluation.tests.tools import make_manager, submit_with_modal
1313
from evap.staff.tests.utils import WebTestStaffMode
1414

1515

@@ -36,7 +36,7 @@ def test_sample_semester_file(self):
3636
form = page.forms["semester-import-form"]
3737
form["vote_start_datetime"] = "2015-01-01 11:11:11"
3838
form["vote_end_date"] = "2099-01-01"
39-
form.submit(name="operation", value="import")
39+
submit_with_modal(page, form, name="operation", value="import")
4040

4141
self.assertEqual(UserProfile.objects.count(), original_user_count + 4)
4242

@@ -50,7 +50,7 @@ def test_sample_user_file(self):
5050
page = form.submit(name="operation", value="test")
5151

5252
form = page.forms["user-import-form"]
53-
form.submit(name="operation", value="import")
53+
submit_with_modal(page, form, name="operation", value="import")
5454

5555
self.assertEqual(UserProfile.objects.count(), original_user_count + 2)
5656

evap/evaluation/tests/tools.py

+9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from contextlib import contextmanager
55
from datetime import timedelta
66

7+
import webtest
78
from django.conf import settings
89
from django.contrib.auth.models import Group
910
from django.db import DEFAULT_DB_ALIAS, connections
@@ -125,6 +126,14 @@ def test_check_response_code_200(self):
125126
self.app.get(self.url, user=user, status=200)
126127

127128

129+
def submit_with_modal(page: webtest.TestResponse, form: webtest.Form, *, name: str, value: str) -> webtest.TestResponse:
130+
# Like form.submit, but looks for a modal instead of a submit button.
131+
assert page.forms[form.id] == form
132+
assert page.html.select_one(f"confirmation-modal[type=submit][name={name}][value={value}]")
133+
params = form.submit_fields() + [(name, value)]
134+
return form.response.goto(form.action, method=form.method, params=params)
135+
136+
128137
def get_form_data_from_instance(form_cls, instance, **kwargs):
129138
assert form_cls._meta.model == type(instance)
130139
form = form_cls(instance=instance, **kwargs)

evap/grades/templates/grades_course_view.html

+24-24
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ <h3 class="mb-3">{{ course.name }} ({{ semester.name }})</h3>
1111
</div>
1212
<div class="card-body table-responsive">
1313
{% if grade_documents %}
14+
<form id="grade-document-deletion-form" custom-success method="POST" action="{% url 'grades:delete_grades' %}">
15+
{% csrf_token %}
16+
</form>
17+
1418
<table class="table table-striped table-vertically-aligned">
1519
<thead>
1620
<tr>
@@ -22,23 +26,39 @@ <h3 class="mb-3">{{ course.name }} ({{ semester.name }})</h3>
2226
</thead>
2327
<tbody>
2428
{% for grade_document in grade_documents %}
25-
<tr id="grade_document-row-{{ grade_document.id }}">
29+
<tr id="grade-document-row-{{ grade_document.id }}">
2630
<td>{{ grade_document.description }}</td>
2731
<td>{{ grade_document.get_type_display }}</td>
2832
<td>{{ grade_document.last_modified_time }}, {% trans 'by' %} {{ grade_document.last_modified_user }}</td>
2933
<td>
3034
<a href="{% url 'grades:download_grades' grade_document.id %}" class="btn btn-sm btn-light" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Download' %}"><span class="fas fa-download"></span></a>
3135
{% if user.is_grade_publisher %}
3236
<a href="{% url 'grades:edit_grades' grade_document.id %}" class="btn btn-sm btn-light" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Edit' %}"><span class="fas fa-pencil"></span></a>
33-
<button type="button" onclick="deleteGradedocumentModalShow({{ grade_document.id }}, '{{ grade_document.description|escapejs }}');" class="btn btn-sm btn-danger" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Delete' %}">
34-
<span class="fas fa-trash"></span>
35-
</button>
37+
<confirmation-modal type="submit" form="grade-document-deletion-form" name="grade_document_id" value="{{ grade_document.id }}" confirm-button-class="btn-danger">
38+
<span slot="title">{% trans 'Delete grade document' %}</span>
39+
<span slot="action-text">{% trans 'Delete grade document' %}</span>
40+
<span slot="question">
41+
{% blocktrans trimmed with description=grade_document.description %}
42+
Do you really want to delete the grade document <strong>{{ description }}</strong>?
43+
{% endblocktrans %}
44+
</span>
45+
46+
<button slot="show-button" type="button" class="btn btn-sm btn-danger" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Delete' %}">
47+
<span class="fas fa-trash"></span>
48+
</button>
49+
</confirmation-modal>
3650
{% endif %}
3751
</td>
3852
</tr>
3953
{% endfor %}
4054
</tbody>
4155
</table>
56+
57+
<script type="module">
58+
document.getElementById("grade-document-deletion-form").addEventListener("submit-success", event => {
59+
fadeOutThenRemove(document.getElementById(`grade-document-row-${event.detail.body.get("grade_document_id")}`));
60+
});
61+
</script>
4262
{% else %}
4363
<span class="fst-italic">{% trans 'No grade documents have been uploaded yet' %}</span>
4464
{% endif %}
@@ -49,23 +69,3 @@ <h3 class="mb-3">{{ course.name }} ({{ semester.name }})</h3>
4969
<a href="{% url 'grades:upload_grades' course.id %}?final=true" class="btn btn-dark">{% trans 'Upload new final grades' %}</a>
5070
{% endif %}
5171
{% endblock %}
52-
53-
{% block modals %}
54-
{{ block.super }}
55-
{% trans 'Delete grade document' as title %}
56-
{% trans 'Do you really want to delete the grade document <strong data-label=""></strong>?' as question %}
57-
{% trans 'Delete grade document' as action_text %}
58-
{% include 'confirmation_modal.html' with modal_id='deleteGradedocumentModal' title=title question=question action_text=action_text btn_type='danger' %}
59-
<script type="text/javascript">
60-
function deleteGradedocumentModalAction(dataId) {
61-
fetch("{% url 'grades:delete_grades' %}", {
62-
body: new URLSearchParams({grade_document_id: dataId}),
63-
headers: CSRF_HEADERS,
64-
method: "POST",
65-
}).then(response => {
66-
assert(response.ok);
67-
fadeOutThenRemove(document.getElementById('grade_document-row-'+dataId));
68-
}).catch(error => {window.alert("{% trans 'The server is not responding.' %}");});
69-
};
70-
</script>
71-
{% endblock %}

0 commit comments

Comments
 (0)