Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b942349
Start: make the profile page the place to edit or request edits
maebeale Oct 14, 2026
550b759
Make a person's own profile the place to edit or ask for changes
maebeale Oct 14, 2026
78b2871
Give "Choose what others see" its own short page
maebeale Oct 14, 2026
4007b08
Add free-text change requests, switch toggles, and save/Ahoy coverage
maebeale Oct 14, 2026
75d23b6
Pick an organization before asking for an organization change
maebeale Oct 14, 2026
da25abd
Ask one question per page when changing an organization
maebeale Oct 14, 2026
3bc83d0
Give owners one simple page to edit their profile
maebeale Oct 14, 2026
16d302a
Show which sector and age range is main on the edit page
maebeale Oct 14, 2026
b9ed9ba
Use slider switches for sectors and ages, and regroup name fields
maebeale Oct 14, 2026
b2e0135
Let people pick their main sector and age range on the edit page
maebeale Oct 14, 2026
52b7b79
Bring owner-editable fields from main onto the simple edit page
maebeale Oct 14, 2026
a08a742
Show emails on the simple edit page, with a way to ask for changes
maebeale Oct 14, 2026
1e4abea
Open email change requests in a new tab
maebeale Oct 14, 2026
af5d1a7
Let people add an organization that isn't listed yet
maebeale Oct 14, 2026
078218e
Explain where the display name shows, and name the free-text request
maebeale Oct 14, 2026
0597685
Drop the visible "Show my name as" label
maebeale Oct 14, 2026
ec8ce98
Let owners add addresses and pick the main one, and tighten the phone…
maebeale Oct 14, 2026
a758494
Label the main-address star, add licenses like addresses, align email…
maebeale Oct 14, 2026
b5ace0f
Add phones like addresses, with a main-phone star, and fix headings
maebeale Oct 14, 2026
ff689a1
Add an Ask a question button to the simple edit page
maebeale Oct 14, 2026
fa8ef33
Add Ask a question to the profile banner
maebeale Oct 14, 2026
1eb66bf
Lead the profile banner buttons with Choose what others see
maebeale Oct 14, 2026
4f13295
Keep Ask a question on the profile banner only
maebeale Oct 14, 2026
0315e80
Show affiliations on the edit page, and rename the question button
maebeale Oct 14, 2026
5b25924
Put Edit my profile in the banner's button row, drop the question button
maebeale Oct 14, 2026
d60f307
Send the hidden-pieces card's sharing link to Choose what others see
maebeale Oct 14, 2026
6b703cd
Simplify the profile banner and let owners add several items at once
maebeale Oct 14, 2026
48f3a67
Let owners see and pick the work address on each affiliation
maebeale Oct 14, 2026
578267b
Prompt for a hidden bio inside the Hidden from profile card (#2688)
maebeale Oct 14, 2026
a9fd56e
Fold the blank-bio prompt into Hidden from profile whatever the toggle
maebeale Oct 14, 2026
cf3d0cb
Show the dotted Add a bio card again when the bio is shared
maebeale Oct 14, 2026
e77c9ca
Let admins fold the Admin details card on a profile
maebeale Oct 14, 2026
9306395
Put the work address beside each affiliation title
maebeale Oct 14, 2026
0a3ae43
Line up Request a change with the work address dropdown
maebeale Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class PeopleController < ApplicationController
include AhoyTracking, TagAssignable, Dedupable
before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio all_comments send_form_link ]
before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio all_comments send_form_link visibility details ]

# The profile's "Submitted content" sections — private to the person and admins,
# not part of the public profile even after profile viewing opens up.
Expand Down Expand Up @@ -243,6 +243,15 @@ def edit
set_form_variables
end

def visibility
authorize! @person
end

def details
authorize! @person
set_form_variables
end

def create
@person = Person.new(person_params.except(:user_attributes))
authorize! @person
Expand Down Expand Up @@ -303,7 +312,7 @@ def update
redirect_to person_update_return_path, notice: "Person was successfully updated."
else
set_form_variables
render :edit, status: :unprocessable_content
render(params[:return_to].presence_in(%w[visibility details]) || :edit, status: :unprocessable_content)
end
end

Expand Down
56 changes: 49 additions & 7 deletions app/controllers/profile_change_requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,10 @@ def new
@person = Person.find(params[:person_id])
@request = @person.profile_change_requests.new(field: requested_field, requested_by: current_user)
authorize! @request

