Skip to content

Commit a53bcb4

Browse files
Story 2449: Webpage Integration: Edit User Profile - Profile Links (boostorg#2514)
Co-authored-by: Teodoro B. Mendes <teodoro.mendes@metalab.com>
1 parent 6bbb0aa commit a53bcb4

12 files changed

Lines changed: 385 additions & 9 deletions

File tree

core/context_processors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ class NavLink:
213213
is_unread: bool = False
214214

215215

216+
def edit_profile_url():
217+
"""Shared CTA URL for the profile edit page."""
218+
return f"{reverse('profile-account')}?edit=True"
219+
220+
216221
def header_context(request):
217222
"""Context processor for header nav links."""
218223
nav_links = [
@@ -229,6 +234,7 @@ def header_context(request):
229234
return {
230235
"nav_links": nav_links,
231236
"releases_url": reverse("releases-most-recent"),
237+
"edit_profile_url": edit_profile_url(),
232238
}
233239

234240

templates/v3/includes/_button.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- icon-library (uses btn-icon-library styles; ignores btn + btn-* classes)
2222
- disabled (optional): if truthy, adds disabled attribute to button
2323
- alpine_disabled (optional): Alpine.js expression for dynamic disabled binding (e.g. "hasErrors")
24+
- alpine_click (optional): Alpine.js expression run on click (e.g. "save()")
2425
- extra_classes (optional) : Any extra classes that the button should have, as a string
2526
- aria_label (optional): An optional label for assistive technology. Defaults to the button label
2627
- js_disabled (optional, boolean): If this value is true, the button is hidden unless Javascript is disabled on the page
@@ -64,6 +65,7 @@
6465
aria-label="{{aria_label|default:label}}"
6566
{% if disabled %}disabled{% endif %}
6667
{% if alpine_disabled %}:disabled="{{ alpine_disabled }}"{% endif %}
68+
{% if alpine_click %}@click="{{ alpine_click }}"{% endif %}
6769
>
6870
{% if icon_position|default:'left' == 'left' %}
6971
{% if icon_html %}

templates/v3/includes/_field_text.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
V3 text input field.
33
Variables:
44
name (required) — input name attribute
5+
data_link_type (optional) — renders a data-link-type attribute for JS to key off of,
6+
instead of relying on DOM/array position
57
label (optional) — label text
8+
aria_label (optional) — accessible name for the input when no visible label is rendered
69
placeholder (optional) — placeholder text
710
value (optional) — pre-filled value; with submit_icon, also seeds the Alpine x-model state so it survives init
811
type (optional) — input type, default "text"
@@ -96,6 +99,8 @@
9699
type="{{ type|default:'text' }}"
97100
id="field-{{ name }}"
98101
name="{{ name }}"
102+
{% if data_link_type %}data-link-type="{{ data_link_type }}"{% endif %}
103+
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
99104
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
100105
{% if value %}value="{{ value }}"{% endif %}
101106
{% if required %}required{% endif %}

templates/v3/includes/header/_header_avatar.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@
2525
<div id="{{ avatar_id }}-menu" role="group" class="header__user-dropdown">
2626
{% include "v3/includes/header/_header_nav_link.html" with url=logout_url label="Logout" %}
2727
{% include "v3/includes/header/_header_nav_link.html" with url=profile_url label="My Profile" %}
28-
{% include "v3/includes/header/_header_nav_link.html" with url=profile_url label="Edit Profile" %}
28+
{% include "v3/includes/header/_header_nav_link.html" with url=edit_profile_url label="Edit Profile" %}
2929
</div>
3030
</div>

templates/v3/user_profile_edit.html

Lines changed: 156 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<div class="user-profile__container">
1616
<div class="user-profile__profile-content-area">
1717
<div class="user-profile__col">
18-
<div class="basic-card card user-profile__profile-edit-card">
18+
<div class="basic-card card user-profile__profile-edit-card" x-data="profileForm">
1919
<div class="card__header">
2020
<span class="card__title">Profile</span>
2121
</div>
@@ -77,9 +77,11 @@
7777
<hr class="card__hr" aria-hidden="true" />
7878
<div class="card__column px-large">
7979
<span class="user-profile__link-section-heading">Profile Links</span>
80-
<div class="user-profile__two-column-inputs">
80+
{% comment %} Link rows: locked label (type) + value input. Inline validation (HTTPS for GitHub/Website, email format, Slack Member ID) is driven by profileForm; errors is keyed by link type. {% endcomment %}
81+
<div class="user-profile__two-column-inputs" @input="clearLinkError($event)">
8182
{% for form in user_profile_form.link_formset %}
82-
{% include 'v3/includes/_form_include.html' with form=form only %}
83+
{% include 'v3/includes/_field_include.html' with bound_field=form.type only %}
84+
{% include 'v3/includes/_field_text.html' with name=form.value.html_name value=form.value.value placeholder=form.value.field.widget.attrs.placeholder aria_label=form.type.value|capfirst|add:" link" alpine_error="errors."|add:form.type.value data_link_type=form.type.value only %}
8385
{% endfor %}
8486
</div>
8587
<div class="field__help">All links will be displayed publicly on your profile for other users to see.</div>
@@ -138,8 +140,16 @@
138140
</div>
139141
</div>
140142
<hr class="card__hr" aria-hidden="true" />
143+
{% comment %} Persists the Profile links to the current user via PATCH /api/v1/users/me/. {% endcomment %}
141144
<div class="card__cta_section">
142-
{% include 'v3/includes/_button.html' with style='primary' type='submit' label='Save Changes' only %}
145+
{% include 'v3/includes/_button.html' with style='primary' type='button' label='Save Changes' alpine_disabled="saving" alpine_click="save()" only %}
146+
<span class="user-profile__save-indicator" role="status" aria-live="polite">
147+
<span class="user-profile__save-state" x-show="saveStatus === 'saved'" x-cloak>
148+
<span class="user-profile__save-text">Saved</span>
149+
{% include "includes/icon.html" with icon_name="saved" icon_class="create-post-page__save-icon" icon_size=16 %}
150+
</span>
151+
<span class="field__error" x-show="saveStatus === 'error'" x-text="saveError" x-cloak></span>
152+
</span>
143153
</div>
144154
</div>
145155
<div class="basic-card card user-profile__delete-account">
@@ -400,6 +410,148 @@
400410
},
401411
};
402412
};
413+
414+
window.profileForm = function () {
415+
// Link value inputs each carry a data-link-type attribute (rendered from
416+
// V3ProfileLinkChoices), so lookups key off that instead of DOM position.
417+
const LINK_TYPES = ['github', 'website', 'email', 'slack'];
418+
const linkInput = (type) => document.querySelector(`[data-link-type='${type}']`);
419+
const SLACK_PROFILE_URL_PREFIX = '{{ SLACK_PROFILE_URL_PREFIX|escapejs }}';
420+
const SLACK_MEMBER_ID_PATTERN = /^[A-Z0-9]{9,11}$/i;
421+
422+
return {
423+
saving: false,
424+
saveStatus: '', // '' | 'saved' | 'error' (button shows "saving" via :disabled)
425+
saveError: '',
426+
errors: { github: '', website: '', email: '', slack: '' }, // inline per-link errors, keyed by type
427+
428+
// Clear a link's inline error as soon as the user edits that field.
429+
clearLinkError(e) {
430+
const type = e.target.dataset.linkType;
431+
if (type) this.errors[type] = '';
432+
this.saveStatus = '';
433+
this.saveError = '';
434+
},
435+
436+
isSecureUrl(value) {
437+
try {
438+
const url = new URL(value);
439+
return url.protocol === 'https:' && Boolean(url.hostname);
440+
} catch {
441+
return false;
442+
}
443+
},
444+
445+
isUrl(value) {
446+
try {
447+
new URL(value);
448+
return true;
449+
} catch {
450+
return false;
451+
}
452+
},
453+
454+
// '' when valid, otherwise the inline error message. Empty is allowed
455+
// (the link is simply unset).
456+
validateLink(type, value) {
457+
if (!value) return '';
458+
if (value.length > 200) return 'Must be 200 characters or fewer';
459+
if (type === 'github' || type === 'website') {
460+
return this.isSecureUrl(value) ? '' : 'Please add a secure link';
461+
}
462+
if (type === 'email') {
463+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '' : 'Please enter a valid email address';
464+
}
465+
if (type === 'slack') {
466+
// A pasted link must be our canonical CPPLang Slack profile URL;
467+
// anything else that isn't a URL is checked against the Member ID shape.
468+
if (this.isUrl(value)) {
469+
return value.toLowerCase().startsWith(SLACK_PROFILE_URL_PREFIX.toLowerCase())
470+
? ''
471+
: 'Please enter a valid CPPLang profile URL or Member ID';
472+
}
473+
return SLACK_MEMBER_ID_PATTERN.test(value) ? '' : 'Please enter a valid CPPLang Member ID';
474+
}
475+
return '';
476+
},
477+
478+
// Build the profile_links payload, dropping empties. Slack takes a
479+
// Member ID; store the canonical profile URL built from it (kept in
480+
// sync with SLACK_PROFILE_URL_PREFIX in users/forms.py, which
481+
// reverses it for prefill).
482+
collectLinks() {
483+
const links = {};
484+
LINK_TYPES.forEach((type) => {
485+
let value = (linkInput(type)?.value || '').trim();
486+
if (!value) return;
487+
if (type === 'slack') {
488+
// Accept a bare Member ID or a pasted full profile URL — store one canonical URL.
489+
let memberId = value;
490+
if (memberId.toLowerCase().startsWith(SLACK_PROFILE_URL_PREFIX.toLowerCase())) {
491+
memberId = memberId.slice(SLACK_PROFILE_URL_PREFIX.length);
492+
}
493+
value = SLACK_PROFILE_URL_PREFIX + memberId;
494+
}
495+
links[type] = value;
496+
});
497+
return links;
498+
},
499+
500+
async save() {
501+
if (this.saving) return;
502+
503+
// Validate all links up front; block the save if any fail.
504+
let hasError = false;
505+
LINK_TYPES.forEach((type) => {
506+
const msg = this.validateLink(type, (linkInput(type)?.value || '').trim());
507+
this.errors[type] = msg;
508+
if (msg) hasError = true;
509+
});
510+
if (hasError) return;
511+
512+
this.saving = true;
513+
this.saveStatus = '';
514+
this.saveError = '';
515+
try {
516+
const res = await fetch('/api/v1/users/me/', {
517+
method: 'PATCH',
518+
headers: {
519+
'X-CSRFToken': '{{ csrf_token }}',
520+
'Content-Type': 'application/json',
521+
},
522+
body: JSON.stringify({ profile_links: this.collectLinks() }),
523+
});
524+
if (!res.ok) {
525+
let data = {};
526+
try {
527+
data = await res.json();
528+
} catch (_) {}
529+
530+
// Per-link validation errors (e.g. the backend secure-URL/Slack
531+
// checks) come back keyed by link type; route them to their own
532+
// field instead of the banner next to the Save button.
533+
const linkErrors = data.profile_links;
534+
if (linkErrors && typeof linkErrors === 'object' && !Array.isArray(linkErrors)) {
535+
Object.entries(linkErrors).forEach(([type, msg]) => {
536+
this.errors[type] = Array.isArray(msg) ? msg[0] : msg;
537+
});
538+
return;
539+
}
540+
541+
const fieldErrors = Object.values(data).flat().filter(Boolean);
542+
throw new Error(fieldErrors.length ? fieldErrors.join(' ') : `Save failed (${res.status})`);
543+
}
544+
this.saveStatus = 'saved';
545+
} catch (err) {
546+
console.error('Profile save failed:', err);
547+
this.saveStatus = 'error';
548+
this.saveError = err.message || 'Could not save. Please try again.';
549+
} finally {
550+
this.saving = false;
551+
}
552+
},
553+
};
554+
};
403555
});
404556

