Skip to content

Commit 56afc75

Browse files
committed
feat(2447): removes legacy toast, adds No country option, changes Cancel behavior
1 parent e17725d commit 56afc75

5 files changed

Lines changed: 148 additions & 17 deletions

File tree

templates/v3/includes/_button.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
- icon-library (uses btn-icon-library styles; ignores btn + btn-* classes)
2424
- disabled (optional): if truthy, adds disabled attribute to button
2525
- alpine_disabled (optional): Alpine.js expression for dynamic disabled binding (e.g. "hasErrors")
26+
- label_x_text (optional): Alpine.js expression that dynamically overrides the button's visible
27+
label (e.g. "saved ? 'Changes Saved' : 'Save changes'"). Requires an ancestor x-data scope.
28+
Not compatible with icon_html/icon_name (x-text replaces the button's children).
2629
- extra_classes (optional) : Any extra classes that the button should have, as a string
2730
- aria_label (optional): An optional label for assistive technology. Defaults to the button label
2831
- js_disabled (optional, boolean): If this value is true, the button is hidden unless Javascript is disabled on the page
@@ -67,6 +70,7 @@
6770
{% if name %}name="{{ name }}" value="true"{% endif %}
6871
{% if disabled %}disabled{% endif %}
6972
{% if alpine_disabled %}:disabled="{{ alpine_disabled }}"{% endif %}
73+
{% if label_x_text %}x-text="{{ label_x_text }}"{% endif %}
7074
>
7175
{% if icon_position|default:'left' == 'left' %}
7276
{% if icon_html %}

templates/v3/user_profile_edit.html

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
<script type="module" src="{% static 'js/v3/wysiwyg-editor.js' %}"></script>
1212
{% endblock %}
1313

14+
{% comment %} Section save state is shown on each submit button (see saved_sections)
15+
instead of the legacy toast; still iterate `messages` so any queued message is
16+
flushed rather than leaking onto the next page. {% endcomment %}
17+
{% block messages %}
18+
<div id="messages" class="w-full text-center transition-opacity" x-data="{show: true}">
19+
{% for message in messages %}{% endfor %}
20+
</div>
21+
{% endblock messages %}
22+
1423
{% block content %}
1524
<div class="user-profile__container">
1625
<div class="user-profile__profile-content-area">
@@ -19,7 +28,11 @@
1928
{% comment %} novalidate: this form still contains required fields (tagline, bio, links) with
2029
no save handler yet; native constraint validation would silently block "Save changes" for
2130
sections that are otherwise ready. Server-side validation is authoritative. {% endcomment %}
22-
<form method="post" action="{% url 'profile-account' %}?edit=true" class="card__form" enctype="multipart/form-data" novalidate>
31+
<form method="post" action="{{ profile_account_edit_url }}" class="card__form" enctype="multipart/form-data" novalidate
32+
x-data="createSectionForm({{ saved_sections.v3_update_profile|yesno:'true,false' }})"
33+
@submit="handleSubmit($event)"
34+
@input="saved = false" @change="saved = false"
35+
@reset="saved = {{ saved_sections.v3_update_profile|yesno:'true,false' }}">
2336
{% csrf_token %}
2437
<div class="card__header">
2538
<span class="card__title">Profile</span>
@@ -144,8 +157,8 @@
144157
</div>
145158
<hr class="card__hr" aria-hidden="true" />
146159
<div class="card__cta_section">
147-
{% include 'v3/includes/_button.html' with style='primary' type='submit' name='v3_update_profile' label='Save changes' only %}
148-
{% include 'v3/includes/_button.html' with style='secondary' url=profile_account_url label='Cancel' only %}
160+
{% include 'v3/includes/_button.html' with style='primary' type='submit' name='v3_update_profile' label='Save changes' label_x_text="saved ? 'Changes Saved' : 'Save changes'" alpine_disabled='saved' only %}
161+
{% include 'v3/includes/_button.html' with style='secondary' type='reset' label='Cancel' only %}
149162
</div>
150163
</form>
151164
</div>
@@ -168,7 +181,10 @@
168181
</div>
169182
<div class="user-profile__col">
170183
<div class="basic-card card user-profile__update-details">
171-
<form method="post" action="{% url 'profile-account' %}?edit=true" class="card__form">
184+
<form method="post" action="{{ profile_account_edit_url }}" class="card__form"
185+
x-data="createSectionForm({{ saved_sections.v3_update_details|yesno:'true,false' }})"
186+
@submit="handleSubmit($event)"
187+
@input="saved = false" @change="saved = false">
172188
{% csrf_token %}
173189
<div class="card__header">
174190
<span class="card__title">Update Details</span>
@@ -184,7 +200,7 @@
184200
</div>
185201
<hr class="card__hr" aria-hidden="true" />
186202
<div class="card__cta_section">
187-
{% include 'v3/includes/_button.html' with style='primary' type='submit' name='v3_update_details' label='Save details' only %}
203+
{% include 'v3/includes/_button.html' with style='primary' type='submit' name='v3_update_details' label='Save details' label_x_text="saved ? 'Changes Saved' : 'Save details'" alpine_disabled='saved' only %}
188204
</div>
189205
</form>
190206
</div>
@@ -209,7 +225,10 @@
209225
</div>
210226
</div>
211227
<div class="basic-card card user-profile__email-preferences-card">
212-
<form method="post" action="{% url 'profile-account' %}?edit=true" class="card__form">
228+
<form method="post" action="{{ profile_account_edit_url }}" class="card__form"
229+
x-data="createSectionForm({{ saved_sections.v3_update_email_preferences|yesno:'true,false' }})"
230+
@submit="handleSubmit($event)"
231+
@input="saved = false" @change="saved = false">
213232
{% csrf_token %}
214233
<div class="card__header">
215234
<span class="card__title">Email Preferences</span>
@@ -229,7 +248,7 @@
229248
</div>
230249
<hr class="card__hr" aria-hidden="true" />
231250
<div class="card__cta_section">
232-
{% include 'v3/includes/_button.html' with style='primary' type='submit' name='v3_update_email_preferences' label='Update' only %}
251+
{% include 'v3/includes/_button.html' with style='primary' type='submit' name='v3_update_email_preferences' label='Update' label_x_text="saved ? 'Changes Saved' : 'Update'" alpine_disabled='saved' only %}
233252
</div>
234253
</form>
235254
</div>
@@ -283,6 +302,44 @@
283302

284303

285304
document.addEventListener('alpine:init', () => {
305+
// Submits a section's form via fetch so a successful save can flip its
306+
// button to "Changes Saved" without a full-page navigation. On any
307+
// non-success outcome (validation error, network failure, JS/fetch
308+
// unsupported) it falls back to a real form submission, which the
309+
// server renders/redirects exactly as it did before this existed.
310+
window.createSectionForm = function (initialSaved) {
311+
return {
312+
saved: initialSaved,
313+
_bypassFetch: false,
314+
async handleSubmit(event) {
315+
if (this._bypassFetch) {
316+
this._bypassFetch = false
317+
return
318+
}
319+
event.preventDefault()
320+
const form = event.target
321+
const submitter = event.submitter
322+
let succeeded = false
323+
try {
324+
const response = await fetch(form.action, {
325+
method: 'POST',
326+
body: new FormData(form, submitter),
327+
headers: { 'X-Requested-With': 'XMLHttpRequest' },
328+
})
329+
const contentType = response.headers.get('content-type') || ''
330+
if (response.ok && contentType.includes('application/json')) {
331+
this.saved = true
332+
succeeded = true
333+
}
334+
} catch (_) {}
335+
if (!succeeded) {
336+
this._bypassFetch = true
337+
form.requestSubmit(submitter)
338+
}
339+
},
340+
}
341+
}
342+
286343
window.createTagline = function () {
287344
const taglineInput = document.querySelector("input[name='{{user_profile_form.tagline.name}}']")
288345

users/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def __init__(self, *args, **kwargs):
232232
commit_emails = kwargs.pop("commit_emails", None)
233233
self._user = kwargs.pop("user", None)
234234
super().__init__(*args, **kwargs)
235-
self.fields["country"].choices = list(countries)
235+
self.fields["country"].choices = [("", "No country")] + list(countries)
236236
if links:
237237
self.link_formset = V3ProfileLinkFormset(
238238
initial=[

users/tests/test_v3_profile_edit.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,61 @@ def test_v3_update_details_duplicate_username_shows_inline_error(user, tp):
163163
assert user.display_name != "taken-name"
164164

165165

166+
@waffle.testutils.override_flag("v3", active=True)
167+
def test_v3_update_profile_redirect_flags_saved_section(user, tp):
168+
"""The redirect after a successful save carries which section was saved,
169+
so that section's submit button can render a "Changes Saved" state
170+
instead of the legacy toast."""
171+
with tp.login(user):
172+
response = tp.post(
173+
f"{tp.reverse('profile-account')}?edit=true",
174+
data={"v3_update_profile": "true", "hide_github": "on"},
175+
)
176+
assert response.status_code == 302
177+
assert response.url == (
178+
f"{tp.reverse('profile-account')}?edit=true&saved=v3_update_profile"
179+
)
180+
181+
182+
@waffle.testutils.override_flag("v3", active=True)
183+
def test_v3_update_profile_ajax_save_returns_json_without_redirect(user, tp):
184+
"""A fetch-based submit (see createSectionForm in user_profile_edit.html)
185+
gets a JSON response instead of a redirect, so the button can flip to
186+
"Changes Saved" without a full-page navigation."""
187+
with tp.login(user):
188+
response = tp.post(
189+
f"{tp.reverse('profile-account')}?edit=true",
190+
data={"v3_update_profile": "true", "hide_github": "on"},
191+
extra={"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"},
192+
)
193+
assert response.status_code == 200
194+
assert response["Content-Type"] == "application/json"
195+
assert response.json() == {"saved": "v3_update_profile"}
196+
user.refresh_from_db()
197+
assert user.hide_github_activity is True
198+
199+
200+
@waffle.testutils.override_flag("v3", active=True)
201+
def test_v3_edit_page_marks_only_the_saved_section(user, tp):
202+
with tp.login(user):
203+
response = tp.get(
204+
f"{tp.reverse('profile-account')}?edit=true&saved=v3_update_details"
205+
)
206+
tp.response_200(response)
207+
saved_sections = response.context["saved_sections"]
208+
assert saved_sections["v3_update_details"] is True
209+
assert saved_sections["v3_update_profile"] is False
210+
assert saved_sections["v3_update_email_preferences"] is False
211+
212+
213+
@waffle.testutils.override_flag("v3", active=True)
214+
def test_v3_edit_page_no_section_saved_by_default(user, tp):
215+
with tp.login(user):
216+
response = tp.get(f"{tp.reverse('profile-account')}?edit=true")
217+
tp.response_200(response)
218+
assert not any(response.context["saved_sections"].values())
219+
220+
166221
@waffle.testutils.override_flag("v3", active=True)
167222
def test_v3_update_email_preferences_saves(user, tp):
168223
with tp.login(user):

users/views.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django.contrib.auth.mixins import LoginRequiredMixin
77
from django.contrib import auth
88
from django.contrib.messages.views import SuccessMessageMixin
9-
from django.http import HttpResponseRedirect
9+
from django.http import HttpResponseRedirect, JsonResponse
1010
from django.urls import reverse, reverse_lazy
1111
from django.views.generic import DetailView, FormView
1212
from django.views.generic.base import TemplateView
@@ -135,6 +135,9 @@ def get_v3_edit_initial(self):
135135
),
136136
}
137137

138+
def get_v3_edit_url(self):
139+
return f"{reverse('profile-account')}?edit=true"
140+
138141
def get_v3_edit_context(self, form=None):
139142
"""Context for the v3 edit-profile template. `form` is a bound form
140143
(with errors) when re-rendering after a failed POST; otherwise a
@@ -145,10 +148,14 @@ def get_v3_edit_context(self, form=None):
145148
user_links={"website": "www.example.com"},
146149
initial=self.get_v3_edit_initial(),
147150
)
151+
saved_section = self.request.GET.get("saved")
148152
return {
149153
"user_profile_form": form,
150154
"country_options": form.fields["country"].choices,
151-
"profile_account_url": reverse("profile-account"),
155+
"profile_account_edit_url": self.get_v3_edit_url(),
156+
"saved_sections": {
157+
key: key == saved_section for key in self.V3_EDIT_SECTIONS
158+
},
152159
"badge_tiers": [
153160
{"tier": "1", "name": "Bronze"},
154161
{"tier": "2", "name": "Silver"},
@@ -561,15 +568,15 @@ def post_v3(self, request, *args, **kwargs):
561568
"""Handle the v3 edit-profile page's independently-submitted section
562569
forms. Each section has its own <form>/submit button; only the
563570
fields owned by that section are validated and persisted."""
564-
edit_url = f"{reverse('profile-account')}?edit=true"
571+
edit_url = self.get_v3_edit_url()
565572

566-
section = next(
567-
(v for key, v in self.V3_EDIT_SECTIONS.items() if key in request.POST),
573+
section_key = next(
574+
(key for key in self.V3_EDIT_SECTIONS if key in request.POST),
568575
None,
569576
)
570-
if section is None:
577+
if section_key is None:
571578
return HttpResponseRedirect(edit_url)
572-
section_fields, save_method_name = section
579+
section_fields, save_method_name = self.V3_EDIT_SECTIONS[section_key]
573580

574581
form = V3UserProfileForm(
575582
request.POST,
@@ -592,8 +599,16 @@ def post_v3(self, request, *args, **kwargs):
592599

593600
getattr(self, save_method_name)(request.user, form)
594601

595-
messages.success(request, self.success_message)
596-
return HttpResponseRedirect(edit_url)
602+
# The saved section's submit button shows a "Changes Saved" state
603+
# instead (see get_v3_edit_context's saved_sections); the legacy
604+
# toast is suppressed on this page. JS submits this fetch-style (see
605+
# createSectionForm in user_profile_edit.html) so a successful save
606+
# can flip the button state without a full-page navigation; a plain
607+
# HTML form post (no JS) still gets the redirect it needs to see the
608+
# saved state on reload.
609+
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
610+
return JsonResponse({"saved": section_key})
611+
return HttpResponseRedirect(f"{edit_url}&saved={section_key}")
597612

598613
def _save_v3_visibility_section(self, user, form):
599614
user.hide_github_activity = form.cleaned_data["hide_github"]

0 commit comments

Comments
 (0)