# Affiliation requests target a specific affiliation chosen on the form, so we
# can't resolve "the" existing one here — only the single-target fields dedupe.
return if requested_field == "affiliation"
return prepare_affiliation_request if requested_field == "affiliation"

existing = @person.profile_change_requests.pending.find_by(field: requested_field)
redirect_to edit_profile_change_request_path(existing) if existing
redirect_to edit_profile_change_request_path(existing, return_to: return_to) if existing
end

def create
Expand All @@ -34,7 +31,7 @@ def create

if @request.save
notify_admins_and_submitter(@request)
redirect_to edit_person_path(@person, anchor: "affiliations"), status: :see_other,
redirect_to @request.decorate.profile_return_path(return_to), status: :see_other,
notice: "Thanks — we've sent your change request to AWBW staff."
else
@person = @request.person
Expand All @@ -51,7 +48,7 @@ def update
authorize! @request

if @request.update(profile_change_request_params)
redirect_to edit_person_path(@request.person, anchor: "affiliations"), status: :see_other,
redirect_to @request.decorate.profile_return_path(return_to), status: :see_other,
notice: "Your change request was updated."
else
@person = @request.person
Expand Down Expand Up @@ -102,6 +99,51 @@ def review_note
params[:reviewer_note].presence
end

# One question per page: which organization (skipped when there's only one),
# then what to change, then a form with just those inputs.
def prepare_affiliation_request
@affiliations = @person.affiliations.includes(:organization).select(&:organization)
.sort_by { |a| [ a.active? ? 0 : 1, a.organization.name.downcase ] }
organizations_path = new_profile_change_request_path(person_id: @person, field: "affiliation", return_to: return_to)
profile_link = if return_to
[ details_person_path(@person, anchor: "affiliations"), "Back to editing my profile" ]
else
[ person_path(@person, anchor: "affiliations"), "Back to my profile" ]
end
organizations_link = @affiliations.many? ? [ organizations_path, "Back to my organizations" ] : profile_link

if params[:add].present? || @affiliations.empty?
@request.requested_value = ProfileChangeRequest::AFFILIATION_CHANGE_ADD
@back_path, @back_label = @affiliations.any? ? [ organizations_path, "Back to my organizations" ] : profile_link
return
end

@request.affiliation = params[:affiliation_id].present? ? @affiliations.find { |a| a.id.to_s == params[:affiliation_id].to_s } : (@affiliations.first if @affiliations.one?)
unless @request.affiliation
@pending_by_affiliation = @person.profile_change_requests.pending.where(field: "affiliation").index_by(&:affiliation_id)
@back_path, @back_label = profile_link
return render :choose_affiliation
end

existing = @person.profile_change_requests.pending.find_by(field: "affiliation", affiliation: @request.affiliation)
return redirect_to edit_profile_change_request_path(existing, return_to: return_to) if existing

change = params[:change].presence_in(ProfileChangeRequest::AFFILIATION_CHANGE_TYPES - [ ProfileChangeRequest::AFFILIATION_CHANGE_ADD ])
unless change
@back_path, @back_label = organizations_link
return render :choose_affiliation_change
end

@request.requested_value = change
@back_path = new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: @request.affiliation.id, return_to: return_to)
@back_label = "Back"
end

def return_to
params[:return_to].presence_in(%w[details])
end
helper_method :return_to