405557
</script>

users/forms.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,18 @@ class V3ProfileLinkChoices(models.TextChoices):
197197
SLACK = "slack"
198198

199199

200+
# Slack links persist as the public profile URL from the CPPLang Slack
201+
# workspace (kept in sync with the frontend construction in
202+
# user_profile_edit.html) — the same link produced by "Copy link to profile"
203+
# in a user's Slack profile within the CPPLang workspace.
204+
SLACK_PROFILE_URL_PREFIX = "https://cpplang.slack.com/team/"
205+
206+
200207
class V3ProfileLinkForm(forms.Form):
201208
type = forms.ChoiceField(
202209
choices=V3ProfileLinkChoices.choices, disabled=True, label=""
203210
)
204-
value = forms.CharField(max_length=80, label="")
211+
value = forms.CharField(max_length=200, label="")
205212

206213

207214
V3ProfileLinkFormset = forms.formset_factory(V3ProfileLinkForm, extra=0)
@@ -248,7 +255,7 @@ def __init__(self, *args, **kwargs):
248255
elif field_value == V3ProfileLinkChoices.EMAIL:
249256
placeholder = "example@example.com"
250257
elif field_value == V3ProfileLinkChoices.SLACK:
251-
placeholder = "@"
258+
placeholder = "CPPLang profile URL"
252259
form.fields["value"].widget.attrs["placeholder"] = placeholder
253260

