Skip to content

Commit 2a0d32b

Browse files
Merge pull request #1874 from betagouv/feat/module-alert-onleave#1860
✅ Module d'alerte en cas de saisie non terminée
2 parents 5abb9ad + 870cd5c commit 2a0d32b

7 files changed

Lines changed: 174 additions & 4 deletions

File tree

recoco/apps/projects/templates/projects/project/conversations_new.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,9 @@ <h3 class="fr-mt-2w conversation-new__topics-list-member-subtitle">Equipe de sui
118118
</div>
119119
</template>
120120
{% if not is_project_owner %}
121-
{% include "tools/editor.html" with model="message" input_name=posting_form.content.name initial_content=posting_form.content.value|default:'' errors=posting_form.content.errors input_required=True can_add_reco=True next_url_add_reco="projects-project-detail-conversations" can_attach_files=True can_attach_contact=True show_send_button=True form_id="conversation-form" placeholder="Écrivez votre message ici. Ajoutez des fichiers, contacts ou une recommandation…" can_compress_editor=True %}
121+
{% include "tools/editor.html" with model="message" input_name=posting_form.content.name initial_content=posting_form.content.value|default:'' errors=posting_form.content.errors input_required=True can_add_reco=True next_url_add_reco="projects-project-detail-conversations" can_attach_files=True can_attach_contact=True show_send_button=True form_id="conversation-form" placeholder="Écrivez votre message ici. Ajoutez des fichiers, contacts ou une recommandation…" can_compress_editor=True on_leave_alert=True %}
122122
{% else %}
123-
{% include "tools/editor.html" with model="message" input_name=posting_form.content.name initial_content=posting_form.content.value|default:'' errors=posting_form.content.errors input_required=True can_add_reco=False next_url_add_reco="projects-project-detail-conversations" can_attach_files=True can_attach_contact=False show_send_button=True form_id="conversation-form" placeholder="Écrivez votre message ici. Vous pouvez ajouter des fichiers." can_compress_editor=True %}
123+
{% include "tools/editor.html" with model="message" input_name=posting_form.content.name initial_content=posting_form.content.value|default:'' errors=posting_form.content.errors input_required=True can_add_reco=False next_url_add_reco="projects-project-detail-conversations" can_attach_files=True can_attach_contact=False show_send_button=True form_id="conversation-form" placeholder="Écrivez votre message ici. Vous pouvez ajouter des fichiers." can_compress_editor=True on_leave_alert=True %}
124124
{% endif %}
125125
<input type="hidden" name="contact" :value="message?.contact?.id">
126126
</div>

recoco/frontend/src/js/components/Editor.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { ToastType } from '../models/toastType';
1313

1414
const MarkdownEditor = createMarkdownEditor(Editor);
1515