def profile_change_request_params
params.require(:profile_change_request).permit(
:field, :requested_value, :details, :affiliation_id, :organization_id,
Expand Down
6 changes: 6 additions & 0 deletions app/decorators/affiliation_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ def detail(length: nil)
"#{person.full_name}: #{title.presence || Affiliation::FACILITATOR_TITLE} - #{organization.name}"
end

# A blank title means a facilitator affiliation, the same default used elsewhere.
def display_title
shown = title.presence || Affiliation::FACILITATOR_TITLE
shown.strip == Affiliation::FACILITATOR_TITLE ? "AWBW #{Affiliation::FACILITATOR_TITLE}" : shown
end

def status_badge
classes, icon = case status_on
when "Active" then [ "bg-green-50 text-green-800 border-green-300", "fa-solid fa-circle-check" ]
Expand Down
24 changes: 24 additions & 0 deletions app/decorators/person_decorator.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,28 @@
class PersonDecorator < ApplicationDecorator
PROFILE_VISIBILITY_GROUPS = {
"About you" => {
profile_show_pronouns: "Pronouns",
profile_show_credentials: "Credentials (like LCSW)",
profile_show_member_since: "When you became a facilitator",
profile_show_bio: "Bio",
profile_show_affiliations: "Organizations",
profile_show_sectors: "Sectors",
profile_show_age_ranges: "Age ranges"
},
"How to reach you" => {
profile_show_email: "Email",
profile_show_phone: "Phone",
profile_show_social_media: "Social media links"
},
"Your activity" => {
profile_show_events_registered: "Events you attended",
profile_show_workshops: "Workshops you wrote",
profile_show_workshop_variations: "Workshop variations you wrote",
profile_show_stories: "Stories you wrote or were featured in",
profile_show_resources: "Resources you wrote"
}
}.freeze

def title
"#{first_name} #{last_name}"
end
Expand Down
39 changes: 39 additions & 0 deletions app/decorators/profile_change_request_decorator.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
class ProfileChangeRequestDecorator < ApplicationDecorator
AFFILIATION_CHANGE_CHOICES = {
ProfileChangeRequest::AFFILIATION_CHANGE_TITLE => { label: "Change my title or role", icon: "fa-solid fa-id-badge" },
ProfileChangeRequest::AFFILIATION_CHANGE_DATES => { label: "Fix my start or end date", icon: "fa-solid fa-calendar-days" },
ProfileChangeRequest::AFFILIATION_CHANGE_ORGANIZATION => { label: "Fix the organization's name", icon: "fa-solid fa-building" },
ProfileChangeRequest::AFFILIATION_CHANGE_REMOVE => { label: "I don't work with them anymore", icon: "fa-solid fa-door-open" },
ProfileChangeRequest::AFFILIATION_CHANGE_WORK_ADDRESS => { label: "Add my work address", icon: "fa-solid fa-location-dot" },
ProfileChangeRequest::AFFILIATION_CHANGE_OTHER => { label: "Something else", icon: "fa-solid fa-comment" },
ProfileChangeRequest::AFFILIATION_CHANGE_ADD => { label: "Add an organization", icon: "fa-solid fa-plus" }
}.freeze
def heading
return "Request a profile data change" if other?

AFFILIATION_CHANGE_CHOICES.dig(requested_value, :label) if field == "affiliation"
end

def status_badge
classes, icon, label = case status
when "resolved" then [ "bg-green-50 text-green-800 border-green-300", "fa-solid fa-circle-check", "Resolved" ]
Expand All @@ -18,12 +33,32 @@ def requested_on
created_at.strftime("%b %-d, %Y")
end

# One plain line the requester recognizes their request by, e.g.
# "Primary email: new@example.com" or "Title or role: Riverside Center".
def requester_summary
parts = if field == "affiliation"
[ requested_value, (affiliation&.organization || organization)&.name || proposed_organization_name ]
else
[ field_label, requested_value.presence || details&.truncate(60) ]
end
parts.compact_blank.join(": ")
end

# `return_to: "details"` comes back to the email card on the simple edit page.
def profile_return_path(return_to = nil)
return h.details_person_path(person, anchor: (field == "affiliation" ? "affiliations" : "emails")) if return_to == "details"

h.person_path(person, anchor: ("affiliations" if field == "affiliation"))
end

# Confirmation prompt for "Approve", spelling out the blast radius where the
# change reaches beyond this one person (renaming a shared organization).
def approve_confirm
case field
when "primary_email"
"Approve and send a confirmation email to #{requested_value}?"
when "secondary_email"
"Approve and set the secondary email to #{requested_value}?"
when "organization_name"
org_rename_confirm(target_organization, requested_value)
when "affiliation"
Expand Down Expand Up @@ -56,8 +91,12 @@ def manual_edit_path
case field
when "primary_email"
person.user ? h.edit_user_path(person.user) : h.edit_person_path(person)
when "secondary_email"
h.edit_person_path(person)
when "organization_name"
person.primary_organization ? h.edit_organization_path(person.primary_organization) : h.edit_person_path(person, anchor: "affiliations")
when ProfileChangeRequest::OTHER_FIELD
h.edit_person_path(person)
else
anchor = affiliation ? h.dom_id(affiliation) : "affiliations"
h.edit_person_path(person, anchor: anchor)
Expand Down
7 changes: 5 additions & 2 deletions app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ def self.normalize_name(name)
# Nested attributes
accepts_nested_attributes_for :addresses, allow_destroy: true,
reject_if: proc { |attrs| attrs.slice("locality", "city", "state", "street_address", "zip_code").values.all?(&:blank?) }
accepts_nested_attributes_for :contact_methods, allow_destroy: true, reject_if: :all_blank
# A new row's hidden kind is always filled in, so a row counts as blank by its number.
accepts_nested_attributes_for :contact_methods, allow_destroy: true,
reject_if: proc { |attrs| attrs["id"].blank? && attrs["value"].blank? }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: This also changes the admin form: a blank cocoon-added phone row is now silently skipped instead of failing validation. Its hidden kind made :all_blank never match.

accepts_nested_attributes_for :sectorable_items, allow_destroy: true,
reject_if: proc { |attrs| attrs["sector_id"].blank? }
# Age ranges edit through cocoon nested fields like sectors. A scoped view of
Expand All @@ -153,8 +155,9 @@ def self.normalize_name(name)
# both INSERT and hit the DB unique index. Collapse duplicates before validation.
before_validation :dedupe_age_range_items
accepts_nested_attributes_for :user, update_only: true
# Only a new row needs an organization; an owner's work-address pick sends just id + address.
accepts_nested_attributes_for :affiliations, allow_destroy: true,
reject_if: proc { |attrs| attrs["organization_id"].blank? }
reject_if: proc { |attrs| attrs["id"].blank? && attrs["organization_id"].blank? }
accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? }
# A blank row (number + kind + state + expiry all empty) is ignored rather than
# creating an empty license.
Expand Down
46 changes: 32 additions & 14 deletions app/models/profile_change_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ class ProfileChangeRequest < ApplicationRecord
belongs_to :organization, optional: true
belongs_to :affiliation, optional: true