254261
# Left Column Fields
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 6.0.2 on 2026-07-01 18:56
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("users", "0021_add_v3_testers_group"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="user",
15+
name="profile_links",
16+
field=models.JSONField(
17+
blank=True,
18+
default=dict,
19+
help_text=(
20+
"Public profile links keyed by type (github, website, email, "
21+
"slack). Slack stores the full canonical CPPLang Slack "
22+
"profile URL (https://cpplang.slack.com/team/<Member ID>) "
23+
"built from the user's Slack Member ID. The Member ID is "
24+
"derived for display in the edit form."
25+
),
26+
),
27+
),
28+
]

users/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,17 @@ class User(BaseUser):
261261
default=False,
262262
help_text="Indicate on the login page the last login method used.",
263263
)
264+
profile_links = models.JSONField(
265+
default=dict,
266+
blank=True,
267+
help_text=(
268+
"Public profile links keyed by type (github, website, email, slack). "
269+
"Slack stores the full canonical CPPLang Slack profile URL "
270+
"(https://cpplang.slack.com/team/<Member ID>) built from the "
271+
"user's Slack Member ID. The Member ID is derived for display in "
272+
"the edit form."
273+
),
274+
)
264275
# If non-null, the user has requested deletion but the grace period has not
265276
# elapsed.
266277
delete_permanently_at = models.DateTimeField(null=True, editable=False)

0 commit comments

Comments
 (0)