16-
Alpine.data('editor', (content, placeholder, isActionPusher = false) => {
16+
Alpine.data('editor', (content, placeholder, isActionPusher = false, onLeaveAlert = false) => {
1717
let editor;
1818

1919
return {
@@ -104,6 +104,9 @@ Alpine.data('editor', (content, placeholder, isActionPusher = false) => {
104104
_this.$nextTick(() => {
105105
_this.updatedAt = Date.now();
106106
});
107+
if (onLeaveAlert) {
108+
_this.$store.onLeaveAlert.setDirty(true);
109+
}
107110
},
108111
onSelectionUpdate({ editor }) {
109112
_this.updatedAt = Date.now();

recoco/frontend/src/js/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import './store/utils';
2323
import './store/editor';
2424
import './store/djangoData';
2525
import './store/app';
26+
import './store/onLeaveAlert';
2627

2728
//Global reused component
2829
import './components/Notification';
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import Alpine from 'alpinejs';
2+
3+
/**
4+
* Store global pour gérer les alertes de sortie avec saisie en cours.
5+
*
6+
* Usage depuis n'importe quel composant :
7+
*
8+
* // Signaler une saisie en cours
9+
* this.$store.onLeaveAlert.setDirty(true);
10+
*
11+
* // Signaler la fin de la saisie (après submit)
12+
* this.$store.onLeaveAlert.setDirty(false);
13+
*
14+
* Le store intercepte automatiquement :
15+
* - La fermeture de l'onglet/navigateur (beforeunload)
16+
* - Les clics sur les liens de navigation
17+
*/
18+
Alpine.store('onLeaveAlert', {
19+
isDirty: false,
20+
isOpen: false,
21+
pendingNavigation: null,
22+
23+
init() {
24+
this.setupBeforeUnload();
25+
this.setupNavigationInterception();
26+
},
27+
28+
/**
29+
* Définit l'état de saisie en cours
30+
*/
31+
setDirty(value) {
32+
this.isDirty = value;
33+
},
34+
35+
/**
36+
* Configure l'événement beforeunload
37+
*/
38+
setupBeforeUnload() {
39+
window.addEventListener('beforeunload', (event) => {
40+
if (this.isDirty) {
41+
event.preventDefault();
42+
}
43+
});
44+
},
45+
46+
/**
47+
* Intercepte les clics sur les liens
48+
*/
49+
setupNavigationInterception() {
50+
document.addEventListener(
51+
'click',
52+
(event) => {
53+
if (!this.isDirty) return;
54+
55+
const link = event.target.closest('a[href]');
56+
if (!link) return;
57+
58+
// Ignorer certains liens
59+
if (link.matches('[data-no-leave-alert]')) return;
60+
if (link.target === '_blank') return;
61+
if (link.hasAttribute('aria-controls')) return;
62+
63+
const href = link.getAttribute('href');
64+
const normalizedHref = href ? href.trim().toLowerCase() : '';
65+
if (
66+
!href ||
67+
normalizedHref.startsWith('#') ||
68+
normalizedHref.startsWith('javascript:') ||
69+
normalizedHref.startsWith('data:') ||
70+
normalizedHref.startsWith('vbscript:')
71+
)
72+
return;
73+
74+
event.preventDefault();
75+
event.stopPropagation();
76+
this.pendingNavigation = href;
77+
this.isOpen = true;
78+
},
79+
true
80+
);
81+
},
82+
83+
/**
84+
* Confirme la sortie
85+
*/
86+
confirmLeave() {
87+
const href = this.pendingNavigation;
88+
this.isDirty = false;
89+
this.isOpen = false;
90+
this.pendingNavigation = null;
91+
92+
if (href) {
93+
window.location.href = href;
94+
}
95+
},
96+
97+
/**
98+
* Annule la sortie
99+
*/
100+
cancelLeave() {
101+
this.isOpen = false;
102+
this.pendingNavigation = null;
103+
},
104+
});
105+
106+
export default Alpine.store('onLeaveAlert');

recoco/templates/default_site/base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ <h1 class="visually-hidden">{{ request.site.name }}</h1>
9898
{% endblock footer %}
9999
</div>
100100
</main>
101+
{% include "tools/on_leave_modal.html" %}
101102
{% if not debug %}
102103
<script src="https://sentry.incubateur.net/js-sdk-loader/caf46e9eca66a6aee8e9dfc9434f00a8.min.js"
103104
crossorigin="anonymous"></script>

recoco/templates/default_site/tools/editor.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
{% vite_asset 'js/styles/contact-card.css.js' %}
2525
{% endblock js %}
2626
<div class="w-100 position-relative editor-container"
27-
{% if initial_content_escapejs %} x-data='editor("{{ initial_content|escapejs }}", "{{ placeholder }}", "{{ is_action_pusher }}" == "True" )' {% elif initial_content_js %} x-data='editor({{ initial_content }}, "{{ placeholder }}", "{{ is_action_pusher }}" == "True")' {% else %} x-data='editor("{{ initial_content }}", "{{ placeholder }}", "{{ is_action_pusher }}" == "True")' {% endif %}
27+
{% if initial_content_escapejs %} x-data='editor("{{ initial_content|escapejs }}", "{{ placeholder }}", "{{ is_action_pusher }}" == "True", {{ on_leave_alert|yesno:"true,false" }})' {% elif initial_content_js %} x-data='editor({{ initial_content }}, "{{ placeholder }}", "{{ is_action_pusher }}" == "True", {{ on_leave_alert|yesno:"true,false" }})' {% else %} x-data='editor("{{ initial_content }}", "{{ placeholder }}", "{{ is_action_pusher }}" == "True", {{ on_leave_alert|yesno:"true,false" }})' {% endif %}
2828
@reset-contact="handleResetContact()"
2929
@set-comment.window="setMarkdownContent(event)"
3030
@modal-response="closeSearchContactModal($event)"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{# Modale DSFR d'alerte pour saisie non terminée #}
2+
{# Le store $store.onLeaveAlert gère l'affichage automatiquement #}
3+
<dialog aria-labelledby="on-leave-alert-modal-title"
4+
aria-describedby="on-leave-alert-modal-desc"
5+
id="on-leave-alert-modal"
6+
class="fr-modal"
7+
x-data="{ previousActiveElement: null }"
8+
x-trap.inert.noscroll="$store.onLeaveAlert.isOpen"
9+
@keydown.escape.window="$store.onLeaveAlert.isOpen && $store.onLeaveAlert.cancelLeave()"
10+
x-effect="if ($store.onLeaveAlert.isOpen) {
11+
previousActiveElement = document.activeElement;
12+
$nextTick(() => $refs.stayButton?.focus());
13+
} else if (previousActiveElement) {
14+
previousActiveElement.focus();
15+
previousActiveElement = null;
16+
}"
17+
:open="$store.onLeaveAlert.isOpen"
18+
:class="{ 'fr-modal--opened': $store.onLeaveAlert.isOpen }"
19+
:aria-modal="$store.onLeaveAlert.isOpen"
20+
:aria-hidden="!$store.onLeaveAlert.isOpen"
21+
role="alertdialog">
22+
<div class="fr-container fr-container--fluid fr-container-md">
23+
<div class="fr-grid-row fr-grid-row--center">
24+
<div class="fr-col-12 fr-col-md-8 fr-col-lg-6">
25+
<div class="fr-modal__body">
26+
<div class="fr-modal__header">
27+
<button class="fr-btn--close fr-btn"
28+
title="Fermer la fenêtre de dialogue"
29+
aria-label="Fermer la fenêtre de dialogue"
30+
@click="$store.onLeaveAlert.cancelLeave()">Fermer</button>
31+
</div>
32+
<div class="fr-modal__content">
33+
<h1 id="on-leave-alert-modal-title" class="fr-modal__title">
34+
<span class="fr-icon-warning-fill fr-icon--lg" aria-hidden="true"></span>
35+
Modifications non enregistrées
36+
</h1>
37+
<p id="on-leave-alert-modal-desc">
38+
Vous avez des modifications non enregistrées.
39+
Si vous quittez cette page, vos changements seront perdus.
40+
</p>
41+
</div>
42+
<div class="fr-modal__footer">
43+
<div class="fr-btns-group fr-btns-group--inline-reverse fr-btns-group--inline-lg">
44+
<button class="fr-btn"
45+
x-ref="stayButton"
46+
@click="$store.onLeaveAlert.cancelLeave()">
47+
Rester sur la page
48+
</button>
49+
<button class="fr-btn fr-btn--secondary"
50+
@click="$store.onLeaveAlert.confirmLeave()">
51+
Quitter sans enregistrer
52+
</button>
53+
</div>
54+
</div>
55+
</div>
56+
</div>
57+
</div>
58+
</div>
59+
</dialog>

0 commit comments

Comments
 (0)