FIELDS = %w[primary_email organization_name affiliation].freeze
OTHER_FIELD = "other".freeze
EMAIL_FIELDS = %w[primary_email secondary_email].freeze
FIELDS = [ *EMAIL_FIELDS, "organization_name", "affiliation", OTHER_FIELD ].freeze
FIELD_LABELS = {
"primary_email" => "Primary email",
"secondary_email" => "Secondary email",
"organization_name" => "Organization name",
"affiliation" => "Affiliation details"
"affiliation" => "Affiliation details",
OTHER_FIELD => "Profile data"
}.freeze
# Fields whose new value is concrete enough for "Approve" to write directly.
AUTO_APPLIABLE_FIELDS = %w[primary_email organization_name].freeze
AUTO_APPLIABLE_FIELDS = [ *EMAIL_FIELDS, "organization_name" ].freeze
STATUSES = %w[pending resolved declined].freeze
RESOLUTION_METHODS = %w[approved manual].freeze
# What an affiliation request is asking to change (stored in requested_value),
Expand All @@ -24,25 +28,27 @@ class ProfileChangeRequest < ApplicationRecord
AFFILIATION_CHANGE_ORGANIZATION = "Organization".freeze
AFFILIATION_CHANGE_REMOVE = "Remove this affiliation".freeze
AFFILIATION_CHANGE_ADD = "Add a new affiliation".freeze
AFFILIATION_CHANGE_WORK_ADDRESS = "Work address".freeze
AFFILIATION_CHANGE_OTHER = "Other".freeze
AFFILIATION_CHANGE_TYPES = [
AFFILIATION_CHANGE_TITLE,
AFFILIATION_CHANGE_DATES,
AFFILIATION_CHANGE_ORGANIZATION,
AFFILIATION_CHANGE_REMOVE,
AFFILIATION_CHANGE_ADD,
AFFILIATION_CHANGE_WORK_ADDRESS,
AFFILIATION_CHANGE_OTHER
].freeze
# Every affiliation category except "Other" carries a structured value Approve
# can apply directly; "Other" is free text an admin handles by hand.
AFFILIATION_AUTO_CATEGORIES = (AFFILIATION_CHANGE_TYPES - [ AFFILIATION_CHANGE_OTHER ]).freeze
# These carry a structured value Approve can apply directly. "Work address" and
# "Other" are free text an admin handles by hand.
AFFILIATION_AUTO_CATEGORIES = (AFFILIATION_CHANGE_TYPES - [ AFFILIATION_CHANGE_WORK_ADDRESS, AFFILIATION_CHANGE_OTHER ]).freeze

