|
15 | 15 | <div class="user-profile__container"> |
16 | 16 | <div class="user-profile__profile-content-area"> |
17 | 17 | <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"> |
19 | 19 | <div class="card__header"> |
20 | 20 | <span class="card__title">Profile</span> |
21 | 21 | </div> |
|
77 | 77 | <hr class="card__hr" aria-hidden="true" /> |
78 | 78 | <div class="card__column px-large"> |
79 | 79 | <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)"> |
81 | 82 | {% 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 %} |
83 | 85 | {% endfor %} |
84 | 86 | </div> |
85 | 87 | <div class="field__help">All links will be displayed publicly on your profile for other users to see.</div> |
|
138 | 140 | </div> |
139 | 141 | </div> |
140 | 142 | <hr class="card__hr" aria-hidden="true" /> |
| 143 | + {% comment %} Persists the Profile links to the current user via PATCH /api/v1/users/me/. {% endcomment %} |
141 | 144 | <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> |
143 | 153 | </div> |
144 | 154 | </div> |
145 | 155 | <div class="basic-card card user-profile__delete-account"> |
|
400 | 410 | }, |
401 | 411 | }; |
402 | 412 | }; |
| 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 | + }; |
403 | 555 | }); |
404 | 556 |
|
405 | 557 | </script> |
|
0 commit comments