validates :field, inclusion: { in: FIELDS }
validates :status, inclusion: { in: STATUSES }
validates :resolution_method, inclusion: { in: RESOLUTION_METHODS }, allow_nil: true
validates :requested_value, presence: true
validates :requested_value, presence: true, unless: :other?
validates :requested_value, format: { with: URI::MailTo::EMAIL_REGEXP, message: "must be a valid email address" },
if: -> { field == "primary_email" && requested_value.present? }
if: -> { field.in?(EMAIL_FIELDS) && requested_value.present? }
validates :details, presence: true, if: :requires_details?
validate :affiliation_belongs_to_person
validate :affiliation_target_present, if: -> { field == "affiliation" }
Expand All @@ -60,10 +66,16 @@ class ProfileChangeRequest < ApplicationRecord
def pending? = status == "pending"
def resolved? = status == "resolved"
def declined? = status == "declined"
def other? = field == OTHER_FIELD

def auto_appliable?
return true if field.in?(AUTO_APPLIABLE_FIELDS)
field == "affiliation" && requested_value.in?(AFFILIATION_AUTO_CATEGORIES)
field == "affiliation" && requested_value.in?(AFFILIATION_AUTO_CATEGORIES) && !new_organization?
end

# An add request for an organization that isn't in the portal yet — staff create it by hand.
def new_organization?
requested_value == AFFILIATION_CHANGE_ADD && organization_id.blank? && proposed_organization_name.present?
end

def field_label
Expand All @@ -79,10 +91,14 @@ def proposed_change_summary
when AFFILIATION_CHANGE_DATES then proposed_dates_label
when AFFILIATION_CHANGE_ORGANIZATION then proposed_organization_name
when AFFILIATION_CHANGE_REMOVE then "End-date and mark inactive"
when AFFILIATION_CHANGE_ADD then [ proposed_title, organization&.name, proposed_dates_label ].compact_blank.join(" · ")
when AFFILIATION_CHANGE_ADD then [ proposed_title, organization&.name || new_organization_label, proposed_dates_label ].compact_blank.join(" · ")
end
end

def new_organization_label
"#{proposed_organization_name} (not in the portal yet)" if new_organization?
end

def proposed_dates_label
start_text = proposed_start_date&.strftime("%b %-d, %Y")
end_text = proposed_end_date&.strftime("%b %-d, %Y")
Expand All @@ -101,6 +117,7 @@ def target_organization
def current_value
case field
when "primary_email" then person.user&.email
when "secondary_email" then person.email_2
when "organization_name" then target_organization&.name
end
end
Expand All @@ -110,7 +127,7 @@ def affiliation_summary
return unless affiliation

org = affiliation.organization&.name
[ affiliation.title.presence || Affiliation::FACILITATOR_TITLE, org ].compact.join(" @ ")
[ affiliation.decorate.display_title, org ].compact.join(" @ ")
end

def resolve!(method:, reviewer:, note: nil)
Expand All @@ -130,7 +147,7 @@ def snapshot_target
end

def requires_details?
field == "affiliation" && requested_value == AFFILIATION_CHANGE_OTHER
other? || (field == "affiliation" && requested_value.in?([ AFFILIATION_CHANGE_WORK_ADDRESS, AFFILIATION_CHANGE_OTHER ]))
end

# A category's hidden sibling inputs still post their (stale) values; drop the
Expand All @@ -143,7 +160,8 @@ def clear_irrelevant_affiliation_fields
self.proposed_start_date = nil
self.proposed_end_date = nil
end
self.proposed_organization_name = nil unless requested_value == AFFILIATION_CHANGE_ORGANIZATION
self.proposed_organization_name = nil unless requested_value.in?([ AFFILIATION_CHANGE_ORGANIZATION, AFFILIATION_CHANGE_ADD ])
self.proposed_organization_name = nil if requested_value == AFFILIATION_CHANGE_ADD && organization_id.present?
end

def affiliation_belongs_to_person
Expand All @@ -166,7 +184,7 @@ def affiliation_proposed_values
when AFFILIATION_CHANGE_ORGANIZATION
errors.add(:proposed_organization_name, "can't be blank") if proposed_organization_name.blank?
when AFFILIATION_CHANGE_ADD
errors.add(:organization_id, "must be selected") if organization_id.blank?
errors.add(:base, "Pick an organization, or type its name if it's not on the list") if organization_id.blank? && proposed_organization_name.blank?
errors.add(:proposed_title, "can't be blank") if proposed_title.blank?
end
end
Expand Down
Loading
Loading