From b132b39cbbb6b88910f13cd3260a26f09dd31241 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 06:54:38 -0400 Subject: [PATCH 01/34] Start: make the profile page the place to edit or request edits Co-Authored-By: Claude Opus 5.5 (1M context) From b80a266e4699b6c39c32599c8e910c2d15884158 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 06:59:13 -0400 Subject: [PATCH 02/34] Make a person's own profile the place to edit or ask for changes Facilitators are a very low-tech audience. They didn't know the edit form existed and couldn't tell which details they could change and which needed staff. The profile now spells that out: one big Edit button, labeled buttons for the staff-kept details, and an Edit button on each section. Change requests and Cancel both return to the profile. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../profile_change_requests_controller.rb | 4 +- .../profile_change_request_decorator.rb | 15 +++ app/policies/person_policy.rb | 4 + app/views/people/_form.html.erb | 6 +- app/views/people/_own_profile_banner.html.erb | 39 +++++++ .../people/_profile_section_action.html.erb | 11 ++ app/views/people/edit.html.erb | 9 +- app/views/people/show.html.erb | 32 +++++- .../profile_change_requests/edit.html.erb | 4 +- .../profile_change_requests/new.html.erb | 2 +- app/views/shared/_navbar_user.html.erb | 5 +- config/features.yml | 17 +++ .../profile_change_request_decorator_spec.rb | 31 +++++ spec/policies/person_policy_spec.rb | 15 +++ spec/requests/people_own_profile_hub_spec.rb | 108 ++++++++++++++++++ 15 files changed, 286 insertions(+), 16 deletions(-) create mode 100644 app/views/people/_own_profile_banner.html.erb create mode 100644 app/views/people/_profile_section_action.html.erb create mode 100644 spec/decorators/profile_change_request_decorator_spec.rb create mode 100644 spec/requests/people_own_profile_hub_spec.rb diff --git a/app/controllers/profile_change_requests_controller.rb b/app/controllers/profile_change_requests_controller.rb index 6c548dd954..1935af3132 100644 --- a/app/controllers/profile_change_requests_controller.rb +++ b/app/controllers/profile_change_requests_controller.rb @@ -34,7 +34,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, status: :see_other, notice: "Thanks — we've sent your change request to AWBW staff." else @person = @request.person @@ -51,7 +51,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, status: :see_other, notice: "Your change request was updated." else @person = @request.person diff --git a/app/decorators/profile_change_request_decorator.rb b/app/decorators/profile_change_request_decorator.rb index 0f40ddd781..3980ca9949 100644 --- a/app/decorators/profile_change_request_decorator.rb +++ b/app/decorators/profile_change_request_decorator.rb @@ -18,6 +18,21 @@ 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 ] + else + [ field_label, requested_value ] + end + parts.compact_blank.join(": ") + end + + def profile_return_path + 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 diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 811df2c219..217f6d243a 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -67,6 +67,10 @@ def update? admin? || (profiles_visible_to_users? && owner?) end + def edit_own_profile? + profiles_visible_to_users? && owner? + end + def destroy? admin? && record.persisted? && !has_associated_data? end diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 5b82b571e7..57946158ca 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -145,7 +145,7 @@ <% (@managed_category_type_ids || []).each do |type_id| %> <%= hidden_field_tag "person[managed_category_type_ids][]", type_id %> <% end %> -
+
@@ -392,7 +392,7 @@
-
+
Background
@@ -598,7 +598,7 @@ data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %> <% end %> - <%= link_to "Cancel", people_path, class: button_classes(:secondary_outline), + <%= link_to "Cancel", (allowed_to?(:edit_own_profile?, f.object) ? person_path(f.object) : people_path), class: button_classes(:secondary_outline), data: { action: "click->dirty-form#confirmCancel" } %> <%= f.button :submit, class: button_classes(:primary) %>
diff --git a/app/views/people/_own_profile_banner.html.erb b/app/views/people/_own_profile_banner.html.erb new file mode 100644 index 0000000000..a4eb9acc92 --- /dev/null +++ b/app/views/people/_own_profile_banner.html.erb @@ -0,0 +1,39 @@ +<%# locals: (person:) %> +<% pending_requests = person.profile_change_requests.pending.newest_first.includes(affiliation: :organization).decorate %> +
+
+
+

This is your profile

+

To update your details, click Edit my profile.

+

AWBW staff keep your email and organizations up to date. Use the buttons below to ask us for a change.

+
+ <%= link_to edit_person_path(person), + class: "text-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-full bg-brand-yellow-400 px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-brand-yellow-500" do %> + Edit my profile + <% end %> +
+
+ <%= link_to new_profile_change_request_path(person_id: person, field: "primary_email"), class: button_classes(:secondary_outline) do %> + Change my email + <% end %> + <%= link_to new_profile_change_request_path(person_id: person, field: "affiliation"), class: button_classes(:secondary_outline) do %> + Change my organizations + <% end %> + <%= link_to edit_person_path(person, anchor: "profile-preferences"), class: button_classes(:secondary_outline) do %> + Choose what others see + <% end %> +
+ <% if pending_requests.any? %> +
+

AWBW staff are looking at your request

+
    + <% pending_requests.each do |request| %> +
  • + <%= request.requester_summary %> · sent <%= request.requested_on %> + <%= link_to "See or change it", edit_profile_change_request_path(request), class: "ms-2 font-medium underline" %> +
  • + <% end %> +
+
+ <% end %> +
diff --git a/app/views/people/_profile_section_action.html.erb b/app/views/people/_profile_section_action.html.erb new file mode 100644 index 0000000000..30e91d3963 --- /dev/null +++ b/app/views/people/_profile_section_action.html.erb @@ -0,0 +1,11 @@ +<%# locals: (person:, anchor:, label: "Edit") %> +<% pill_class = "inline-flex items-center gap-1.5 rounded-full border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50" %> +<% if anchor == "affiliations" && allowed_to?(:edit_own_profile?, person) && !allowed_to?(:manage?, Person) %> + <%= link_to new_profile_change_request_path(person_id: person, field: "affiliation"), class: pill_class do %> + Ask for a change + <% end %> +<% elsif allowed_to?(:edit?, person) %> + <%= link_to edit_person_path(person, anchor: anchor), class: pill_class do %> + <%= label %> + <% end %> +<% end %> diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 0285354c9e..3aac187a28 100644 --- a/app/views/people/edit.html.erb +++ b/app/views/people/edit.html.erb @@ -1,4 +1,5 @@ <% content_for(:page_bg_class, "admin-or-owner") %> +<% own_profile = allowed_to?(:edit_own_profile?, @person) %> <% back = capture do %> <% if params[:return_to] == "onboarding" && params[:event_id].present? %>
@@ -18,6 +19,12 @@ Organization linking <% end %>
+ <% elsif own_profile %> +
+ <%= link_to person_path(@person), class: "text-sm #{eyebrow_link_class} px-2 py-1" do %> + My profile + <% end %> +
<% end %> <% end %> <% actions = capture do %> @@ -43,7 +50,7 @@ <%= link_to "Profile", person_path(@person), class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <% end %> <%= render layout: "shared/form_page", - locals: { domain: :people, icon: "fas fa-user", eyebrow: "Edit person", + locals: { domain: :people, icon: "fas fa-user", eyebrow: own_profile ? "Edit my profile" : "Edit person", title: truncate(@person.name, length: 40), back: back, actions: actions, width: "max-w-7xl", sticky_header: true, card: false } do %> diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index 2330973c01..e7d8ad4ebb 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -15,6 +15,8 @@ <% if allowed_to?(:index?, User) %> <%= render "people/multiple_user_accounts_warning", person: @person, return_to: "person_show" %> <% end %> +<% own_profile = allowed_to?(:edit_own_profile?, @person) %> +<%= render "people/own_profile_banner", person: @person if own_profile %>
<%= render accent_strip_partial %> @@ -43,7 +45,7 @@ <% if allowed_to?(:index?, Person) %> <%= link_to "People", people_path, class: "admin-only text-sm text-gray-600 hover:text-primary px-2 py-1" %> <% end %> - <% if allowed_to?(:edit?, @person) %> + <% if allowed_to?(:edit?, @person) && !own_profile %> <%= link_to edit_person_path(@person), class: "admin-only inline-flex items-center gap-1.5 text-sm font-semibold rounded-full bg-brand-yellow-400 hover:bg-brand-yellow-500 text-primary px-4 py-1.5 shadow-sm transition" do %> Edit @@ -160,7 +162,10 @@ <% if @person.profile_show_affiliations? %>
-

Affiliations

+
+

Affiliations

+ <%= render "people/profile_section_action", person: @person, anchor: "affiliations" %> +
<%= turbo_frame_tag "person_affiliations_section", src: person_path(@person, section: "affiliations"), loading: :lazy do %>

Loading affiliations...

<% end %> @@ -176,13 +181,19 @@
<% if show_sectors %>
-

Sectors

+
+

Sectors

+ <%= render "people/profile_section_action", person: @person, anchor: "sectors" %> +
<%= render "people/profile_pieces/sectors", person: @person %>
<% end %> <% if show_age %>
-

Age ranges

+
+

Age ranges

+ <%= render "people/profile_section_action", person: @person, anchor: "sectors" %> +
<%= render "shared/age_group_tags", primary: primary_age_groups, additional: additional_age_groups, compact: true %>
<% end %> @@ -198,9 +209,20 @@ <% if @person.bio.present? && @person.profile_show_bio? %>
-

Bio

+
+

Bio

+ <%= render "people/profile_section_action", person: @person, anchor: "bio" %> +
<%= render "people/profile_pieces/bio", person: @person %>
+ <% elsif @person.bio.blank? && own_profile %> +
+

Bio

+

You haven't added a bio yet. A few sentences about you and your work helps others get to know you.

+
+ <%= render "people/profile_section_action", person: @person, anchor: "bio", label: "Add a bio" %> +
+
<% end %> <% authored_sections = %i[workshops workshop_variations stories resources].select { |section| @person.public_send(:"profile_show_#{section}?") } %> diff --git a/app/views/profile_change_requests/edit.html.erb b/app/views/profile_change_requests/edit.html.erb index ce9254aab3..66129d91f7 100644 --- a/app/views/profile_change_requests/edit.html.erb +++ b/app/views/profile_change_requests/edit.html.erb @@ -1,8 +1,8 @@ <% content_for(:page_bg_class, "admin-or-owner") %>
- <%= link_to edit_person_path(@person, anchor: "affiliations"), class: "text-sm #{eyebrow_link_class}" do %> - Back to profile + <%= link_to @request.decorate.profile_return_path, class: "text-sm #{eyebrow_link_class}" do %> + Back to my profile <% end %>
diff --git a/app/views/profile_change_requests/new.html.erb b/app/views/profile_change_requests/new.html.erb index 6d51e0beca..170ab0fb39 100644 --- a/app/views/profile_change_requests/new.html.erb +++ b/app/views/profile_change_requests/new.html.erb @@ -1,7 +1,7 @@ <% content_for(:page_bg_class, "admin-or-owner") %>
- <%= link_to edit_person_path(@person, anchor: "affiliations"), class: "text-sm #{eyebrow_link_class}" do %> + <%= link_to @request.decorate.profile_return_path, class: "text-sm #{eyebrow_link_class}" do %> Back to my profile <% end %>
diff --git a/app/views/shared/_navbar_user.html.erb b/app/views/shared/_navbar_user.html.erb index 9cc3cf6120..8d3a8e138e 100644 --- a/app/views/shared/_navbar_user.html.erb +++ b/app/views/shared/_navbar_user.html.erb @@ -37,9 +37,10 @@ <% end %> <% end %> - <% if person && allowed_to?(:manage?, Person) %> + <% own_profile = person && allowed_to?(:edit_own_profile?, person) %> + <% if own_profile || (person && allowed_to?(:manage?, Person)) %> <%= link_to person_path(person), - class: "admin-only bg-blue-100 flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 space-x-2" do %> + class: "#{"admin-only bg-blue-100" unless own_profile} flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 space-x-2" do %> My profile <% end %> diff --git a/config/features.yml b/config/features.yml index c9a062f985..fd0f3e3d15 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4593,3 +4593,20 @@ pro_tips: - "To tell apart two real organizations with the same name, add the city or program, e.g. \"Hope House (Oakland)\"." - "Use Dedupe on the Organizations page to combine records that already exist twice." + + - "Add ?admin=true to the end of the edit page's address to show the Internal notes box, the same as on organizations and users." + +- name: "Update your details from your profile" + area: people + display_status: user_facing + released_on: 2026-10-14 + pr_number: 2671 + summary: >- + Your own profile page is now the place to keep your details up to date. A + big "Edit my profile" button sits at the top, along with buttons to ask + AWBW staff to change your email or organizations. + pro_tips: + - "Open it from \"My profile\" in the menu under your picture." + - "Each section has its own Edit button that jumps straight to that part of the form." + - "Any change you've asked for shows at the top until staff have looked at it." + - "Use \"Choose what others see\" to hide parts of your profile." diff --git a/spec/decorators/profile_change_request_decorator_spec.rb b/spec/decorators/profile_change_request_decorator_spec.rb new file mode 100644 index 0000000000..7a9d1ca312 --- /dev/null +++ b/spec/decorators/profile_change_request_decorator_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe ProfileChangeRequestDecorator do + describe "#requester_summary" do + it "names the field and new value for an email request" do + request = build_stubbed(:profile_change_request, :primary_email, requested_value: "new@example.com").decorate + expect(request.requester_summary).to eq("Primary email: new@example.com") + end + + it "names the change and organization for an affiliation request" do + person = create(:person) + affiliation = create(:affiliation, person: person, organization: create(:organization, name: "Sunrise Center")) + request = build_stubbed(:profile_change_request, person: person, affiliation: affiliation, + requested_value: "Title or role").decorate + + expect(request.requester_summary).to eq("Title or role: Sunrise Center") + end + end + + describe "#profile_return_path" do + it "returns to the profile's affiliations for an affiliation request" do + request = build_stubbed(:profile_change_request).decorate + expect(request.profile_return_path).to eq("/people/#{request.person.to_param}#affiliations") + end + + it "returns to the top of the profile for an email request" do + request = build_stubbed(:profile_change_request, :primary_email).decorate + expect(request.profile_return_path).to eq("/people/#{request.person.to_param}") + end + end +end diff --git a/spec/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index 912b5d13f0..8e377284be 100644 --- a/spec/policies/person_policy_spec.rb +++ b/spec/policies/person_policy_spec.rb @@ -219,6 +219,21 @@ def in_production end end + describe "#edit_own_profile?" do + it "allows the person's own user" do + expect(policy_for(record: owned_person, user: owner_user)).to be_allowed_to(:edit_own_profile?) + end + + it "is denied in production" do + in_production + expect(policy_for(record: owned_person, user: owner_user)).not_to be_allowed_to(:edit_own_profile?) + end + + it "denies an admin looking at someone else's profile" do + expect(policy_for(record: searchable_person, user: admin_user)).not_to be_allowed_to(:edit_own_profile?) + end + end + describe "#destroy?" do context "when person has authored stories" do let(:admin) { create(:user, :admin) } diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb new file mode 100644 index 0000000000..7a3cb7ca6e --- /dev/null +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -0,0 +1,108 @@ +require "rails_helper" + +# The person's own profile page is where they go to edit or ask for changes +# (outside production, per PersonPolicy#edit_own_profile?). +RSpec.describe "A person's own profile as the place to edit", type: :request do + let(:owner_user) { create(:user, :with_person) } + let(:person) { owner_user.person } + let(:organization) { create(:organization, name: "Sunrise Center") } + let!(:affiliation) { create(:affiliation, person: person, organization: organization, title: "Facilitator") } + + describe "viewing their own profile" do + before { sign_in owner_user } + + it "shows the edit button and the ask-for-a-change buttons" do + get person_path(person) + + expect(response.body).to include("This is your profile") + expect(response.body).to include("Edit my profile") + expect(response.body).to include("field=primary_email") + expect(response.body).to include("field=affiliation") + expect(response.body).to include(edit_person_path(person, anchor: "profile-preferences")) + end + + it "offers to ask for a change on affiliations and to edit sectors" do + get person_path(person) + + expect(response.body).to include("Ask for a change") + expect(response.body).to include(edit_person_path(person, anchor: "sectors")) + end + + it "prompts for a bio when there isn't one" do + person.update!(bio: nil) + + get person_path(person) + + expect(response.body).to include("Add a bio") + expect(response.body).to include(edit_person_path(person, anchor: "bio")) + end + + it "lists pending change requests" do + request = create(:profile_change_request, :primary_email, person: person, + requested_by: owner_user, requested_value: "new@example.com") + + get person_path(person) + + expect(response.body).to include("AWBW staff are looking at your request") + expect(response.body).to include("Primary email: new@example.com") + expect(response.body).to include(edit_profile_change_request_path(request)) + end + + it "hides the banner in production" do + allow(Rails.env).to receive(:production?).and_return(true) + + get person_path(person) + + expect(response.body).not_to include("This is your profile") + expect(response.body).not_to include("Ask for a change") + end + end + + describe "an admin viewing someone else's profile" do + before { sign_in create(:user, :admin) } + + it "shows the admin edit button but not the owner banner" do + get person_path(person) + + expect(response.body).not_to include("This is your profile") + expect(response.body).to include(edit_person_path(person)) + expect(response.body).to include(edit_person_path(person, anchor: "affiliations")) + expect(response.body).not_to include("Ask for a change") + end + end + + describe "the edit page" do + before { sign_in owner_user } + + it "leads back to the profile" do + get edit_person_path(person) + + expect(response.body).to include("Edit my profile") + expect(response.body).to match(/href="#{person_path(person)}"[^>]*>\s*]*><\/i>\s*My profile/) + expect(response.body).to match(/href="#{person_path(person)}"[^>]*>Cancel/) + end + end + + describe "sending a change request" do + before { sign_in owner_user } + + it "returns to the profile" do + post profile_change_requests_path(person_id: person), params: { + profile_change_request: { field: "primary_email", requested_value: "new@example.com" } + } + + expect(response).to redirect_to(person_path(person)) + end + + it "returns to the profile's affiliations after an affiliation request" do + post profile_change_requests_path(person_id: person), params: { + profile_change_request: { + field: "affiliation", affiliation_id: affiliation.id, + requested_value: "Title or role", proposed_title: "Lead facilitator" + } + } + + expect(response).to redirect_to(person_path(person, anchor: "affiliations")) + end + end +end From 5fa3c5d7abe7c1502e5961868b5b1166465cb797 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 07:02:03 -0400 Subject: [PATCH 03/34] Give "Choose what others see" its own short page The visibility checkboxes sat near the bottom of a long form, mixed with fields staff use. Low-tech users need a short checklist they can't get lost in. Saving goes through the regular update and returns to the profile. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/controllers/people_controller.rb | 8 ++- app/decorators/person_decorator.rb | 24 +++++++++ app/policies/person_policy.rb | 4 ++ app/views/people/_own_profile_banner.html.erb | 4 +- app/views/people/visibility.html.erb | 52 +++++++++++++++++++ config/features.yml | 2 +- config/routes.rb | 1 + spec/requests/people_own_profile_hub_spec.rb | 42 ++++++++++++++- spec/views/page_bg_class_alignment_spec.rb | 1 + 9 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 app/views/people/visibility.html.erb diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 1a66c3dfce..b9fb326ce0 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -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 ] # The profile's "Submitted content" sections — private to the person and admins, # not part of the public profile even after profile viewing opens up. @@ -243,6 +243,10 @@ def edit set_form_variables end + def visibility + authorize! @person + end + def create @person = Person.new(person_params.except(:user_attributes)) authorize! @person @@ -303,7 +307,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] == "visibility" ? :visibility : :edit, status: :unprocessable_content) end end diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index ada6260be9..09d1a01c26 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -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 work" => { + 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", + profile_show_resources: "Resources" + } + }.freeze + def title "#{first_name} #{last_name}" end diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 217f6d243a..64cdbe35f5 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -67,6 +67,10 @@ def update? admin? || (profiles_visible_to_users? && owner?) end + def visibility? + edit? + end + def edit_own_profile? profiles_visible_to_users? && owner? end diff --git a/app/views/people/_own_profile_banner.html.erb b/app/views/people/_own_profile_banner.html.erb index a4eb9acc92..468b9f03e5 100644 --- a/app/views/people/_own_profile_banner.html.erb +++ b/app/views/people/_own_profile_banner.html.erb @@ -3,7 +3,7 @@
-

This is your profile

+

This is your profile

To update your details, click Edit my profile.

AWBW staff keep your email and organizations up to date. Use the buttons below to ask us for a change.

@@ -19,7 +19,7 @@ <%= link_to new_profile_change_request_path(person_id: person, field: "affiliation"), class: button_classes(:secondary_outline) do %> Change my organizations <% end %> - <%= link_to edit_person_path(person, anchor: "profile-preferences"), class: button_classes(:secondary_outline) do %> + <%= link_to visibility_person_path(person), class: button_classes(:secondary_outline) do %> Choose what others see <% end %>
diff --git a/app/views/people/visibility.html.erb b/app/views/people/visibility.html.erb new file mode 100644 index 0000000000..3e084b0718 --- /dev/null +++ b/app/views/people/visibility.html.erb @@ -0,0 +1,52 @@ +<% content_for(:page_bg_class, "admin-or-owner") %> +<% checkbox_row_class = "flex cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-lg text-gray-800 hover:bg-gray-50" %> +<% checkbox_class = "h-6 w-6 rounded border-gray-300 text-primary focus:border-blue-500 focus:ring-blue-200" %> +<% card_class = "border-primary/10 rounded-2xl border bg-white p-5 shadow-sm sm:p-6" %> +
+
+ <%= link_to person_path(@person), class: "text-sm #{eyebrow_link_class}" do %> + Back to my profile + <% end %> +
+

Choose what others see

+

+ Check the parts of your profile other facilitators can see. AWBW staff can always see everything. +

+ <%= simple_form_for @person, html: { class: "mt-6 space-y-6" } do |f| %> + <%= hidden_field_tag :return_to, "visibility" %> + <%= render "shared/errors", resource: @person if @person.errors.any? %> +
+

Your profile and name

+ + +
+ <%= f.label :display_name_preference, "Show my name as", class: "mb-1 block text-lg text-gray-800" %> + <%= f.select :display_name_preference, + Person::DISPLAY_NAME_PREFERENCE_LABELS.invert.to_a, + { selected: @person.display_name_preference || "full_name" }, + class: "w-full rounded-md border-gray-300 text-lg shadow-sm focus:border-blue-500 focus:ring-blue-200" %> +
+
+ <% PersonDecorator::PROFILE_VISIBILITY_GROUPS.each do |heading, fields| %> +
+

<%= heading %>

+ <% fields.each do |attribute, label| %> + + <% end %> +
+ <% end %> +
+ <%= link_to "Cancel", person_path(@person), class: button_classes(:secondary_outline) %> + <%= f.button :submit, "Save", class: button_classes(:primary, extra: "px-8 text-lg") %> +
+ <% end %> +
diff --git a/config/features.yml b/config/features.yml index fd0f3e3d15..a5cde23d76 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4609,4 +4609,4 @@ - "Open it from \"My profile\" in the menu under your picture." - "Each section has its own Edit button that jumps straight to that part of the form." - "Any change you've asked for shows at the top until staff have looked at it." - - "Use \"Choose what others see\" to hide parts of your profile." + - "\"Choose what others see\" opens a short checklist of what other facilitators can see on your profile." diff --git a/config/routes.rb b/config/routes.rb index 0ac8257e14..03734cad95 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -300,6 +300,7 @@ get :checkout get :bio get :all_comments + get :visibility post :send_form_link end resources :comments, only: [ :create, :update ] diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index 7a3cb7ca6e..f947d20389 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -18,7 +18,7 @@ expect(response.body).to include("Edit my profile") expect(response.body).to include("field=primary_email") expect(response.body).to include("field=affiliation") - expect(response.body).to include(edit_person_path(person, anchor: "profile-preferences")) + expect(response.body).to include(visibility_person_path(person)) end it "offers to ask for a change on affiliations and to edit sectors" do @@ -83,6 +83,46 @@ end end + describe "choosing what others see" do + before { sign_in owner_user } + + it "shows only the visibility choices" do + get visibility_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Choose what others see") + expect(response.body).to include("Let other facilitators find and see my profile") + expect(response.body).not_to include("person[bio]") + end + + it "saves and returns to the profile" do + patch person_path(person), params: { + return_to: "visibility", + person: { profile_show_email: "0", profile_show_bio: "1", display_name_preference: "first_name_only" } + } + + expect(response).to redirect_to(person_path(person)) + person.reload + expect(person.profile_show_email).to be(false) + expect(person.display_name_preference).to eq("first_name_only") + end + + it "re-renders the visibility page when the save fails" do + patch person_path(person), params: { return_to: "visibility", person: { first_name: "" } } + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Choose what others see") + end + + it "is closed to other people" do + sign_in create(:user) + + get visibility_person_path(person) + + expect(response).to redirect_to(root_path) + end + end + describe "sending a change request" do before { sign_in owner_user } diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 3a5455523b..6fdc53f311 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -94,6 +94,7 @@ "app/views/people/show.html.erb" => "admin-or-owner-or-authsearchable", "app/views/organizations/edit.html.erb" => "admin-or-owner", "app/views/people/edit.html.erb" => "admin-or-owner", + "app/views/people/visibility.html.erb" => "admin-or-owner", "app/views/reports/show.html.erb" => "admin-or-owner-or-member", "app/views/workshop_logs/show.html.erb" => "admin-or-owner-or-member", From d6b6b6c7e63b72cd2577d83490c6a719b3762662 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 07:08:27 -0400 Subject: [PATCH 04/34] Add free-text change requests, switch toggles, and save/Ahoy coverage People need a way to ask for changes that aren't their email or an organization, so an "other" request carries just a free-text description that staff resolve by hand. The visibility page now uses on/off switches and name cards that show the person's real name in each style, so it's easier to scan. New specs check that every setting on the page saves for an owner and lands on the person's change log. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../profile_change_request_decorator.rb | 4 +- app/models/profile_change_request.rb | 11 +- app/views/people/_form.html.erb | 7 ++ app/views/people/_own_profile_banner.html.erb | 5 +- app/views/people/_visibility_switch.html.erb | 10 ++ app/views/people/visibility.html.erb | 61 +++++----- .../profile_change_requests/_fields.html.erb | 8 +- .../profile_change_requests/new.html.erb | 8 +- config/features.yml | 4 +- .../profile_change_request_decorator_spec.rb | 5 + spec/factories/profile_change_requests.rb | 6 + spec/models/profile_change_request_spec.rb | 11 ++ spec/requests/people_own_profile_hub_spec.rb | 48 ++------ spec/requests/people_visibility_spec.rb | 109 ++++++++++++++++++ spec/system/person_visibility_spec.rb | 28 +++++ spec/views/people/edit.html.erb_spec.rb | 1 + 16 files changed, 251 insertions(+), 75 deletions(-) create mode 100644 app/views/people/_visibility_switch.html.erb create mode 100644 spec/requests/people_visibility_spec.rb create mode 100644 spec/system/person_visibility_spec.rb diff --git a/app/decorators/profile_change_request_decorator.rb b/app/decorators/profile_change_request_decorator.rb index 3980ca9949..bd24c78cc3 100644 --- a/app/decorators/profile_change_request_decorator.rb +++ b/app/decorators/profile_change_request_decorator.rb @@ -24,7 +24,7 @@ def requester_summary parts = if field == "affiliation" [ requested_value, (affiliation&.organization || organization)&.name ] else - [ field_label, requested_value ] + [ field_label, requested_value.presence || details&.truncate(60) ] end parts.compact_blank.join(": ") end @@ -73,6 +73,8 @@ def manual_edit_path person.user ? h.edit_user_path(person.user) : 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) diff --git a/app/models/profile_change_request.rb b/app/models/profile_change_request.rb index 94d462baea..f15c09b0f8 100644 --- a/app/models/profile_change_request.rb +++ b/app/models/profile_change_request.rb @@ -7,11 +7,13 @@ 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 + FIELDS = [ "primary_email", "organization_name", "affiliation", OTHER_FIELD ].freeze FIELD_LABELS = { "primary_email" => "Primary email", "organization_name" => "Organization name", - "affiliation" => "Affiliation details" + "affiliation" => "Affiliation details", + OTHER_FIELD => "Profile" }.freeze # Fields whose new value is concrete enough for "Approve" to write directly. AUTO_APPLIABLE_FIELDS = %w[primary_email organization_name].freeze @@ -40,7 +42,7 @@ class ProfileChangeRequest < ApplicationRecord 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? } validates :details, presence: true, if: :requires_details? @@ -60,6 +62,7 @@ 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) @@ -130,7 +133,7 @@ def snapshot_target end def requires_details? - field == "affiliation" && requested_value == AFFILIATION_CHANGE_OTHER + other? || (field == "affiliation" && requested_value == AFFILIATION_CHANGE_OTHER) end # A category's hidden sibling inputs still post their (stale) values; drop the diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 57946158ca..227c6a8ead 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -592,6 +592,13 @@
<% end %> + <% if allowed_to?(:edit_own_profile?, f.object) %> +

+ Can't change something here? + <%= link_to "Ask AWBW staff to change it", new_profile_change_request_path(person_id: f.object, field: ProfileChangeRequest::OTHER_FIELD), + class: "font-medium text-blue-600 underline hover:text-blue-800" %> +

+ <% end %>
<% if allowed_to?(:destroy?, f.object) %> <%= link_to "Delete", @person, class: button_classes(:danger_outline), diff --git a/app/views/people/_own_profile_banner.html.erb b/app/views/people/_own_profile_banner.html.erb index 468b9f03e5..536c231ca2 100644 --- a/app/views/people/_own_profile_banner.html.erb +++ b/app/views/people/_own_profile_banner.html.erb @@ -5,7 +5,7 @@

This is your profile

To update your details, click Edit my profile.

-

AWBW staff keep your email and organizations up to date. Use the buttons below to ask us for a change.

+

AWBW staff keep your email and organizations up to date. Use the buttons below to ask us for a change, or for anything you can't change yourself.

<%= link_to edit_person_path(person), class: "text-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-full bg-brand-yellow-400 px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-brand-yellow-500" do %> @@ -19,6 +19,9 @@ <%= link_to new_profile_change_request_path(person_id: person, field: "affiliation"), class: button_classes(:secondary_outline) do %> Change my organizations <% end %> + <%= link_to new_profile_change_request_path(person_id: person, field: ProfileChangeRequest::OTHER_FIELD), class: button_classes(:secondary_outline) do %> + Ask for another change + <% end %> <%= link_to visibility_person_path(person), class: button_classes(:secondary_outline) do %> Choose what others see <% end %> diff --git a/app/views/people/_visibility_switch.html.erb b/app/views/people/_visibility_switch.html.erb new file mode 100644 index 0000000000..6d4dfdb0d1 --- /dev/null +++ b/app/views/people/_visibility_switch.html.erb @@ -0,0 +1,10 @@ +<%# locals: (f:, attribute:, label:, on_text: "Shown", off_text: "Hidden") %> + diff --git a/app/views/people/visibility.html.erb b/app/views/people/visibility.html.erb index 3e084b0718..bf3b7e2956 100644 --- a/app/views/people/visibility.html.erb +++ b/app/views/people/visibility.html.erb @@ -1,47 +1,54 @@ <% content_for(:page_bg_class, "admin-or-owner") %> -<% checkbox_row_class = "flex cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-lg text-gray-800 hover:bg-gray-50" %> -<% checkbox_class = "h-6 w-6 rounded border-gray-300 text-primary focus:border-blue-500 focus:ring-blue-200" %> <% card_class = "border-primary/10 rounded-2xl border bg-white p-5 shadow-sm sm:p-6" %> -
+
<%= link_to person_path(@person), class: "text-sm #{eyebrow_link_class}" do %> Back to my profile <% end %>
-

Choose what others see

+

Choose what others see

- Check the parts of your profile other facilitators can see. AWBW staff can always see everything. + Turn on the parts of your profile other facilitators can see. AWBW staff can always see everything.

<%= simple_form_for @person, html: { class: "mt-6 space-y-6" } do |f| %> <%= hidden_field_tag :return_to, "visibility" %> <%= render "shared/errors", resource: @person if @person.errors.any? %>
-

Your profile and name

- - -
- <%= f.label :display_name_preference, "Show my name as", class: "mb-1 block text-lg text-gray-800" %> - <%= f.select :display_name_preference, - Person::DISPLAY_NAME_PREFERENCE_LABELS.invert.to_a, - { selected: @person.display_name_preference || "full_name" }, - class: "w-full rounded-md border-gray-300 text-lg shadow-sm focus:border-blue-500 focus:ring-blue-200" %> +

Your name

+
+ Show my name as +
+ <% current_preference = @person.display_name_preference.presence || "full_name" %> + <% Person::DISPLAY_NAME_PREFERENCE_LABELS.each do |preference, label| %> + + <% end %> +
+
+
+
+

Your privacy

+
+ <%= render "people/visibility_switch", f: f, attribute: :profile_is_searchable, + label: "Let other facilitators find and see my profile", on_text: "Yes", off_text: "No" %> + <%= render "people/visibility_switch", f: f, attribute: :anonymous_contributions, + label: "Stay anonymous on stories, workshops, and variations I write", on_text: "Yes", off_text: "No" %>
<% PersonDecorator::PROFILE_VISIBILITY_GROUPS.each do |heading, fields| %>
-

<%= heading %>

- <% fields.each do |attribute, label| %> - - <% end %> +

<%= heading %>

+
+ <% fields.each do |attribute, label| %> + <%= render "people/visibility_switch", f: f, attribute: attribute, label: label %> + <% end %> +
<% end %>
diff --git a/app/views/profile_change_requests/_fields.html.erb b/app/views/profile_change_requests/_fields.html.erb index 94d57bbda5..940129133d 100644 --- a/app/views/profile_change_requests/_fields.html.erb +++ b/app/views/profile_change_requests/_fields.html.erb @@ -8,7 +8,13 @@
<% end %> -<% if request.auto_appliable? %> +<% if request.other? %> + <%= f.input :details, + as: :text, + label: "What would you like us to change?", + hint: "For example: \"Please fix the spelling of my last name.\"", + input_html: { rows: 5, class: "w-full text-lg" } %> +<% elsif request.auto_appliable? %> <%= f.input :requested_value, label: "Change #{request.field_label.downcase} to", input_html: { class: "w-full" } %> diff --git a/app/views/profile_change_requests/new.html.erb b/app/views/profile_change_requests/new.html.erb index 170ab0fb39..ac97e2bc36 100644 --- a/app/views/profile_change_requests/new.html.erb +++ b/app/views/profile_change_requests/new.html.erb @@ -8,8 +8,12 @@

Request a change

- <%= @request.field_label %> is maintained by AWBW staff.
- Tell us what should change and we'll review it. + <% if @request.other? %> + Tell us what you'd like changed on your profile and AWBW staff will take care of it. + <% else %> + <%= @request.field_label %> is maintained by AWBW staff.
+ Tell us what should change and we'll review it. + <% end %>

diff --git a/config/features.yml b/config/features.yml index a5cde23d76..be4cf8d72c 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4604,9 +4604,9 @@ summary: >- Your own profile page is now the place to keep your details up to date. A big "Edit my profile" button sits at the top, along with buttons to ask - AWBW staff to change your email or organizations. + AWBW staff to change your email, your organizations, or anything else. pro_tips: - "Open it from \"My profile\" in the menu under your picture." - "Each section has its own Edit button that jumps straight to that part of the form." - "Any change you've asked for shows at the top until staff have looked at it." - - "\"Choose what others see\" opens a short checklist of what other facilitators can see on your profile." + - "\"Choose what others see\" opens a short page of on/off switches for what other facilitators can see, plus how your name shows." diff --git a/spec/decorators/profile_change_request_decorator_spec.rb b/spec/decorators/profile_change_request_decorator_spec.rb index 7a9d1ca312..a2becc6ebb 100644 --- a/spec/decorators/profile_change_request_decorator_spec.rb +++ b/spec/decorators/profile_change_request_decorator_spec.rb @@ -7,6 +7,11 @@ expect(request.requester_summary).to eq("Primary email: new@example.com") end + it "quotes the details for an other request" do + request = build_stubbed(:profile_change_request, :other, details: "Fix my last name").decorate + expect(request.requester_summary).to eq("Profile: Fix my last name") + end + it "names the change and organization for an affiliation request" do person = create(:person) affiliation = create(:affiliation, person: person, organization: create(:organization, name: "Sunrise Center")) diff --git a/spec/factories/profile_change_requests.rb b/spec/factories/profile_change_requests.rb index 514b87b76e..1cf4e5f606 100644 --- a/spec/factories/profile_change_requests.rb +++ b/spec/factories/profile_change_requests.rb @@ -19,6 +19,12 @@ details { nil } end + trait :other do + field { ProfileChangeRequest::OTHER_FIELD } + requested_value { nil } + details { "Please fix the spelling of my last name." } + end + trait :resolved do status { "resolved" } resolution_method { "manual" } diff --git a/spec/models/profile_change_request_spec.rb b/spec/models/profile_change_request_spec.rb index dd84ae43fb..57640d24fd 100644 --- a/spec/models/profile_change_request_spec.rb +++ b/spec/models/profile_change_request_spec.rb @@ -19,6 +19,16 @@ expect(request.errors[:details]).to be_present end + it "accepts an other request with only details" do + expect(build(:profile_change_request, :other)).to be_valid + end + + it "requires details for an other request" do + request = build(:profile_change_request, :other, details: nil) + expect(request).not_to be_valid + expect(request.errors[:details]).to be_present + end + it "rejects an unknown field" do request = build(:profile_change_request, field: "shoe_size") expect(request).not_to be_valid @@ -37,6 +47,7 @@ expect(build(:profile_change_request, :primary_email)).to be_auto_appliable expect(build(:profile_change_request, :organization_name)).to be_auto_appliable expect(build(:profile_change_request, field: "affiliation")).not_to be_auto_appliable + expect(build(:profile_change_request, :other)).not_to be_auto_appliable end end diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index f947d20389..7c64948ec5 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -18,6 +18,7 @@ expect(response.body).to include("Edit my profile") expect(response.body).to include("field=primary_email") expect(response.body).to include("field=affiliation") + expect(response.body).to include("field=other") expect(response.body).to include(visibility_person_path(person)) end @@ -80,58 +81,31 @@ expect(response.body).to include("Edit my profile") expect(response.body).to match(/href="#{person_path(person)}"[^>]*>\s*]*><\/i>\s*My profile/) expect(response.body).to match(/href="#{person_path(person)}"[^>]*>Cancel/) + expect(response.body).to include("Ask AWBW staff to change it") end end - describe "choosing what others see" do + describe "sending a change request" do before { sign_in owner_user } - it "shows only the visibility choices" do - get visibility_person_path(person) - - expect(response).to have_http_status(:ok) - expect(response.body).to include("Choose what others see") - expect(response.body).to include("Let other facilitators find and see my profile") - expect(response.body).not_to include("person[bio]") - end - - it "saves and returns to the profile" do - patch person_path(person), params: { - return_to: "visibility", - person: { profile_show_email: "0", profile_show_bio: "1", display_name_preference: "first_name_only" } + it "returns to the profile" do + post profile_change_requests_path(person_id: person), params: { + profile_change_request: { field: "primary_email", requested_value: "new@example.com" } } expect(response).to redirect_to(person_path(person)) - person.reload - expect(person.profile_show_email).to be(false) - expect(person.display_name_preference).to eq("first_name_only") - end - - it "re-renders the visibility page when the save fails" do - patch person_path(person), params: { return_to: "visibility", person: { first_name: "" } } - - expect(response).to have_http_status(:unprocessable_content) - expect(response.body).to include("Choose what others see") end - it "is closed to other people" do - sign_in create(:user) + it "accepts a free-text request for anything else" do + get new_profile_change_request_path(person_id: person, field: ProfileChangeRequest::OTHER_FIELD) + expect(response.body).to include("What would you like us to change?") - get visibility_person_path(person) - - expect(response).to redirect_to(root_path) - end - end - - describe "sending a change request" do - before { sign_in owner_user } - - it "returns to the profile" do post profile_change_requests_path(person_id: person), params: { - profile_change_request: { field: "primary_email", requested_value: "new@example.com" } + profile_change_request: { field: ProfileChangeRequest::OTHER_FIELD, details: "Please fix my last name." } } expect(response).to redirect_to(person_path(person)) + expect(person.profile_change_requests.last).to have_attributes(field: "other", details: "Please fix my last name.") end it "returns to the profile's affiliations after an affiliation request" do diff --git a/spec/requests/people_visibility_spec.rb b/spec/requests/people_visibility_spec.rb new file mode 100644 index 0000000000..3c484bf121 --- /dev/null +++ b/spec/requests/people_visibility_spec.rb @@ -0,0 +1,109 @@ +require "rails_helper" + +# The "Choose what others see" page saves through PeopleController#update as the +# owner, so every setting it shows must survive the owner params filter and land +# on the person's change log. +RSpec.describe "Choosing what others see on your profile", type: :request do + let(:owner_user) { create(:user, :with_person) } + let(:person) { owner_user.person } + let(:switches) do + %i[profile_is_searchable anonymous_contributions] + + PersonDecorator::PROFILE_VISIBILITY_GROUPS.values.flat_map(&:keys) + end + + before do + Analytics::LifecycleBuffer.store.clear + sign_in owner_user + end + + after { Current.user = nil } + + def save_visibility(attributes) + patch person_path(person), params: { return_to: "visibility", person: attributes } + end + + # The controller flushes (and clears) the buffer in an after_action, so copy + # the events as it flushes. + def buffered_during_request + events = [] + allow(Analytics::LifecycleBuffer).to receive(:flush) do + events.concat(Analytics::LifecycleBuffer.store) + Analytics::LifecycleBuffer.store.clear + end + yield + events + end + + it "renders a control for every setting" do + get visibility_person_path(person) + + expect(response).to have_http_status(:ok) + switches.each { |attribute| expect(response.body).to include(%(name="person[#{attribute}]")) } + Person::DISPLAY_NAME_PREFERENCES.each do |preference| + expect(response.body).to include(%(value="#{preference}")) + end + expect(response.body).not_to include("person[bio]") + end + + it "saves every switch flipped, then flipped back" do + original = switches.index_with { |attribute| person.public_send(attribute) } + + save_visibility(original.transform_values { |value| value ? "0" : "1" }) + + expect(response).to redirect_to(person_path(person)) + person.reload + original.each { |attribute, value| expect(person.public_send(attribute)).to eq(!value), attribute.to_s } + + save_visibility(original.transform_values { |value| value ? "1" : "0" }) + + person.reload + original.each { |attribute, value| expect(person.public_send(attribute)).to eq(value), attribute.to_s } + end + + it "saves each name choice" do + Person::DISPLAY_NAME_PREFERENCES.each do |preference| + save_visibility(display_name_preference: preference) + expect(person.reload.display_name_preference).to eq(preference) + end + end + + it "logs every change to the person's change log as the owner" do + original = switches.index_with { |attribute| person.public_send(attribute) } + + events = buffered_during_request do + save_visibility(original.transform_values { |value| value ? "0" : "1" }.merge(display_name_preference: "first_name_only")) + end + + event = events.find { |e| e[:name] == "update.person" } + expect(event).to be_present + expect(event[:user]).to eq(owner_user) + changes = event.dig(:properties, :changes) + original.each do |attribute, value| + expect(changes[attribute.to_s]).to eq(before: value, after: !value), attribute.to_s + end + expect(changes["display_name_preference"]).to include(after: "first_name_only") + end + + it "re-renders the page when the save fails" do + save_visibility(first_name: "") + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Choose what others see") + end + + it "is closed to other people" do + sign_in create(:user) + + get visibility_person_path(person) + + expect(response).to redirect_to(root_path) + end + + it "is closed in production" do + allow(Rails.env).to receive(:production?).and_return(true) + + get visibility_person_path(person) + + expect(response).to redirect_to(root_path) + end +end diff --git a/spec/system/person_visibility_spec.rb b/spec/system/person_visibility_spec.rb new file mode 100644 index 0000000000..75066b67c9 --- /dev/null +++ b/spec/system/person_visibility_spec.rb @@ -0,0 +1,28 @@ +require "rails_helper" + +RSpec.describe "Choosing what others see", type: :system, js: true do + let(:user) { create(:user, :with_person) } + let(:person) { user.person } + + before { sign_in user } + + it "saves every switch and the name choice from the page" do + labels = [ + "Let other facilitators find and see my profile", + "Stay anonymous on stories, workshops, and variations I write" + ] + PersonDecorator::PROFILE_VISIBILITY_GROUPS.values.flat_map(&:values) + attributes = %i[profile_is_searchable anonymous_contributions] + + PersonDecorator::PROFILE_VISIBILITY_GROUPS.values.flat_map(&:keys) + original = attributes.index_with { |attribute| person.public_send(attribute) } + + visit visibility_person_path(person) + labels.each { |label| find("label", text: label, exact_text: false).click } + find("label", text: "First name only").click + click_button "Save" + + expect(page).to have_current_path(person_path(person)) + person.reload + original.each { |attribute, value| expect(person.public_send(attribute)).to eq(!value), attribute.to_s } + expect(person.display_name_preference).to eq("first_name_only") + end +end diff --git a/spec/views/people/edit.html.erb_spec.rb b/spec/views/people/edit.html.erb_spec.rb index 46056339cd..627ec85832 100644 --- a/spec/views/people/edit.html.erb_spec.rb +++ b/spec/views/people/edit.html.erb_spec.rb @@ -10,6 +10,7 @@ assign(:person, person) allow(view).to receive(:current_user).and_return(admin) allow(view).to receive(:allowed_to?).and_return(true) + allow(view).to receive(:allowed_to?).with(:edit_own_profile?, anything).and_return(false) render end From b67f00abc46f5d9076e94f013fce3e7f903deae4 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 07:10:18 -0400 Subject: [PATCH 05/34] Pick an organization before asking for an organization change People with several organizations had to find theirs in a dropdown buried in the form. They now pick from cards first, or add a new one. Someone with one organization goes straight to the form. Also fixes the request form showing a plain text box for an affiliation request that already has a category, which broke editing a pending request. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../profile_change_requests_controller.rb | 25 +++++- .../profile_change_requests/_fields.html.erb | 2 +- .../choose_affiliation.html.erb | 37 +++++++++ .../profile_change_requests/new.html.erb | 13 ++- ...change_request_organization_picker_spec.rb | 81 +++++++++++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 6 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 app/views/profile_change_requests/choose_affiliation.html.erb create mode 100644 spec/requests/profile_change_request_organization_picker_spec.rb diff --git a/app/controllers/profile_change_requests_controller.rb b/app/controllers/profile_change_requests_controller.rb index 1935af3132..072a7fe6eb 100644 --- a/app/controllers/profile_change_requests_controller.rb +++ b/app/controllers/profile_change_requests_controller.rb @@ -17,10 +17,7 @@ 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 @@ -102,6 +99,26 @@ def review_note params[:reviewer_note].presence end + # Someone with several organizations picks one first; with one (or none) the + # form opens straight on it. + def prepare_affiliation_request + @affiliations = @person.affiliations.includes(:organization).select(&:organization) + .sort_by { |a| [ a.active? ? 0 : 1, a.organization.name.downcase ] } + if params[:add].present? || @affiliations.empty? + @request.requested_value = ProfileChangeRequest::AFFILIATION_CHANGE_ADD + elsif params[:affiliation_id].present? + @request.affiliation = @affiliations.find { |a| a.id.to_s == params[:affiliation_id].to_s } + elsif @affiliations.one? + @request.affiliation = @affiliations.first + else + @pending_by_affiliation = @person.profile_change_requests.pending.where(field: "affiliation").index_by(&:affiliation_id) + return render :choose_affiliation + end + + existing = @request.affiliation && @person.profile_change_requests.pending.find_by(field: "affiliation", affiliation: @request.affiliation) + redirect_to edit_profile_change_request_path(existing) if existing + end + def profile_change_request_params params.require(:profile_change_request).permit( :field, :requested_value, :details, :affiliation_id, :organization_id, diff --git a/app/views/profile_change_requests/_fields.html.erb b/app/views/profile_change_requests/_fields.html.erb index 940129133d..09a5a5283d 100644 --- a/app/views/profile_change_requests/_fields.html.erb +++ b/app/views/profile_change_requests/_fields.html.erb @@ -14,7 +14,7 @@ label: "What would you like us to change?", hint: "For example: \"Please fix the spelling of my last name.\"", input_html: { rows: 5, class: "w-full text-lg" } %> -<% elsif request.auto_appliable? %> +<% elsif request.field.in?(ProfileChangeRequest::AUTO_APPLIABLE_FIELDS) %> <%= f.input :requested_value, label: "Change #{request.field_label.downcase} to", input_html: { class: "w-full" } %> diff --git a/app/views/profile_change_requests/choose_affiliation.html.erb b/app/views/profile_change_requests/choose_affiliation.html.erb new file mode 100644 index 0000000000..f48a19ea1b --- /dev/null +++ b/app/views/profile_change_requests/choose_affiliation.html.erb @@ -0,0 +1,37 @@ +<% content_for(:page_bg_class, "admin-or-owner") %> +<% card_class = "flex h-full flex-col gap-1 rounded-2xl border bg-white p-5 shadow-sm transition" %> +
+
+ <%= link_to person_path(@person, anchor: "affiliations"), class: "text-sm #{eyebrow_link_class}" do %> + Back to my profile + <% end %> +
+

Which organization?

+

Pick the organization you'd like to change. AWBW staff will look at your request.

+
+ <% @affiliations.each do |affiliation| %> + <% decorated = affiliation.decorate %> + <% pending = @pending_by_affiliation[affiliation.id] %> + <% path = pending ? edit_profile_change_request_path(pending) : new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id) %> + <%= link_to path, class: "#{card_class} border-primary/10 hover:border-primary hover:shadow-md" do %> + <%= affiliation.organization.name %> + <%= affiliation.title.presence || Affiliation::FACILITATOR_TITLE %> + + <%= decorated.status_badge %> + <%= decorated.period_label %> + + <% if pending %> + + Change requested · see or change it + + <% end %> + <% end %> + <% end %> + <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", add: 1), + class: "#{card_class} items-center justify-center border-2 border-dashed border-gray-300 text-center hover:border-primary" do %> + + Add an organization + I work with an organization that isn't listed + <% end %> +
+
diff --git a/app/views/profile_change_requests/new.html.erb b/app/views/profile_change_requests/new.html.erb index ac97e2bc36..17dc68c9e2 100644 --- a/app/views/profile_change_requests/new.html.erb +++ b/app/views/profile_change_requests/new.html.erb @@ -1,12 +1,21 @@ <% content_for(:page_bg_class, "admin-or-owner") %>
- <%= link_to @request.decorate.profile_return_path, class: "text-sm #{eyebrow_link_class}" do %> - Back to my profile + <% if @affiliations&.many? %> + <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation"), class: "text-sm #{eyebrow_link_class}" do %> + Back to my organizations + <% end %> + <% else %> + <%= link_to @request.decorate.profile_return_path, class: "text-sm #{eyebrow_link_class}" do %> + Back to my profile + <% end %> <% end %>

Request a change

+ <% if @request.affiliation %> +

<%= @request.affiliation_summary %>

+ <% end %>

<% if @request.other? %> Tell us what you'd like changed on your profile and AWBW staff will take care of it. diff --git a/spec/requests/profile_change_request_organization_picker_spec.rb b/spec/requests/profile_change_request_organization_picker_spec.rb new file mode 100644 index 0000000000..6602dcb97d --- /dev/null +++ b/spec/requests/profile_change_request_organization_picker_spec.rb @@ -0,0 +1,81 @@ +require "rails_helper" + +RSpec.describe "Choosing which organization to change", type: :request do + let(:owner_user) { create(:user, :with_person) } + let(:person) { owner_user.person } + let!(:sunrise) { create(:affiliation, person: person, organization: create(:organization, name: "Sunrise Center"), title: "Facilitator") } + + before { sign_in owner_user } + + def new_affiliation_request(**params) + get new_profile_change_request_path(person_id: person, field: "affiliation", **params) + end + + context "with several organizations" do + let!(:harbor) { create(:affiliation, person: person, organization: create(:organization, name: "Harbor House"), title: "Board member") } + + it "lists each organization and a way to add one" do + new_affiliation_request + + expect(response.body).to include("Which organization?") + expect(response.body).to include("Sunrise Center", "Harbor House") + expect(response.body).to include("affiliation_id=#{sunrise.id}", "affiliation_id=#{harbor.id}") + expect(response.body).to include("Add an organization") + end + + it "opens the form on the chosen organization, with a way back to the list" do + new_affiliation_request(affiliation_id: harbor.id) + + expect(response.body).to include("Board member @ Harbor House") + expect(response.body).to include(%(

Choose what others see

- Turn on the parts of your profile other facilitators can see. AWBW staff can always see everything. + Turn on the parts of your profile other AWBW facilitators can see. AWBW staff can always see everything.

<%= simple_form_for @person, html: { class: "mt-6 space-y-6" } do |f| %> <%= hidden_field_tag :return_to, "visibility" %> @@ -36,7 +36,7 @@

Your privacy

<%= render "people/visibility_switch", f: f, attribute: :profile_is_searchable, - label: "Let other facilitators find and see my profile", on_text: "Yes", off_text: "No" %> + label: "Let other AWBW facilitators find and see my profile", on_text: "Yes", off_text: "No" %> <%= render "people/visibility_switch", f: f, attribute: :anonymous_contributions, label: "Stay anonymous on stories, workshops, and variations I write", on_text: "Yes", off_text: "No" %>
diff --git a/app/views/profile_change_requests/_fields.html.erb b/app/views/profile_change_requests/_fields.html.erb index 09a5a5283d..a2f832fe4b 100644 --- a/app/views/profile_change_requests/_fields.html.erb +++ b/app/views/profile_change_requests/_fields.html.erb @@ -23,49 +23,41 @@ label: "Anything else we should know? (optional)", input_html: { rows: 3, class: "w-full" } %> <% else %> - <% affiliations = request.person.affiliations.includes(:organization).map { |a| [ "#{a.title.presence || Affiliation::FACILITATOR_TITLE} @ #{a.organization&.name}", a.id ] } %> - <% existing_row_categories = (ProfileChangeRequest::AFFILIATION_CHANGE_TYPES - [ ProfileChangeRequest::AFFILIATION_CHANGE_ADD ]).join(",") %> -
- <%= f.input :requested_value, - collection: ProfileChangeRequest::AFFILIATION_CHANGE_TYPES, - include_blank: "Select what to change…", - label: "What should change?", - input_html: { class: "w-full", data: { conditional_fields_target: "source", action: "change->conditional-fields#toggle" } } %> - - - - + <% elsif change == ProfileChangeRequest::AFFILIATION_CHANGE_ORGANIZATION %> + <%= f.input :proposed_organization_name, label: "Correct organization name", + hint: ("Right now: #{current.organization&.name}" if current), + input_html: { class: "w-full text-lg" } %> + <% elsif change == ProfileChangeRequest::AFFILIATION_CHANGE_REMOVE %> +

We'll mark <%= current&.organization&.name %> as an organization you no longer work with.

+ <% end %> + <% if change == ProfileChangeRequest::AFFILIATION_CHANGE_OTHER %> + <%= f.input :details, as: :text, label: "What would you like us to change?", input_html: { rows: 5, class: "w-full text-lg" } %> + <% else %> + <%= f.input :details, as: :text, label: "Anything else we should know? (optional)", input_html: { rows: 3, class: "w-full" } %> + <% end %> <% end %> diff --git a/app/views/profile_change_requests/choose_affiliation.html.erb b/app/views/profile_change_requests/choose_affiliation.html.erb index f48a19ea1b..dcba454314 100644 --- a/app/views/profile_change_requests/choose_affiliation.html.erb +++ b/app/views/profile_change_requests/choose_affiliation.html.erb @@ -2,11 +2,11 @@ <% card_class = "flex h-full flex-col gap-1 rounded-2xl border bg-white p-5 shadow-sm transition" %>
- <%= link_to person_path(@person, anchor: "affiliations"), class: "text-sm #{eyebrow_link_class}" do %> - Back to my profile + <%= link_to @back_path, class: "text-sm #{eyebrow_link_class}" do %> + <%= @back_label %> <% end %>
-

Which organization?

+

Which organization?

Pick the organization you'd like to change. AWBW staff will look at your request.

<% @affiliations.each do |affiliation| %> @@ -14,8 +14,8 @@ <% pending = @pending_by_affiliation[affiliation.id] %> <% path = pending ? edit_profile_change_request_path(pending) : new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id) %> <%= link_to path, class: "#{card_class} border-primary/10 hover:border-primary hover:shadow-md" do %> - <%= affiliation.organization.name %> - <%= affiliation.title.presence || Affiliation::FACILITATOR_TITLE %> + <%= affiliation.organization.name %> + <%= decorated.display_title %> <%= decorated.status_badge %> <%= decorated.period_label %> @@ -29,8 +29,8 @@ <% end %> <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", add: 1), class: "#{card_class} items-center justify-center border-2 border-dashed border-gray-300 text-center hover:border-primary" do %> - - Add an organization + + Add an organization I work with an organization that isn't listed <% end %>
diff --git a/app/views/profile_change_requests/choose_affiliation_change.html.erb b/app/views/profile_change_requests/choose_affiliation_change.html.erb new file mode 100644 index 0000000000..cf342708d1 --- /dev/null +++ b/app/views/profile_change_requests/choose_affiliation_change.html.erb @@ -0,0 +1,21 @@ +<% content_for(:page_bg_class, "admin-or-owner") %> +
+
+ <%= link_to @back_path, class: "text-sm #{eyebrow_link_class}" do %> + <%= @back_label %> + <% end %> +
+

What would you like to change?

+

<%= @request.affiliation_summary %>

+
+ <% ProfileChangeRequestDecorator::AFFILIATION_CHANGE_CHOICES.except(ProfileChangeRequest::AFFILIATION_CHANGE_ADD).each do |change, choice| %> + <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: @request.affiliation.id, change: change), + class: "border-primary/10 flex items-center gap-4 rounded-2xl border bg-white p-5 shadow-sm transition hover:border-primary hover:shadow-md" do %> + + + + <%= choice[:label] %> + <% end %> + <% end %> +
+
diff --git a/app/views/profile_change_requests/new.html.erb b/app/views/profile_change_requests/new.html.erb index 17dc68c9e2..7aaf9b934d 100644 --- a/app/views/profile_change_requests/new.html.erb +++ b/app/views/profile_change_requests/new.html.erb @@ -1,24 +1,20 @@ <% content_for(:page_bg_class, "admin-or-owner") %>
- <% if @affiliations&.many? %> - <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation"), class: "text-sm #{eyebrow_link_class}" do %> - Back to my organizations - <% end %> - <% else %> - <%= link_to @request.decorate.profile_return_path, class: "text-sm #{eyebrow_link_class}" do %> - Back to my profile - <% end %> + <%= link_to @back_path || @request.decorate.profile_return_path, class: "text-sm #{eyebrow_link_class}" do %> + <%= @back_label || "Back to my profile" %> <% end %>
-

Request a change

+

<%= @request.decorate.heading || "Request a change" %>

<% if @request.affiliation %>

<%= @request.affiliation_summary %>

<% end %>

<% if @request.other? %> Tell us what you'd like changed on your profile and AWBW staff will take care of it. + <% elsif @request.field == "affiliation" %> + AWBW staff will look at your request and make the change. <% else %> <%= @request.field_label %> is maintained by AWBW staff.
Tell us what should change and we'll review it. diff --git a/config/features.yml b/config/features.yml index be4cf8d72c..1a779914da 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4609,4 +4609,4 @@ - "Open it from \"My profile\" in the menu under your picture." - "Each section has its own Edit button that jumps straight to that part of the form." - "Any change you've asked for shows at the top until staff have looked at it." - - "\"Choose what others see\" opens a short page of on/off switches for what other facilitators can see, plus how your name shows." + - "\"Choose what others see\" opens a short page of on/off switches for what other AWBW facilitators can see, plus how your name shows." diff --git a/spec/decorators/affiliation_decorator_spec.rb b/spec/decorators/affiliation_decorator_spec.rb index f6e3608c6e..629f571ef3 100644 --- a/spec/decorators/affiliation_decorator_spec.rb +++ b/spec/decorators/affiliation_decorator_spec.rb @@ -1,6 +1,17 @@ require "rails_helper" RSpec.describe AffiliationDecorator do + describe "#display_title" do + it "names a facilitator affiliation as an AWBW facilitator" do + expect(build_stubbed(:affiliation, title: "Facilitator").decorate.display_title).to eq("AWBW Facilitator") + expect(build_stubbed(:affiliation, title: nil).decorate.display_title).to eq("AWBW Facilitator") + end + + it "keeps any other title" do + expect(build_stubbed(:affiliation, title: "Board member").decorate.display_title).to eq("Board member") + end + end + describe "#status_badge" do it "labels an active affiliation Active" do affiliation = build(:affiliation, inactive: false, start_date: 1.year.ago, end_date: nil).decorate diff --git a/spec/requests/profile_change_request_organization_picker_spec.rb b/spec/requests/profile_change_request_organization_picker_spec.rb index 6602dcb97d..8819481bc8 100644 --- a/spec/requests/profile_change_request_organization_picker_spec.rb +++ b/spec/requests/profile_change_request_organization_picker_spec.rb @@ -23,11 +23,12 @@ def new_affiliation_request(**params) expect(response.body).to include("Add an organization") end - it "opens the form on the chosen organization, with a way back to the list" do + it "asks what to change on the chosen organization, with a way back to the list" do new_affiliation_request(affiliation_id: harbor.id) + expect(response.body).to include("What would you like to change?") expect(response.body).to include("Board member @ Harbor House") - expect(response.body).to include(%(

+
Age ranges
diff --git a/app/views/people/_own_profile_banner.html.erb b/app/views/people/_own_profile_banner.html.erb index 536c231ca2..6d5fffc3bf 100644 --- a/app/views/people/_own_profile_banner.html.erb +++ b/app/views/people/_own_profile_banner.html.erb @@ -7,7 +7,7 @@

To update your details, click Edit my profile.

AWBW staff keep your email and organizations up to date. Use the buttons below to ask us for a change, or for anything you can't change yourself.

- <%= link_to edit_person_path(person), + <%= link_to details_person_path(person), class: "text-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-full bg-brand-yellow-400 px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-brand-yellow-500" do %> Edit my profile <% end %> diff --git a/app/views/people/_profile_section_action.html.erb b/app/views/people/_profile_section_action.html.erb index 30e91d3963..7631ee5e17 100644 --- a/app/views/people/_profile_section_action.html.erb +++ b/app/views/people/_profile_section_action.html.erb @@ -5,7 +5,7 @@ Ask for a change <% end %> <% elsif allowed_to?(:edit?, person) %> - <%= link_to edit_person_path(person, anchor: anchor), class: pill_class do %> + <%= link_to (allowed_to?(:edit_own_profile?, person) ? details_person_path(person, anchor: anchor) : edit_person_path(person, anchor: anchor)), class: pill_class do %> <%= label %> <% end %> <% end %> diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb new file mode 100644 index 0000000000..95d51cbcaf --- /dev/null +++ b/app/views/people/details.html.erb @@ -0,0 +1,183 @@ +<% content_for(:page_bg_class, "admin-or-owner") %> +<% card_class = "border-primary/10 scroll-mt-4 rounded-2xl border bg-white p-5 shadow-sm sm:p-6" %> +<% heading_class = "mb-4 font-display text-2xl text-gray-900" %> +<% input_class = "w-full text-lg" %> +<% pill_class = "has-checked:border-primary has-checked:bg-brand-cream has-checked:ring-primary has-checked:ring-1 flex cursor-pointer items-center gap-3 rounded-xl border border-gray-300 bg-white px-4 py-3 text-lg text-gray-800 shadow-sm transition hover:border-gray-400" %> +<% checkbox_class = "text-primary h-5 w-5 rounded border-gray-300 focus:ring-blue-200" %> +
+
+ <%= link_to person_path(@person), class: "text-sm #{eyebrow_link_class}" do %> + Back to my profile + <% end %> +
+

Edit my profile

+

+ Change anything below, then click Save. To change your email or organizations, use the buttons on your profile. +

+ <%= simple_form_for @person, html: { multipart: true, class: "mt-6 space-y-6" } do |f| %> + <%= hidden_field_tag :return_to, "details" %> + <%= render "shared/errors", resource: @person if @person.errors.any? %> +
+

Your photo

+ <%= render "shared/form_image_field", f: f, field_name: :avatar, label: "Photo", rounded: true %> +
+
+

Your name

+
+ <%= f.input :first_name, label: "First name", input_html: { class: input_class } %> + <%= f.input :last_name, label: "Last name", input_html: { class: input_class } %> + <%= f.input :pronouns, label: "Pronouns", hint: "For example: she/her, he/him, they/them", input_html: { class: input_class } %> + <%= f.input :pronunciation, label: "How to say your name", hint: "For example: Oom-BEAR-toh", input_html: { class: input_class } %> + <%= f.input :legal_first_name, label: "Legal first name", hint: "Only if it's different from your first name", input_html: { class: input_class } %> +
+
+
+

About you

+ <%= f.input :bio, as: :text, label: "Bio", hint: "A few sentences about you and your work.", + input_html: { rows: 5, maxlength: 1650, class: input_class } %> + <%= f.input :date_of_birth, label: "Birthday", as: :date, html5: true, + hint: "Only AWBW staff see this. Your profile never shows the year.", + wrapper_html: { class: "mt-4 sm:w-64" }, input_html: { class: input_class } %> +
+
+

Phone

+
+ <%= f.simple_fields_for :contact_methods, @person.contact_methods.reject(&:inactive?) do |phone| %> +
+ <%= phone.input :value, label: "Phone number", input_html: { class: input_class } %> + <%= phone.input :contact_type, label: "Type", collection: ContactMethod::CONTACT_TYPES.compact.map { |type| [ type.humanize, type ] }, + include_blank: true, input_html: { class: input_class } %> + +
+ <% end %> + <%= f.simple_fields_for :contact_methods, ContactMethod.new(kind: "phone"), child_index: 999 do |phone| %> + <%= phone.hidden_field :kind %> +
+ <%= phone.input :value, label: @person.contact_methods.reject(&:inactive?).any? ? "Add another phone number" : "Phone number", + required: false, input_html: { class: input_class } %> + <%= phone.input :contact_type, label: "Type", collection: ContactMethod::CONTACT_TYPES.compact.map { |type| [ type.humanize, type ] }, + include_blank: true, input_html: { class: input_class } %> +
+ <% end %> +
+
+
+

Mailing address

+ <% addresses = @person.addresses.reject(&:inactive?).presence || [ Address.new ] %> +
+ <%= f.simple_fields_for :addresses, addresses do |address| %> +
+ <%= address.input :street_address, label: "Street address", wrapper_html: { class: "sm:col-span-6" }, input_html: { class: input_class } %> + <%= address.input :city, label: "City", required: false, wrapper_html: { class: "sm:col-span-3" }, input_html: { class: input_class } %> + <%= address.input :state, label: "State", required: false, wrapper_html: { class: "sm:col-span-1" }, input_html: { class: input_class } %> + <%= address.input :zip_code, label: "Zip code", wrapper_html: { class: "sm:col-span-2" }, input_html: { class: input_class } %> + <%= address.input :locality, label: "Area", required: false, collection: Address::LOCALITIES, include_blank: true, + wrapper_html: { class: "sm:col-span-3" }, input_html: { class: input_class } %> + <% if address.object.persisted? %> + + <% end %> +
+ <% end %> +
+
+
+

Social media

+
+ <%= f.input :linked_in_url, label: "LinkedIn", placeholder: "https://", input_html: { class: input_class } %> + <%= f.input :instagram_url, label: "Instagram", placeholder: "https://", input_html: { class: input_class } %> + <%= f.input :facebook_url, label: "Facebook", placeholder: "https://", input_html: { class: input_class } %> + <%= f.input :youtube_url, label: "YouTube", placeholder: "https://", input_html: { class: input_class } %> + <%= f.input :twitter_url, label: "X (Twitter)", placeholder: "https://", input_html: { class: input_class } %> +
+
+
+

Sectors you work in

+

Check every one that fits.

+ <% items_by_sector = @person.sectorable_items.index_by(&:sector_id) %> +
+ <% @sectors_collection.each_with_index do |(name, sector_id), index| %> + <% existing = items_by_sector[sector_id] %> + <%= f.fields_for :sectorable_items, existing || SectorableItem.new(sector_id: sector_id), child_index: index do |sector| %> + <%= sector.hidden_field :sector_id %> + + <% end %> + <% end %> +
+
+ <% if @age_ranges_collection.present? %> +
+

Ages you work with

+

Check every one that fits.

+ <% items_by_category = @person.age_range_categorizable_items.index_by(&:category_id) %> +
+ <% @age_ranges_collection.each_with_index do |(name, category_id), index| %> + <% existing = items_by_category[category_id] %> + <%= f.fields_for :age_range_categorizable_items, existing || CategorizableItem.new(category_id: category_id), child_index: index do |age| %> + <%= age.hidden_field :category_id %> + + <% end %> + <% end %> +
+
+ <% end %> +
+

Professional licenses

+

Used for continuing education credit.

+
+ <% @person.professional_licenses.each_with_index do |license, index| %> + <% if allowed_to?(:edit?, license) %> + <%= f.simple_fields_for :professional_licenses, license, child_index: index do |lf| %> +
+ <%= lf.input :kind, label: "Type", placeholder: "For example: LMFT", input_html: { class: input_class } %> + <%= lf.input :number, label: "License number", input_html: { class: input_class } %> + <%= lf.input :issuing_state, label: "State", collection: us_states, include_blank: true, input_html: { class: input_class } %> + <%= lf.input :expires_on, label: "Expires", as: :date, html5: true, input_html: { class: input_class } %> +
+ <% if license.removable? %> + + <% end %> + <% end %> + <% else %> +
+

+ <%= [ license.kind, license.number, license.issuing_state ].compact_blank.join(" · ") %> + <% if license.expires_on %>· expires <%= license.expires_on.to_fs(:long) %><% end %> +

+

This license has continuing education credits, so only AWBW staff can change it.

+
+ <% end %> + <% end %> + <%= f.simple_fields_for :professional_licenses, ProfessionalLicense.new, child_index: 999 do |lf| %> +

<%= @person.professional_licenses.any? ? "Add another license" : "Add a license" %>

+
+ <%= lf.input :kind, label: "Type", placeholder: "For example: LMFT", input_html: { class: input_class } %> + <%= lf.input :number, label: "License number", input_html: { class: input_class } %> + <%= lf.input :issuing_state, label: "State", collection: us_states, include_blank: true, input_html: { class: input_class } %> + <%= lf.input :expires_on, label: "Expires", as: :date, html5: true, input_html: { class: input_class } %> +
+ <% end %> +
+
+
+ <%= link_to "Cancel", person_path(@person), class: button_classes(:secondary_outline) %> + <%= f.button :submit, "Save", class: button_classes(:primary, extra: "px-10 text-lg") %> +
+ <% end %> +

+ Can't change something here? + <%= link_to "Ask AWBW staff to change it", new_profile_change_request_path(person_id: @person, field: ProfileChangeRequest::OTHER_FIELD), + class: "font-medium text-blue-600 underline hover:text-blue-800" %> +

+
diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index e7d8ad4ebb..a784d09c0c 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -192,7 +192,7 @@

Age ranges

- <%= render "people/profile_section_action", person: @person, anchor: "sectors" %> + <%= render "people/profile_section_action", person: @person, anchor: "age-ranges" %>
<%= render "shared/age_group_tags", primary: primary_age_groups, additional: additional_age_groups, compact: true %>
diff --git a/app/views/profile_change_requests/choose_affiliation_change.html.erb b/app/views/profile_change_requests/choose_affiliation_change.html.erb index cf342708d1..c3297abd58 100644 --- a/app/views/profile_change_requests/choose_affiliation_change.html.erb +++ b/app/views/profile_change_requests/choose_affiliation_change.html.erb @@ -5,7 +5,7 @@ <%= @back_label %> <% end %>
-

What would you like to change?

+

What would you like to change?

<%= @request.affiliation_summary %>

<% ProfileChangeRequestDecorator::AFFILIATION_CHANGE_CHOICES.except(ProfileChangeRequest::AFFILIATION_CHANGE_ADD).each do |change, choice| %> @@ -14,7 +14,7 @@ - <%= choice[:label] %> + <%= choice[:label] %> <% end %> <% end %>
diff --git a/config/features.yml b/config/features.yml index 1a779914da..8103c88b5c 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4607,6 +4607,8 @@ AWBW staff to change your email, your organizations, or anything else. pro_tips: - "Open it from \"My profile\" in the menu under your picture." - - "Each section has its own Edit button that jumps straight to that part of the form." + - "\"Edit my profile\" opens one simple page for your photo, name, bio, phone, address, social media, sectors, ages, and licenses." + - "Each section of your profile has its own Edit button that jumps straight to that part of the page." + - "To change an organization, pick it, then pick what to change — dates use a date picker." - "Any change you've asked for shows at the top until staff have looked at it." - "\"Choose what others see\" opens a short page of on/off switches for what other AWBW facilitators can see, plus how your name shows." diff --git a/config/routes.rb b/config/routes.rb index 03734cad95..6801961c11 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -301,6 +301,7 @@ get :bio get :all_comments get :visibility + get :details post :send_form_link end resources :comments, only: [ :create, :update ] diff --git a/spec/requests/people_authorization_spec.rb b/spec/requests/people_authorization_spec.rb index c6aedf820b..c6f2ad184b 100644 --- a/spec/requests/people_authorization_spec.rb +++ b/spec/requests/people_authorization_spec.rb @@ -98,8 +98,9 @@ get person_path(regular_user.person) end - it "shows the Edit link outside production" do - expect(response.body).to include(edit_person_path(regular_user.person)) + it "links to the simple edit page, not the full form, outside production" do + expect(response.body).to include(details_person_path(regular_user.person)) + expect(response.body).not_to include(%(href="#{edit_person_path(regular_user.person)}")) end it "shows the Submitted content section" do diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb new file mode 100644 index 0000000000..02ca7a39dc --- /dev/null +++ b/spec/requests/people_details_spec.rb @@ -0,0 +1,127 @@ +require "rails_helper" + +# The simple "Edit my profile" page saves through PeopleController#update as +# the owner, so it must survive the owner params filter, skip its blank add-new +# rows, and land on the person's change log. +RSpec.describe "Editing your own profile details", type: :request do + let(:owner_user) { create(:user, :with_person) } + let(:person) { owner_user.person } + let!(:arts) { create(:sector, :published, name: "Arts") } + let!(:health) { create(:sector, :published, name: "Health") } + + before do + Analytics::LifecycleBuffer.store.clear + sign_in owner_user + end + + after { Current.user = nil } + + def save_details(attributes) + patch person_path(person), params: { return_to: "details", person: attributes } + end + + # The controller flushes (and clears) the buffer in an after_action, so copy + # the events as it flushes. + def buffered_during_request + events = [] + allow(Analytics::LifecycleBuffer).to receive(:flush) do + events.concat(Analytics::LifecycleBuffer.store) + Analytics::LifecycleBuffer.store.clear + end + yield + events + end + + it "shows every section" do + get details_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Your photo", "Your name", "About you", "Phone", "Mailing address", + "Social media", "Sectors you work in", "Arts", "Health") + expect(response.body).not_to include("person[email]", "person[filemaker_code]", "person[profile_show_email]") + end + + it "saves and returns to the profile" do + save_details(first_name: "Jordan", pronouns: "they/them", bio: "I teach art.", linked_in_url: "https://linkedin.com/in/x") + + expect(response).to redirect_to(person_path(person)) + expect(person.reload).to have_attributes(first_name: "Jordan", pronouns: "they/them", bio: "I teach art.", + linked_in_url: "https://linkedin.com/in/x") + end + + it "ignores the blank add-new rows when nothing is filled in" do + expect { + save_details( + first_name: person.first_name, + contact_methods_attributes: { "999" => { kind: "phone", value: "", contact_type: "" } }, + addresses_attributes: { "0" => { street_address: "", city: "", state: "", zip_code: "", locality: "" } }, + sectorable_items_attributes: { "0" => { sector_id: arts.id, _destroy: "1" } }, + professional_licenses_attributes: { "999" => { kind: "", number: "", issuing_state: "", expires_on: "" } } + ) + }.not_to change { [ person.contact_methods.count, person.addresses.count, person.sectorable_items.count, person.professional_licenses.count ] } + + expect(response).to redirect_to(person_path(person)) + end + + it "adds and removes sectors from the checkboxes" do + existing = create(:sectorable_item, sectorable: person, sector: arts) + + save_details(sectorable_items_attributes: { + "0" => { id: existing.id, sector_id: arts.id, _destroy: "1" }, + "1" => { sector_id: health.id, _destroy: "0" } + }) + + expect(person.reload.sectors).to contain_exactly(health) + end + + it "adds a license from the add row" do + save_details(professional_licenses_attributes: { "999" => { kind: "LMFT", number: "12345", issuing_state: "CA", expires_on: "2027-01-31" } }) + + expect(person.reload.professional_licenses.map(&:number)).to eq([ "12345" ]) + end + + it "shows a license with CE credits as read-only" do + license = create(:professional_license, person: person, number: "LOCK-1") + allow_any_instance_of(ProfessionalLicense).to receive(:used_for_ce?).and_return(true) + + get details_person_path(person) + + expect(response.body).to include("LOCK-1", "only AWBW staff can change it") + expect(response.body).not_to include(%(value="#{license.id}")) + end + + it "re-renders the page when the save fails" do + save_details(first_name: "") + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Edit my profile", "Sectors you work in") + end + + it "logs field and sector changes to the person's change log as the owner" do + events = buffered_during_request do + save_details(first_name: "Jordan", sectorable_items_attributes: { "1" => { sector_id: health.id, _destroy: "0" } }) + end + + event = events.find { |e| e[:name] == "update.person" } + expect(event).to be_present + expect(event[:user]).to eq(owner_user) + expect(event.dig(:properties, :changes, "first_name")).to include(after: "Jordan") + expect(event.dig(:properties, :association_changes)).to be_present + end + + it "is closed to other people" do + sign_in create(:user) + + get details_person_path(person) + + expect(response).to redirect_to(root_path) + end + + it "is closed in production" do + allow(Rails.env).to receive(:production?).and_return(true) + + get details_person_path(person) + + expect(response).to redirect_to(root_path) + end +end diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index 7c64948ec5..5cbd29cd45 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -26,7 +26,7 @@ get person_path(person) expect(response.body).to include("Ask for a change") - expect(response.body).to include(edit_person_path(person, anchor: "sectors")) + expect(response.body).to include(details_person_path(person, anchor: "sectors")) end it "prompts for a bio when there isn't one" do @@ -35,7 +35,7 @@ get person_path(person) expect(response.body).to include("Add a bio") - expect(response.body).to include(edit_person_path(person, anchor: "bio")) + expect(response.body).to include(details_person_path(person, anchor: "bio")) end it "lists pending change requests" do diff --git a/spec/system/person_details_spec.rb b/spec/system/person_details_spec.rb new file mode 100644 index 0000000000..95d232edec --- /dev/null +++ b/spec/system/person_details_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +RSpec.describe "Editing my profile details", type: :system, js: true do + let(:user) { create(:user, :with_person) } + let(:person) { user.person } + let!(:arts) { create(:sector, :published, name: "Arts") } + let!(:health) { create(:sector, :published, name: "Health") } + let(:age_type) { create(:category_type, :published, name: "AgeRange") } + let!(:children) { create(:category, :published, category_type: age_type, name: "Children (0-12)") } + let!(:teens) { create(:category, :published, category_type: age_type, name: "Teens (13-17)") } + let!(:old_phone) { create(:contact_method, contactable: person, value: "555-0100") } + + before do + create(:sectorable_item, sectorable: person, sector: arts) + create(:categorizable_item, categorizable: person, category: children) + sign_in user + end + + it "saves every section from the page" do + visit details_person_path(person) + + fill_in "First name", with: "Jordan" + fill_in "Last name", with: "Rivera" + fill_in "Pronouns", with: "they/them" + fill_in "How to say your name", with: "JOR-dan" + fill_in "Bio", with: "I teach art." + find("label", text: "Remove", exact_text: true).click + fill_in "Add another phone number", with: "555-0199" + fill_in "Street address", with: "1 Main St" + fill_in "City", with: "Los Angeles" + fill_in "State", with: "CA" + fill_in "Zip code", with: "90001" + select "LA City", from: "Area" + fill_in "LinkedIn", with: "https://linkedin.com/in/jordan" + find("label", text: "Arts").click + find("label", text: "Health").click + find("label", text: "Children (0-12)").click + find("label", text: "Teens (13-17)").click + fill_in "Type", with: "LMFT" + fill_in "License number", with: "12345" + click_button "Save" + + expect(page).to have_current_path(person_path(person)) + person.reload + expect(person).to have_attributes(first_name: "Jordan", last_name: "Rivera", pronouns: "they/them", + pronunciation: "JOR-dan", bio: "I teach art.", + linked_in_url: "https://linkedin.com/in/jordan") + expect(person.contact_methods.map(&:value)).to eq([ "555-0199" ]) + expect(person.addresses.map { |a| [ a.street_address, a.city, a.state, a.zip_code, a.locality ] }) + .to eq([ [ "1 Main St", "Los Angeles", "CA", "90001", "LA City" ] ]) + expect(person.sectors).to contain_exactly(health) + expect(person.age_range_categorizable_items.map(&:category)).to contain_exactly(teens) + expect(person.professional_licenses.map { |l| [ l.kind, l.number ] }).to eq([ [ "LMFT", "12345" ] ]) + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 79c06a93c6..faf0a76852 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -97,6 +97,7 @@ "app/views/organizations/edit.html.erb" => "admin-or-owner", "app/views/people/edit.html.erb" => "admin-or-owner", "app/views/people/visibility.html.erb" => "admin-or-owner", + "app/views/people/details.html.erb" => "admin-or-owner", "app/views/reports/show.html.erb" => "admin-or-owner-or-member", "app/views/workshop_logs/show.html.erb" => "admin-or-owner-or-member", From 0409494b49f1740110e645f2b0cfdbf6b3082dbe Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 07:30:30 -0400 Subject: [PATCH 08/34] Show which sector and age range is main on the edit page People can't change their main sector or age range here, so they need to see which one it is before unchecking it. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_main_badge.html.erb | 3 +++ app/views/people/details.html.erb | 8 +++++--- spec/requests/people_details_spec.rb | 13 +++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 app/views/people/_main_badge.html.erb diff --git a/app/views/people/_main_badge.html.erb b/app/views/people/_main_badge.html.erb new file mode 100644 index 0000000000..9aca8c0def --- /dev/null +++ b/app/views/people/_main_badge.html.erb @@ -0,0 +1,3 @@ + + Main + diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index 95d51cbcaf..7818bfd51b 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -10,7 +10,7 @@ Back to my profile <% end %>
-

Edit my profile

+

Edit my profile

Change anything below, then click Save. To change your email or organizations, use the buttons on your profile.

@@ -96,7 +96,7 @@

Sectors you work in

-

Check every one that fits.

+

Check every one that fits.<% if @person.sectorable_items.any?(&:is_primary?) %> Main marks your main sector. To change which one is main, ask AWBW staff.<% end %>

<% items_by_sector = @person.sectorable_items.index_by(&:sector_id) %>
<% @sectors_collection.each_with_index do |(name, sector_id), index| %> @@ -106,6 +106,7 @@ <% end %> <% end %> @@ -114,7 +115,7 @@ <% if @age_ranges_collection.present? %>

Ages you work with

-

Check every one that fits.

+

Check every one that fits.<% if @person.age_range_categorizable_items.any?(&:is_primary?) %> Main marks your main age range. To change which one is main, ask AWBW staff.<% end %>

<% items_by_category = @person.age_range_categorizable_items.index_by(&:category_id) %>
<% @age_ranges_collection.each_with_index do |(name, category_id), index| %> @@ -124,6 +125,7 @@ <% end %> <% end %> diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 02ca7a39dc..0bed090d10 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -74,6 +74,19 @@ def buffered_during_request expect(person.reload.sectors).to contain_exactly(health) end + it "marks the main sector" do + create(:sectorable_item, sectorable: person, sector: arts, is_primary: true) + create(:sectorable_item, sectorable: person, sector: health) + + get details_person_path(person) + + arts_pill = response.body[/]*>(?:(?!<\/label>).)*Arts(?:(?!<\/label>).)*<\/label>/m] + health_pill = response.body[/]*>(?:(?!<\/label>).)*Health(?:(?!<\/label>).)*<\/label>/m] + expect(arts_pill).to include("Main") + expect(health_pill).not_to include("Main") + expect(response.body).to include("marks your main sector") + end + it "adds a license from the add row" do save_details(professional_licenses_attributes: { "999" => { kind: "LMFT", number: "12345", issuing_state: "CA", expires_on: "2027-01-31" } }) From ca39272a29e586347caa99c4431b63c118e6bc1d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 07:31:53 -0400 Subject: [PATCH 09/34] Use slider switches for sectors and ages, and regroup name fields Sectors and age ranges now use the same on/off switches as the visibility page, through one shared switch partial. The name card groups pronunciation with legal first name and puts pronouns on its own row. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_main_badge.html.erb | 2 +- ...y_switch.html.erb => _switch_row.html.erb} | 12 ++++--- app/views/people/details.html.erb | 33 +++++++++---------- app/views/people/visibility.html.erb | 6 ++-- 4 files changed, 27 insertions(+), 26 deletions(-) rename app/views/people/{_visibility_switch.html.erb => _switch_row.html.erb} (52%) diff --git a/app/views/people/_main_badge.html.erb b/app/views/people/_main_badge.html.erb index 9aca8c0def..0e8ad3f880 100644 --- a/app/views/people/_main_badge.html.erb +++ b/app/views/people/_main_badge.html.erb @@ -1,3 +1,3 @@ - + Main diff --git a/app/views/people/_visibility_switch.html.erb b/app/views/people/_switch_row.html.erb similarity index 52% rename from app/views/people/_visibility_switch.html.erb rename to app/views/people/_switch_row.html.erb index 6d4dfdb0d1..a7d7ae7986 100644 --- a/app/views/people/_visibility_switch.html.erb +++ b/app/views/people/_switch_row.html.erb @@ -1,10 +1,12 @@ -<%# locals: (f:, attribute:, label:, on_text: "Shown", off_text: "Hidden") %> +<%# locals: (f:, attribute:, label:, on_text: "Shown", off_text: "Hidden", checked: nil, checked_value: "1", unchecked_value: "0", badge: nil) %> diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index 7818bfd51b..982a184830 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -2,7 +2,6 @@ <% card_class = "border-primary/10 scroll-mt-4 rounded-2xl border bg-white p-5 shadow-sm sm:p-6" %> <% heading_class = "mb-4 font-display text-2xl text-gray-900" %> <% input_class = "w-full text-lg" %> -<% pill_class = "has-checked:border-primary has-checked:bg-brand-cream has-checked:ring-primary has-checked:ring-1 flex cursor-pointer items-center gap-3 rounded-xl border border-gray-300 bg-white px-4 py-3 text-lg text-gray-800 shadow-sm transition hover:border-gray-400" %> <% checkbox_class = "text-primary h-5 w-5 rounded border-gray-300 focus:ring-blue-200" %>
@@ -22,13 +21,13 @@ <%= render "shared/form_image_field", f: f, field_name: :avatar, label: "Photo", rounded: true %>
-

Your name

+

Your name & pronouns

<%= f.input :first_name, label: "First name", input_html: { class: input_class } %> <%= f.input :last_name, label: "Last name", input_html: { class: input_class } %> - <%= f.input :pronouns, label: "Pronouns", hint: "For example: she/her, he/him, they/them", input_html: { class: input_class } %> <%= f.input :pronunciation, label: "How to say your name", hint: "For example: Oom-BEAR-toh", input_html: { class: input_class } %> <%= f.input :legal_first_name, label: "Legal first name", hint: "Only if it's different from your first name", input_html: { class: input_class } %> + <%= f.input :pronouns, label: "Pronouns", hint: "For example: she/her, he/him, they/them", input_html: { class: input_class } %>
@@ -96,18 +95,18 @@

Sectors you work in

-

Check every one that fits.<% if @person.sectorable_items.any?(&:is_primary?) %> Main marks your main sector. To change which one is main, ask AWBW staff.<% end %>

+

Turn on every one that fits.<% if @person.sectorable_items.any?(&:is_primary?) %> Main marks your main sector. To change which one is main, ask AWBW staff.<% end %>

<% items_by_sector = @person.sectorable_items.index_by(&:sector_id) %> -
+
<% @sectors_collection.each_with_index do |(name, sector_id), index| %> <% existing = items_by_sector[sector_id] %> <%= f.fields_for :sectorable_items, existing || SectorableItem.new(sector_id: sector_id), child_index: index do |sector| %> <%= sector.hidden_field :sector_id %> - +
+ <%= render "people/switch_row", f: sector, attribute: :_destroy, label: name, on_text: nil, + checked: existing.present? && !existing.marked_for_destruction?, checked_value: "0", unchecked_value: "1", + badge: (render("people/main_badge") if existing&.is_primary?) %> +
<% end %> <% end %>
@@ -115,18 +114,18 @@ <% if @age_ranges_collection.present? %>

Ages you work with

-

Check every one that fits.<% if @person.age_range_categorizable_items.any?(&:is_primary?) %> Main marks your main age range. To change which one is main, ask AWBW staff.<% end %>

+

Turn on every one that fits.<% if @person.age_range_categorizable_items.any?(&:is_primary?) %> Main marks your main age range. To change which one is main, ask AWBW staff.<% end %>

<% items_by_category = @person.age_range_categorizable_items.index_by(&:category_id) %> -
+
<% @age_ranges_collection.each_with_index do |(name, category_id), index| %> <% existing = items_by_category[category_id] %> <%= f.fields_for :age_range_categorizable_items, existing || CategorizableItem.new(category_id: category_id), child_index: index do |age| %> <%= age.hidden_field :category_id %> - +
+ <%= render "people/switch_row", f: age, attribute: :_destroy, label: name, on_text: nil, + checked: existing.present? && !existing.marked_for_destruction?, checked_value: "0", unchecked_value: "1", + badge: (render("people/main_badge") if existing&.is_primary?) %> +
<% end %> <% end %>
diff --git a/app/views/people/visibility.html.erb b/app/views/people/visibility.html.erb index 9bc4066bf4..53fd2e3ae7 100644 --- a/app/views/people/visibility.html.erb +++ b/app/views/people/visibility.html.erb @@ -35,9 +35,9 @@

Your privacy

- <%= render "people/visibility_switch", f: f, attribute: :profile_is_searchable, + <%= render "people/switch_row", f: f, attribute: :profile_is_searchable, label: "Let other AWBW facilitators find and see my profile", on_text: "Yes", off_text: "No" %> - <%= render "people/visibility_switch", f: f, attribute: :anonymous_contributions, + <%= render "people/switch_row", f: f, attribute: :anonymous_contributions, label: "Stay anonymous on stories, workshops, and variations I write", on_text: "Yes", off_text: "No" %>
@@ -46,7 +46,7 @@

<%= heading %>

<% fields.each do |attribute, label| %> - <%= render "people/visibility_switch", f: f, attribute: attribute, label: label %> + <%= render "people/switch_row", f: f, attribute: attribute, label: label %> <% end %>
From 5060f64d18f12166b54022e172c2b099019059f1 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 07:52:04 -0400 Subject: [PATCH 10/34] Let people pick their main sector and age range on the edit page A star beside each sector and age range sets the main one. It reuses the primary-tag controller, so starring a row turns it on and turning a row off clears its star. If JavaScript doesn't run, the server still rejects two main sectors. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_main_badge.html.erb | 3 --- app/views/people/_main_star.html.erb | 7 ++++++ app/views/people/_switch_row.html.erb | 27 ++++++++++++----------- app/views/people/details.html.erb | 18 +++++++++------- spec/requests/people_details_spec.rb | 31 +++++++++++++++++++++------ spec/system/person_details_spec.rb | 7 ++++-- 6 files changed, 62 insertions(+), 31 deletions(-) delete mode 100644 app/views/people/_main_badge.html.erb create mode 100644 app/views/people/_main_star.html.erb diff --git a/app/views/people/_main_badge.html.erb b/app/views/people/_main_badge.html.erb deleted file mode 100644 index 0e8ad3f880..0000000000 --- a/app/views/people/_main_badge.html.erb +++ /dev/null @@ -1,3 +0,0 @@ - - Main - diff --git a/app/views/people/_main_star.html.erb b/app/views/people/_main_star.html.erb new file mode 100644 index 0000000000..f3998cb433 --- /dev/null +++ b/app/views/people/_main_star.html.erb @@ -0,0 +1,7 @@ +<%# locals: (f:, kind:) %> + diff --git a/app/views/people/_switch_row.html.erb b/app/views/people/_switch_row.html.erb index a7d7ae7986..ccb6070b78 100644 --- a/app/views/people/_switch_row.html.erb +++ b/app/views/people/_switch_row.html.erb @@ -1,12 +1,15 @@ -<%# locals: (f:, attribute:, label:, on_text: "Shown", off_text: "Hidden", checked: nil, checked_value: "1", unchecked_value: "0", badge: nil) %> - +<%# locals: (f:, attribute:, label:, on_text: "Shown", off_text: "Hidden", checked: nil, checked_value: "1", unchecked_value: "0", leading: nil, input_data: {}) %> +
+ <%= leading %> + +
diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index 982a184830..d0ceeea89e 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -95,17 +95,18 @@

Sectors you work in

-

Turn on every one that fits.<% if @person.sectorable_items.any?(&:is_primary?) %> Main marks your main sector. To change which one is main, ask AWBW staff.<% end %>

+

Turn on every one that fits. Then tap the star next to your main one.

<% items_by_sector = @person.sectorable_items.index_by(&:sector_id) %> -
+
<% @sectors_collection.each_with_index do |(name, sector_id), index| %> <% existing = items_by_sector[sector_id] %> <%= f.fields_for :sectorable_items, existing || SectorableItem.new(sector_id: sector_id), child_index: index do |sector| %> <%= sector.hidden_field :sector_id %> -
+
<%= render "people/switch_row", f: sector, attribute: :_destroy, label: name, on_text: nil, checked: existing.present? && !existing.marked_for_destruction?, checked_value: "0", unchecked_value: "1", - badge: (render("people/main_badge") if existing&.is_primary?) %> + leading: render("people/main_star", f: sector, kind: "sector"), + input_data: { primary_tag_target: "member", action: "primary-tag#selectMember" } %>
<% end %> <% end %> @@ -114,17 +115,18 @@ <% if @age_ranges_collection.present? %>

Ages you work with

-

Turn on every one that fits.<% if @person.age_range_categorizable_items.any?(&:is_primary?) %> Main marks your main age range. To change which one is main, ask AWBW staff.<% end %>

+

Turn on every one that fits. Then tap the star next to your main one.

<% items_by_category = @person.age_range_categorizable_items.index_by(&:category_id) %> -
+
<% @age_ranges_collection.each_with_index do |(name, category_id), index| %> <% existing = items_by_category[category_id] %> <%= f.fields_for :age_range_categorizable_items, existing || CategorizableItem.new(category_id: category_id), child_index: index do |age| %> <%= age.hidden_field :category_id %> -
+
<%= render "people/switch_row", f: age, attribute: :_destroy, label: name, on_text: nil, checked: existing.present? && !existing.marked_for_destruction?, checked_value: "0", unchecked_value: "1", - badge: (render("people/main_badge") if existing&.is_primary?) %> + leading: render("people/main_star", f: age, kind: "age range"), + input_data: { primary_tag_target: "member", action: "primary-tag#selectMember" } %>
<% end %> <% end %> diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 0bed090d10..9fa0da96a5 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -74,17 +74,36 @@ def buffered_during_request expect(person.reload.sectors).to contain_exactly(health) end - it "marks the main sector" do + it "stars the current main sector" do create(:sectorable_item, sectorable: person, sector: arts, is_primary: true) create(:sectorable_item, sectorable: person, sector: health) get details_person_path(person) - arts_pill = response.body[/]*>(?:(?!<\/label>).)*Arts(?:(?!<\/label>).)*<\/label>/m] - health_pill = response.body[/]*>(?:(?!<\/label>).)*Health(?:(?!<\/label>).)*<\/label>/m] - expect(arts_pill).to include("Main") - expect(health_pill).not_to include("Main") - expect(response.body).to include("marks your main sector") + expect(response.body).to match(/]*checked="checked"[^>]*name="person\[sectorable_items_attributes\]\[0\]\[is_primary\]"/) + expect(response.body).not_to match(/]*checked="checked"[^>]*name="person\[sectorable_items_attributes\]\[1\]\[is_primary\]"/) + end + + it "moves the main sector to the newly starred one" do + old_main = create(:sectorable_item, sectorable: person, sector: arts, is_primary: true) + other = create(:sectorable_item, sectorable: person, sector: health) + + save_details(sectorable_items_attributes: { + "0" => { id: old_main.id, sector_id: arts.id, _destroy: "0", is_primary: "0" }, + "1" => { id: other.id, sector_id: health.id, _destroy: "0", is_primary: "1" } + }) + + expect(person.reload.primary_sector).to eq(health) + end + + it "rejects two main sectors" do + save_details(sectorable_items_attributes: { + "0" => { sector_id: arts.id, _destroy: "0", is_primary: "1" }, + "1" => { sector_id: health.id, _destroy: "0", is_primary: "1" } + }) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Only one sector can be marked as primary") end it "adds a license from the add row" do diff --git a/spec/system/person_details_spec.rb b/spec/system/person_details_spec.rb index 95d232edec..ecdaa3ac54 100644 --- a/spec/system/person_details_spec.rb +++ b/spec/system/person_details_spec.rb @@ -33,9 +33,10 @@ select "LA City", from: "Area" fill_in "LinkedIn", with: "https://linkedin.com/in/jordan" find("label", text: "Arts").click - find("label", text: "Health").click + find("label[title='Make this my main sector']", match: :first) # stars render + within(find("[data-primary-tag-target='chip']", text: "Health")) { find("label[title='Make this my main sector']").click } find("label", text: "Children (0-12)").click - find("label", text: "Teens (13-17)").click + within(find("[data-primary-tag-target='chip']", text: "Teens (13-17)")) { find("label[title='Make this my main age range']").click } fill_in "Type", with: "LMFT" fill_in "License number", with: "12345" click_button "Save" @@ -49,7 +50,9 @@ expect(person.addresses.map { |a| [ a.street_address, a.city, a.state, a.zip_code, a.locality ] }) .to eq([ [ "1 Main St", "Los Angeles", "CA", "90001", "LA City" ] ]) expect(person.sectors).to contain_exactly(health) + expect(person.primary_sector).to eq(health) expect(person.age_range_categorizable_items.map(&:category)).to contain_exactly(teens) + expect(person.primary_age_groups).to eq([ teens ]) expect(person.professional_licenses.map { |l| [ l.kind, l.number ] }).to eq([ [ "LMFT", "12345" ] ]) end end From 4e8aff04ac8ec5826e8caafd46fb298e735ae586 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 09:15:08 -0400 Subject: [PATCH 11/34] Bring owner-editable fields from main onto the simple edit page Main made time zone, best time to call, and program settings owner-editable, so the simple page now has them too. ADR-0006 now lists the owner pages. The full form keeps its admin wording when an admin edits their own record. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_form.html.erb | 4 +-- app/views/people/_switch_row.html.erb | 4 +-- app/views/people/details.html.erb | 32 ++++++++++++++++++++ app/views/people/edit.html.erb | 2 +- docs/adr/0006-person-profile-field-access.md | 9 ++++-- spec/requests/people_details_spec.rb | 22 ++++++++++++++ 6 files changed, 66 insertions(+), 7 deletions(-) diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 9023ca962e..1267850bc6 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -592,7 +592,7 @@
<% end %> - <% if allowed_to?(:edit_own_profile?, f.object) %> + <% if allowed_to?(:edit_own_profile?, f.object) && !allowed_to?(:manage?, Person) %>

Can't change something here? <%= link_to "Ask AWBW staff to change it", new_profile_change_request_path(person_id: f.object, field: ProfileChangeRequest::OTHER_FIELD), @@ -605,7 +605,7 @@ data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %> <% end %> - <%= link_to "Cancel", (allowed_to?(:edit_own_profile?, f.object) ? person_path(f.object) : people_path), class: button_classes(:secondary_outline), + <%= link_to "Cancel", ((allowed_to?(:edit_own_profile?, f.object) && !allowed_to?(:manage?, Person)) ? person_path(f.object) : people_path), class: button_classes(:secondary_outline), data: { action: "click->dirty-form#confirmCancel" } %> <%= f.button :submit, class: button_classes(:primary) %>

diff --git a/app/views/people/_switch_row.html.erb b/app/views/people/_switch_row.html.erb index ccb6070b78..f134de11cb 100644 --- a/app/views/people/_switch_row.html.erb +++ b/app/views/people/_switch_row.html.erb @@ -1,10 +1,10 @@ -<%# locals: (f:, attribute:, label:, on_text: "Shown", off_text: "Hidden", checked: nil, checked_value: "1", unchecked_value: "0", leading: nil, input_data: {}) %> +<%# locals: (label:, f: nil, attribute: nil, input: nil, on_text: "Shown", off_text: "Hidden", checked: nil, checked_value: "1", unchecked_value: "0", leading: nil, input_data: {}) %>
<%= leading %>

Phone

@@ -61,6 +68,8 @@
<% end %>
+ <%= f.input :best_time_to_call, label: "Best time to call", hint: "For example: weekday mornings", + wrapper_html: { class: "mt-4 sm:w-96" }, input_html: { class: input_class } %>

Mailing address

@@ -133,6 +142,29 @@
<% end %> + <% program_setting_types = (@person_categories_grouped || {}).reject { |type, _| type.name == AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE } %> + <% if program_setting_types.present? %> + <%= hidden_field_tag "person[category_ids][]", "" %> + <%= hidden_field_tag "person[managed_category_type_ids][]", "" %> + <% @managed_category_type_ids.each do |type_id| %> + <%= hidden_field_tag "person[managed_category_type_ids][]", type_id %> + <% end %> + <% program_setting_types.each do |type, categories| %> +
+

<%= type.display_label %>

+

Turn on every one that fits.

+
+ <% categories.each do |category| %> +
+ <%= render "people/switch_row", label: category.name, on_text: nil, + input: check_box_tag("person[category_ids][]", category.id, @selected_category_ids.include?(category.id), + id: "person_category_ids_#{category.id}", class: "peer sr-only") %> +
+ <% end %> +
+
+ <% end %> + <% end %>

Professional licenses

Used for continuing education credit.

diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 3aac187a28..9e88cb6b33 100644 --- a/app/views/people/edit.html.erb +++ b/app/views/people/edit.html.erb @@ -1,5 +1,5 @@ <% content_for(:page_bg_class, "admin-or-owner") %> -<% own_profile = allowed_to?(:edit_own_profile?, @person) %> +<% own_profile = allowed_to?(:edit_own_profile?, @person) && !allowed_to?(:manage?, Person) %> <% back = capture do %> <% if params[:return_to] == "onboarding" && params[:event_id].present? %>
diff --git a/docs/adr/0006-person-profile-field-access.md b/docs/adr/0006-person-profile-field-access.md index c9aa1cb010..0d956451df 100644 --- a/docs/adr/0006-person-profile-field-access.md +++ b/docs/adr/0006-person-profile-field-access.md @@ -70,8 +70,11 @@ Ask in order, and stop at the first yes: What each audience gets today, as of rubyforgood/awbw#2673. "Index" is the people index (`people_results`). Non-admins only see published people there, and on their own row the -owner sees hidden pieces like an admin does. "Profile" is `people#show`. "Edit" is the -person form. +owner sees hidden pieces like an admin does. "Profile" is `people#show`. "Edit · admin" is +the person form. "Edit · owner" is the set of pages an owner reaches from their profile: +"Edit my profile" (`people#details`), "Choose what others see" (`people#visibility`, the +`profile_show_*` toggles plus search, display-name, and anonymity preferences), and the +change-request pages for 📨 fields. Owners aren't linked to the person form. **Hidden pieces stay visible to the owner and admins.** When a `profile_show_*` toggle is off, the index marks that cell with an eye-slash, and the profile moves the piece into a @@ -128,6 +131,8 @@ membership status, staff tags, and mailing list. These places don't yet follow the tiers above: +- **Owners can't reach the email type fields.** Primary and secondary email type are + owner-editable but only appear on the person form, which owners aren't linked to. - **Unused submitted-content columns.** The person `profile_show_workshop_logs` and `profile_show_monthly_reports` columns no longer drive anything and can be dropped (the idea columns went in rubyforgood/awbw#2680). diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 9fa0da96a5..547e220b14 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -106,6 +106,28 @@ def buffered_during_request expect(response.body).to include("Only one sector can be marked as primary") end + it "saves the time zone and best time to call" do + save_details(best_time_to_call: "Weekday mornings", user_attributes: { id: owner_user.id, time_zone: "Eastern Time (US & Canada)" }) + + expect(person.reload.best_time_to_call).to eq("Weekday mornings") + expect(owner_user.reload.time_zone).to eq("Eastern Time (US & Canada)") + end + + it "saves program settings without dropping age ranges" do + settings_type = create(:category_type, :published, name: "WorkshopSetting", profile_specific: true) + setting = create(:category, :published, category_type: settings_type, name: "Shelter") + age_type = create(:category_type, :published, name: "AgeRange") + teens = create(:category, :published, category_type: age_type, name: "Teens (13-17)") + create(:categorizable_item, categorizable: person, category: teens) + + get details_person_path(person) + expect(response.body).to include("Shelter") + + save_details(category_ids: [ "", setting.id.to_s ], managed_category_type_ids: [ "", settings_type.id.to_s ]) + + expect(person.reload.categories).to contain_exactly(setting, teens) + end + it "adds a license from the add row" do save_details(professional_licenses_attributes: { "999" => { kind: "LMFT", number: "12345", issuing_state: "CA", expires_on: "2027-01-31" } }) From 022aad837123a6398c5d49309b4281690619912e Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 09:27:25 -0400 Subject: [PATCH 12/34] Show emails on the simple edit page, with a way to ask for changes Owners can now see both email addresses, set each one's type, and jump to a change request for that exact address. The secondary email gets its own request type, which admins approve like the primary one. The request form returns to the email card. Both owner forms now warn before leaving with unsaved changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../profile_change_requests_controller.rb | 11 +++-- .../profile_change_request_decorator.rb | 9 +++- app/models/profile_change_request.rb | 9 ++-- app/services/profile_change_requests/apply.rb | 15 +++++- app/views/people/details.html.erb | 47 ++++++++++++++++-- app/views/people/visibility.html.erb | 4 +- .../profile_change_requests/edit.html.erb | 5 +- .../profile_change_requests/new.html.erb | 5 +- config/features.yml | 3 +- docs/adr/0006-person-profile-field-access.md | 6 +-- .../profile_change_request_decorator_spec.rb | 5 ++ spec/factories/profile_change_requests.rb | 6 +++ spec/models/profile_change_request_spec.rb | 5 ++ spec/requests/people_details_spec.rb | 49 +++++++++++++++++++ spec/requests/profile_change_requests_spec.rb | 9 ++++ 15 files changed, 166 insertions(+), 22 deletions(-) diff --git a/app/controllers/profile_change_requests_controller.rb b/app/controllers/profile_change_requests_controller.rb index 89bd88c650..81b750249d 100644 --- a/app/controllers/profile_change_requests_controller.rb +++ b/app/controllers/profile_change_requests_controller.rb @@ -20,7 +20,7 @@ def new 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 @@ -31,7 +31,7 @@ def create if @request.save notify_admins_and_submitter(@request) - redirect_to @request.decorate.profile_return_path, 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 @@ -48,7 +48,7 @@ def update authorize! @request if @request.update(profile_change_request_params) - redirect_to @request.decorate.profile_return_path, 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 @@ -135,6 +135,11 @@ def prepare_affiliation_request @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, diff --git a/app/decorators/profile_change_request_decorator.rb b/app/decorators/profile_change_request_decorator.rb index 647b34ad54..7c20aea2c8 100644 --- a/app/decorators/profile_change_request_decorator.rb +++ b/app/decorators/profile_change_request_decorator.rb @@ -42,7 +42,10 @@ def requester_summary parts.compact_blank.join(": ") end - def profile_return_path + # `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: "emails") if return_to == "details" + h.person_path(person, anchor: ("affiliations" if field == "affiliation")) end @@ -52,6 +55,8 @@ 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" @@ -84,6 +89,8 @@ 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 diff --git a/app/models/profile_change_request.rb b/app/models/profile_change_request.rb index c7e8759f61..6ca90ad730 100644 --- a/app/models/profile_change_request.rb +++ b/app/models/profile_change_request.rb @@ -8,15 +8,17 @@ class ProfileChangeRequest < ApplicationRecord belongs_to :affiliation, optional: true OTHER_FIELD = "other".freeze - FIELDS = [ "primary_email", "organization_name", "affiliation", OTHER_FIELD ].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", OTHER_FIELD => "Profile" }.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), @@ -44,7 +46,7 @@ class ProfileChangeRequest < ApplicationRecord validates :resolution_method, inclusion: { in: RESOLUTION_METHODS }, allow_nil: 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" } @@ -104,6 +106,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 diff --git a/app/services/profile_change_requests/apply.rb b/app/services/profile_change_requests/apply.rb index 1f20260922..8cc80b2d12 100644 --- a/app/services/profile_change_requests/apply.rb +++ b/app/services/profile_change_requests/apply.rb @@ -1,5 +1,5 @@ module ProfileChangeRequests - # Applies an auto-appliable change request (primary email, organization name). + # Applies an auto-appliable change request (an email, organization name). # Affiliation requests are free-form and resolved by a manual edit instead, so # they return applied: false with a message directing the admin to edit by hand. class Apply @@ -17,6 +17,7 @@ def initialize(request:, reviewer:) def call case @request.field when "primary_email" then apply_primary_email + when "secondary_email" then apply_secondary_email when "organization_name" then apply_organization_name when "affiliation" then apply_affiliation else Result.new(applied: false, message: "This request needs a manual update.") @@ -103,6 +104,18 @@ def apply_primary_email Result.new(applied: true, message: "Confirmation email sent to #{requested}.") end + def apply_secondary_email + person = @request.person + requested = @request.requested_value + return Result.new(applied: true, message: "Secondary email already matches.") if person.email_2 == requested + + if person.update(email_2: requested) + Result.new(applied: true, message: "Secondary email set to #{requested}.") + else + Result.new(applied: false, message: person.errors.full_messages.to_sentence) + end + end + def apply_organization_name organization = @request.target_organization return Result.new(applied: false, message: "This person has no organization to rename.") unless organization diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index 0eee4f78b5..adaefd46ea 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -11,9 +11,9 @@

Edit my profile

- Change anything below, then click Save. To change your email or organizations, use the buttons on your profile. + Change anything below, then click Save. To change your organizations, use the button on your profile.

- <%= simple_form_for @person, html: { multipart: true, class: "mt-6 space-y-6" } do |f| %> + <%= simple_form_for @person, html: { multipart: true, class: "mt-6 space-y-6", data: { controller: "dirty-form" } } do |f| %> <%= hidden_field_tag :return_to, "details" %> <%= render "shared/errors", resource: @person if @person.errors.any? %>
@@ -30,6 +30,47 @@ <%= f.input :pronouns, label: "Pronouns", hint: "For example: she/her, he/him, they/them", input_html: { class: input_class } %>
+
+

Email

+

AWBW staff change your email addresses for you. You can pick each one's type.

+ <% pending_email_requests = @person.profile_change_requests.pending.where(field: ProfileChangeRequest::EMAIL_FIELDS).index_by(&:field) %> + <% emails = [ + { field: "primary_email", label: "Primary email", address: @person.user&.email || @person.email, type_attribute: :email_type, + type_selected: @person.email_type || @person.user&.email_type }, + { field: "secondary_email", label: "Secondary email", address: @person.email_2, type_attribute: :email_2_type, + type_selected: @person.email_2_type } + ] %> +
+ <% emails.each do |email| %> + <% pending = pending_email_requests[email[:field]] %> +
+
+

<%= email[:label] %>

+ <% if email[:address].present? %> +

<%= email[:address] %>

+ <% else %> +

None yet

+ <% end %> +
+ <%= link_to new_profile_change_request_path(person_id: @person, field: email[:field], return_to: "details"), + class: button_classes(:secondary_outline, size: :sm) do %> + <%= pending ? "See your request" : (email[:address].present? ? "Request a change" : "Add a #{email[:label].downcase}") %> + + <% end %> + <% if pending %> + Change to <%= pending.requested_value %> requested + <% end %> +
+
+ <% if email[:address].present? %> + <%= f.input email[:type_attribute], label: "Type", + collection: Person::CONTACT_TYPES.compact.map { |type| [ type.to_s.humanize, type ] }, + selected: email[:type_selected], include_blank: true, input_html: { class: input_class } %> + <% end %> +
+ <% end %> +
+

About you

<%= f.input :bio, as: :text, label: "Bio", hint: "A few sentences about you and your work.", @@ -206,7 +247,7 @@
- <%= link_to "Cancel", person_path(@person), class: button_classes(:secondary_outline) %> + <%= link_to "Cancel", person_path(@person), class: button_classes(:secondary_outline), data: { action: "click->dirty-form#confirmCancel" } %> <%= f.button :submit, "Save", class: button_classes(:primary, extra: "px-10 text-lg") %>
<% end %> diff --git a/app/views/people/visibility.html.erb b/app/views/people/visibility.html.erb index 53fd2e3ae7..262446503f 100644 --- a/app/views/people/visibility.html.erb +++ b/app/views/people/visibility.html.erb @@ -10,7 +10,7 @@

Turn on the parts of your profile other AWBW facilitators can see. AWBW staff can always see everything.

- <%= simple_form_for @person, html: { class: "mt-6 space-y-6" } do |f| %> + <%= simple_form_for @person, html: { class: "mt-6 space-y-6", data: { controller: "dirty-form" } } do |f| %> <%= hidden_field_tag :return_to, "visibility" %> <%= render "shared/errors", resource: @person if @person.errors.any? %>
@@ -52,7 +52,7 @@
<% end %>
- <%= link_to "Cancel", person_path(@person), class: button_classes(:secondary_outline) %> + <%= link_to "Cancel", person_path(@person), class: button_classes(:secondary_outline), data: { action: "click->dirty-form#confirmCancel" } %> <%= f.button :submit, "Save", class: button_classes(:primary, extra: "px-8 text-lg") %>
<% end %> diff --git a/app/views/profile_change_requests/edit.html.erb b/app/views/profile_change_requests/edit.html.erb index 66129d91f7..88c24504df 100644 --- a/app/views/profile_change_requests/edit.html.erb +++ b/app/views/profile_change_requests/edit.html.erb @@ -1,8 +1,8 @@ <% content_for(:page_bg_class, "admin-or-owner") %>
- <%= link_to @request.decorate.profile_return_path, class: "text-sm #{eyebrow_link_class}" do %> - Back to my profile + <%= link_to @request.decorate.profile_return_path(return_to), class: "text-sm #{eyebrow_link_class}" do %> + <%= return_to ? "Back to editing my profile" : "Back to my profile" %> <% end %>
@@ -14,6 +14,7 @@
<%= simple_form_for @request, html: { class: "space-y-5" } do |f| %> <%= render "shared/errors", resource: @request if @request.errors.any? %> + <%= hidden_field_tag :return_to, return_to if return_to %> <%= render "fields", f: f, request: @request %>
diff --git a/app/views/profile_change_requests/new.html.erb b/app/views/profile_change_requests/new.html.erb index 7aaf9b934d..b3329429fd 100644 --- a/app/views/profile_change_requests/new.html.erb +++ b/app/views/profile_change_requests/new.html.erb @@ -1,8 +1,8 @@ <% content_for(:page_bg_class, "admin-or-owner") %>
- <%= link_to @back_path || @request.decorate.profile_return_path, class: "text-sm #{eyebrow_link_class}" do %> - <%= @back_label || "Back to my profile" %> + <%= link_to @back_path || @request.decorate.profile_return_path(return_to), class: "text-sm #{eyebrow_link_class}" do %> + <%= @back_label || (return_to ? "Back to editing my profile" : "Back to my profile") %> <% end %>
@@ -24,6 +24,7 @@
<%= simple_form_for @request, url: profile_change_requests_path(person_id: @person), method: :post, html: { class: "space-y-5" } do |f| %> <%= render "shared/errors", resource: @request if @request.errors.any? %> + <%= hidden_field_tag :return_to, return_to if return_to %> <%= render "fields", f: f, request: @request %>
diff --git a/config/features.yml b/config/features.yml index 8103c88b5c..ad4f8346f8 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4607,7 +4607,8 @@ AWBW staff to change your email, your organizations, or anything else. pro_tips: - "Open it from \"My profile\" in the menu under your picture." - - "\"Edit my profile\" opens one simple page for your photo, name, bio, phone, address, social media, sectors, ages, and licenses." + - "\"Edit my profile\" opens one simple page for your photo, name, emails, bio, phone, address, social media, sectors, ages, and licenses." + - "Your emails show on that page too. Pick each one's type there, or ask AWBW staff to change the address." - "Each section of your profile has its own Edit button that jumps straight to that part of the page." - "To change an organization, pick it, then pick what to change — dates use a date picker." - "Any change you've asked for shows at the top until staff have looked at it." diff --git a/docs/adr/0006-person-profile-field-access.md b/docs/adr/0006-person-profile-field-access.md index 0d956451df..bddcde84d6 100644 --- a/docs/adr/0006-person-profile-field-access.md +++ b/docs/adr/0006-person-profile-field-access.md @@ -41,7 +41,7 @@ Ask in order, and stop at the first yes: 1. **Is it internal staff data?** → admin-only. 2. **Does it identify the person or log them in?** → request a change, or admin-set. An owner who could set it could claim someone else's records. `email_2` is matched by - `PersonMatcher` and the duplicate finders, so it's admin-set. + `PersonMatcher` and the duplicate finders, so owners request changes to it. 3. **Is it shared with other people's records?** → request a change. An organization's name is the same for everyone affiliated with it. Affiliations drive program status (ADR-0003). @@ -114,7 +114,7 @@ read-only with a "Request a change" link · 🔵 admin-only (admin styling) · | Legal first name, pronunciation, birthday, time zone | Private | — | — | — | — | 🔵 | ✅ | ✅ | | Addresses, other phones, best time to call | Private | — | — | — | — | 🔵 | ✅ | ✅ | | Program settings | Private | — | — | — | — | 🔵 | ✅ | ✅ | -| Secondary email | Admin-set | — | — | — | — | 🔵 | 🔒 | ✅ | +| Secondary email | Request a change | — | — | — | — | 🔵 | 📨 | ✅ | | Secondary email type | Private | — | — | — | — | 🔵 | ✅ (with a secondary email) | ✅ | | Membership | Private | — | filter only | — | ✅ | on the edit page | — | 🔵 | | Communications | Admin-only (owner sees their own) | — | — | — | — | 🔵 | 🔒 | ✅ | @@ -131,8 +131,6 @@ membership status, staff tags, and mailing list. These places don't yet follow the tiers above: -- **Owners can't reach the email type fields.** Primary and secondary email type are - owner-editable but only appear on the person form, which owners aren't linked to. - **Unused submitted-content columns.** The person `profile_show_workshop_logs` and `profile_show_monthly_reports` columns no longer drive anything and can be dropped (the idea columns went in rubyforgood/awbw#2680). diff --git a/spec/decorators/profile_change_request_decorator_spec.rb b/spec/decorators/profile_change_request_decorator_spec.rb index a2becc6ebb..8026e32a11 100644 --- a/spec/decorators/profile_change_request_decorator_spec.rb +++ b/spec/decorators/profile_change_request_decorator_spec.rb @@ -28,6 +28,11 @@ expect(request.profile_return_path).to eq("/people/#{request.person.to_param}#affiliations") end + it "returns to the email card on the edit page when it came from there" do + request = build_stubbed(:profile_change_request, :secondary_email).decorate + expect(request.profile_return_path("details")).to eq("/people/#{request.person.to_param}/details#emails") + end + it "returns to the top of the profile for an email request" do request = build_stubbed(:profile_change_request, :primary_email).decorate expect(request.profile_return_path).to eq("/people/#{request.person.to_param}") diff --git a/spec/factories/profile_change_requests.rb b/spec/factories/profile_change_requests.rb index 1cf4e5f606..2c1a6e6273 100644 --- a/spec/factories/profile_change_requests.rb +++ b/spec/factories/profile_change_requests.rb @@ -13,6 +13,12 @@ details { nil } end + trait :secondary_email do + field { "secondary_email" } + requested_value { "second@example.com" } + details { nil } + end + trait :organization_name do field { "organization_name" } requested_value { "New Org Name" } diff --git a/spec/models/profile_change_request_spec.rb b/spec/models/profile_change_request_spec.rb index 57640d24fd..c589b5e00c 100644 --- a/spec/models/profile_change_request_spec.rb +++ b/spec/models/profile_change_request_spec.rb @@ -29,6 +29,11 @@ expect(request.errors[:details]).to be_present end + it "requires a valid email for a secondary_email request" do + expect(build(:profile_change_request, :secondary_email, requested_value: "not-an-email")).not_to be_valid + expect(build(:profile_change_request, :secondary_email)).to be_valid + end + it "rejects an unknown field" do request = build(:profile_change_request, field: "shoe_size") expect(request).not_to be_valid diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 547e220b14..0b296540e6 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -106,6 +106,55 @@ def buffered_during_request expect(response.body).to include("Only one sector can be marked as primary") end + describe "the email card" do + it "shows each email with its type and a link to request a change" do + person.update!(email_2: "second@example.com", email_2_type: "personal") + + get details_person_path(person) + + expect(response.body).to include(owner_user.email, "second@example.com") + expect(response.body).to include("person[email_type]", "person[email_2_type]") + expect(response.body).to include("field=primary_email&person_id=#{person.id}&return_to=details") + expect(response.body).to include("field=secondary_email&person_id=#{person.id}&return_to=details") + expect(response.body).not_to include("person[email]", "person[email_2]\"") + end + + it "offers to add a secondary email when there isn't one" do + get details_person_path(person) + + expect(response.body).to include("Add a secondary email") + expect(response.body).not_to include("person[email_2_type]") + end + + it "saves both email types" do + person.update!(email_2: "second@example.com") + + save_details(email_type: "work", email_2_type: "personal") + + expect(person.reload).to have_attributes(email_type: "work", email_2_type: "personal") + end + + it "shows a pending email request" do + create(:profile_change_request, :secondary_email, person: person, requested_by: owner_user, requested_value: "new2@example.com") + + get details_person_path(person) + + expect(response.body).to include("See your request", "Change to new2@example.com requested") + end + + it "opens the request form on the chosen email and comes back to the card" do + person.update!(email_2: "second@example.com") + + get new_profile_change_request_path(person_id: person, field: "secondary_email", return_to: "details") + expect(response.body).to include("Current secondary email", "second@example.com", "Back to editing my profile") + + post profile_change_requests_path(person_id: person), params: { + return_to: "details", profile_change_request: { field: "secondary_email", requested_value: "new2@example.com" } + } + expect(response).to redirect_to(details_person_path(person, anchor: "emails")) + end + end + it "saves the time zone and best time to call" do save_details(best_time_to_call: "Weekday mornings", user_attributes: { id: owner_user.id, time_zone: "Eastern Time (US & Canada)" }) diff --git a/spec/requests/profile_change_requests_spec.rb b/spec/requests/profile_change_requests_spec.rb index 9bed17eabd..df6298c478 100644 --- a/spec/requests/profile_change_requests_spec.rb +++ b/spec/requests/profile_change_requests_spec.rb @@ -70,6 +70,15 @@ expect(person.user.reload.unconfirmed_email).to eq("changed@example.com") end + it "approves a secondary email request by setting the secondary email" do + request = create(:profile_change_request, :secondary_email, person: person, requested_value: "second@example.com") + + post approve_profile_change_request_path(request) + + expect(request.reload).to be_resolved + expect(person.reload.email_2).to eq("second@example.com") + end + it "approves an organization name request by renaming the org" do org = create(:organization, name: "Old Name") create(:affiliation, person: person, organization: org, inactive: false, start_date: 1.year.ago, end_date: nil) From 2e79ddd454525ac34735197d372f77c8853a0013 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 09:34:53 -0400 Subject: [PATCH 13/34] Open email change requests in a new tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ↗ icon promises a new tab, and opening one keeps the edit page and any unsaved changes in place. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/details.html.erb | 1 + spec/requests/people_details_spec.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index adaefd46ea..14915cf325 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -53,6 +53,7 @@ <% end %>
<%= link_to new_profile_change_request_path(person_id: @person, field: email[:field], return_to: "details"), + target: "_blank", rel: "noopener", class: button_classes(:secondary_outline, size: :sm) do %> <%= pending ? "See your request" : (email[:address].present? ? "Request a change" : "Add a #{email[:label].downcase}") %> diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 0b296540e6..83f9368c29 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -117,6 +117,7 @@ def buffered_during_request expect(response.body).to include("field=primary_email&person_id=#{person.id}&return_to=details") expect(response.body).to include("field=secondary_email&person_id=#{person.id}&return_to=details") expect(response.body).not_to include("person[email]", "person[email_2]\"") + expect(response.body).to match(/]*target="_blank"[^>]*href="[^"]*field=secondary_email/) end it "offers to add a secondary email when there isn't one" do From fa68b68e80ef5e2f5007a630cd029f76ad6d6dca Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 09:48:41 -0400 Subject: [PATCH 14/34] Let people add an organization that isn't listed yet The add-an-organization request needed an existing organization from the search, which left people stuck if theirs wasn't in the portal. They can now type its name instead. Staff create it by hand, so these requests skip Approve. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../profile_change_request_decorator.rb | 2 +- app/models/profile_change_request.rb | 18 ++++++++++--- app/services/profile_change_requests/apply.rb | 2 ++ .../profile_change_requests/_fields.html.erb | 4 ++- spec/models/profile_change_request_spec.rb | 25 +++++++++++++++++++ ...change_request_organization_picker_spec.rb | 14 +++++++++++ spec/requests/profile_change_requests_spec.rb | 10 ++++++++ 7 files changed, 69 insertions(+), 6 deletions(-) diff --git a/app/decorators/profile_change_request_decorator.rb b/app/decorators/profile_change_request_decorator.rb index 7c20aea2c8..6583430278 100644 --- a/app/decorators/profile_change_request_decorator.rb +++ b/app/decorators/profile_change_request_decorator.rb @@ -35,7 +35,7 @@ def requested_on # "Primary email: new@example.com" or "Title or role: Riverside Center". def requester_summary parts = if field == "affiliation" - [ requested_value, (affiliation&.organization || organization)&.name ] + [ requested_value, (affiliation&.organization || organization)&.name || proposed_organization_name ] else [ field_label, requested_value.presence || details&.truncate(60) ] end diff --git a/app/models/profile_change_request.rb b/app/models/profile_change_request.rb index 6ca90ad730..cf1262e02d 100644 --- a/app/models/profile_change_request.rb +++ b/app/models/profile_change_request.rb @@ -68,7 +68,12 @@ 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 @@ -84,10 +89,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") @@ -149,7 +158,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 @@ -172,7 +182,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 diff --git a/app/services/profile_change_requests/apply.rb b/app/services/profile_change_requests/apply.rb index 8cc80b2d12..21f438cfb2 100644 --- a/app/services/profile_change_requests/apply.rb +++ b/app/services/profile_change_requests/apply.rb @@ -77,6 +77,8 @@ def apply_affiliation_remove end def apply_affiliation_add + return Result.new(applied: false, message: "This organization isn't in the portal yet.") if @request.new_organization? + affiliation = @request.person.affiliations.create( organization_id: @request.organization_id, title: @request.proposed_title, diff --git a/app/views/profile_change_requests/_fields.html.erb b/app/views/profile_change_requests/_fields.html.erb index a2f832fe4b..b2708dc7ed 100644 --- a/app/views/profile_change_requests/_fields.html.erb +++ b/app/views/profile_change_requests/_fields.html.erb @@ -29,9 +29,11 @@ <% if change == ProfileChangeRequest::AFFILIATION_CHANGE_ADD %> <%= f.input :organization_id, include_blank: true, - label: "Organization", + label: "Find your organization", prompt: "Search organizations…", input_html: { class: "w-full", data: { controller: "remote-select", remote_select_model_value: "organization" } } %> + <%= f.input :proposed_organization_name, label: "Not on the list? Type the organization's name", + hint: "AWBW staff will add it to the portal.", input_html: { class: "w-full text-lg" } %> <%= f.input :proposed_title, label: "Your title or role", input_html: { class: "w-full text-lg" } %> <% else %> <%= f.hidden_field :affiliation_id %> diff --git a/spec/models/profile_change_request_spec.rb b/spec/models/profile_change_request_spec.rb index c589b5e00c..7144c27695 100644 --- a/spec/models/profile_change_request_spec.rb +++ b/spec/models/profile_change_request_spec.rb @@ -34,6 +34,31 @@ expect(build(:profile_change_request, :secondary_email)).to be_valid end + describe "adding an organization that isn't in the portal" do + let(:add) { { field: "affiliation", requested_value: ProfileChangeRequest::AFFILIATION_CHANGE_ADD, proposed_title: "Counselor", details: nil } } + + it "accepts a typed organization name instead of a picked one" do + request = build(:profile_change_request, **add, proposed_organization_name: "Hillside Shelter") + expect(request).to be_valid + expect(request).to be_new_organization + expect(request).not_to be_auto_appliable + expect(request.proposed_change_summary).to include("Hillside Shelter (not in the portal yet)") + end + + it "needs either a picked organization or a typed name" do + request = build(:profile_change_request, **add) + expect(request).not_to be_valid + expect(request.errors[:base].join).to include("type its name") + end + + it "drops the typed name when an organization is picked" do + request = build(:profile_change_request, **add, organization: create(:organization), proposed_organization_name: "Typo") + request.validate + expect(request.proposed_organization_name).to be_nil + expect(request).to be_auto_appliable + end + end + it "rejects an unknown field" do request = build(:profile_change_request, field: "shoe_size") expect(request).not_to be_valid diff --git a/spec/requests/profile_change_request_organization_picker_spec.rb b/spec/requests/profile_change_request_organization_picker_spec.rb index 8819481bc8..75b380e7ef 100644 --- a/spec/requests/profile_change_request_organization_picker_spec.rb +++ b/spec/requests/profile_change_request_organization_picker_spec.rb @@ -89,6 +89,20 @@ def new_affiliation_request(**params) expect(person.profile_change_requests.last).to have_attributes(affiliation: sunrise, proposed_end_date: Date.new(2026, 6, 30)) end + it "sends an add request for an organization that isn't listed" do + new_affiliation_request(add: 1) + expect(response.body).to include("Not on the list? Type the organization") + + post profile_change_requests_path(person_id: person), params: { + profile_change_request: { + field: "affiliation", requested_value: ProfileChangeRequest::AFFILIATION_CHANGE_ADD, + proposed_organization_name: "Hillside Shelter", proposed_title: "Counselor" + } + } + + expect(person.profile_change_requests.last).to have_attributes(proposed_organization_name: "Hillside Shelter", organization_id: nil) + end + it "starts an add request from the add card" do new_affiliation_request(add: 1) diff --git a/spec/requests/profile_change_requests_spec.rb b/spec/requests/profile_change_requests_spec.rb index df6298c478..fdb9cf6019 100644 --- a/spec/requests/profile_change_requests_spec.rb +++ b/spec/requests/profile_change_requests_spec.rb @@ -79,6 +79,16 @@ expect(person.reload.email_2).to eq("second@example.com") end + it "offers no Approve for an organization that isn't in the portal yet" do + create(:profile_change_request, person: person, requested_value: ProfileChangeRequest::AFFILIATION_CHANGE_ADD, + proposed_organization_name: "Hillside Shelter", proposed_title: "Counselor", details: nil) + + get profile_change_requests_path(status: "pending"), headers: { "Turbo-Frame" => "profile_change_requests_results" } + + expect(response.body).to include("Hillside Shelter (not in the portal yet)") + expect(response.body).not_to include(">Approve<") + end + it "approves an organization name request by renaming the org" do org = create(:organization, name: "Old Name") create(:affiliation, person: person, organization: org, inactive: false, start_date: 1.year.ago, end_date: nil) From 6496ea87101e09a7387d584ce0aba321bc452d25 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 09:49:33 -0400 Subject: [PATCH 15/34] Explain where the display name shows, and name the free-text request "Show my name as" now says it applies throughout the site. The free-text request reads "Request a profile data change" everywhere it's offered. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/decorators/profile_change_request_decorator.rb | 3 +++ app/models/profile_change_request.rb | 2 +- app/views/people/_form.html.erb | 2 +- app/views/people/_own_profile_banner.html.erb | 2 +- app/views/people/details.html.erb | 2 +- app/views/people/visibility.html.erb | 3 ++- spec/decorators/profile_change_request_decorator_spec.rb | 2 +- spec/requests/people_own_profile_hub_spec.rb | 2 +- 8 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/decorators/profile_change_request_decorator.rb b/app/decorators/profile_change_request_decorator.rb index 6583430278..af51fe6d4c 100644 --- a/app/decorators/profile_change_request_decorator.rb +++ b/app/decorators/profile_change_request_decorator.rb @@ -7,8 +7,11 @@ class ProfileChangeRequestDecorator < ApplicationDecorator 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 + OTHER_LABEL = "Request a profile data change".freeze def heading + return OTHER_LABEL if other? + AFFILIATION_CHANGE_CHOICES.dig(requested_value, :label) if field == "affiliation" end diff --git a/app/models/profile_change_request.rb b/app/models/profile_change_request.rb index cf1262e02d..067a80bd5a 100644 --- a/app/models/profile_change_request.rb +++ b/app/models/profile_change_request.rb @@ -15,7 +15,7 @@ class ProfileChangeRequest < ApplicationRecord "secondary_email" => "Secondary email", "organization_name" => "Organization name", "affiliation" => "Affiliation details", - OTHER_FIELD => "Profile" + OTHER_FIELD => "Profile data" }.freeze # Fields whose new value is concrete enough for "Approve" to write directly. AUTO_APPLIABLE_FIELDS = [ *EMAIL_FIELDS, "organization_name" ].freeze diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 1267850bc6..38d885e038 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -595,7 +595,7 @@ <% if allowed_to?(:edit_own_profile?, f.object) && !allowed_to?(:manage?, Person) %>

Can't change something here? - <%= link_to "Ask AWBW staff to change it", new_profile_change_request_path(person_id: f.object, field: ProfileChangeRequest::OTHER_FIELD), + <%= link_to ProfileChangeRequestDecorator::OTHER_LABEL, new_profile_change_request_path(person_id: f.object, field: ProfileChangeRequest::OTHER_FIELD), class: "font-medium text-blue-600 underline hover:text-blue-800" %>

<% end %> diff --git a/app/views/people/_own_profile_banner.html.erb b/app/views/people/_own_profile_banner.html.erb index 6d5fffc3bf..b0e051468e 100644 --- a/app/views/people/_own_profile_banner.html.erb +++ b/app/views/people/_own_profile_banner.html.erb @@ -20,7 +20,7 @@ Change my organizations <% end %> <%= link_to new_profile_change_request_path(person_id: person, field: ProfileChangeRequest::OTHER_FIELD), class: button_classes(:secondary_outline) do %> - Ask for another change + <%= ProfileChangeRequestDecorator::OTHER_LABEL %> <% end %> <%= link_to visibility_person_path(person), class: button_classes(:secondary_outline) do %> Choose what others see diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index 14915cf325..eddf498f41 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -254,7 +254,7 @@ <% end %>

Can't change something here? - <%= link_to "Ask AWBW staff to change it", new_profile_change_request_path(person_id: @person, field: ProfileChangeRequest::OTHER_FIELD), + <%= link_to ProfileChangeRequestDecorator::OTHER_LABEL, new_profile_change_request_path(person_id: @person, field: ProfileChangeRequest::OTHER_FIELD), class: "font-medium text-blue-600 underline hover:text-blue-800" %>

diff --git a/app/views/people/visibility.html.erb b/app/views/people/visibility.html.erb index 262446503f..ac57018802 100644 --- a/app/views/people/visibility.html.erb +++ b/app/views/people/visibility.html.erb @@ -16,7 +16,8 @@

Your name

- Show my name as + Show my name as +

This is how your name shows throughout the site.

<% current_preference = @person.display_name_preference.presence || "full_name" %> <% Person::DISPLAY_NAME_PREFERENCE_LABELS.each do |preference, label| %> diff --git a/spec/decorators/profile_change_request_decorator_spec.rb b/spec/decorators/profile_change_request_decorator_spec.rb index 8026e32a11..ab0e6ce82e 100644 --- a/spec/decorators/profile_change_request_decorator_spec.rb +++ b/spec/decorators/profile_change_request_decorator_spec.rb @@ -9,7 +9,7 @@ it "quotes the details for an other request" do request = build_stubbed(:profile_change_request, :other, details: "Fix my last name").decorate - expect(request.requester_summary).to eq("Profile: Fix my last name") + expect(request.requester_summary).to eq("Profile data: Fix my last name") end it "names the change and organization for an affiliation request" do diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index 5cbd29cd45..07c583c633 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -81,7 +81,7 @@ expect(response.body).to include("Edit my profile") expect(response.body).to match(/href="#{person_path(person)}"[^>]*>\s*]*><\/i>\s*My profile/) expect(response.body).to match(/href="#{person_path(person)}"[^>]*>Cancel/) - expect(response.body).to include("Ask AWBW staff to change it") + expect(response.body).to include("Request a profile data change") end end From bac79a8411fc04c9b24a63a41234a89839e66c7e Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 09:50:06 -0400 Subject: [PATCH 16/34] Drop the visible "Show my name as" label The Your name heading and the hint already say what the choices do. The label stays for screen readers. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/visibility.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/people/visibility.html.erb b/app/views/people/visibility.html.erb index ac57018802..e26e5e93f9 100644 --- a/app/views/people/visibility.html.erb +++ b/app/views/people/visibility.html.erb @@ -16,8 +16,8 @@

Your name

- Show my name as -

This is how your name shows throughout the site.

+ Show my name as +

This is how your name shows throughout the site.

<% current_preference = @person.display_name_preference.presence || "full_name" %> <% Person::DISPLAY_NAME_PREFERENCE_LABELS.each do |preference, label| %> From 2fc8715cb22b31a8644470b0634858e3e4df2b33 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 09:55:20 -0400 Subject: [PATCH 17/34] Let owners add addresses and pick the main one, and tighten the phone card People with more than one address had no way to add another or say which one we should mail to. A native details toggle adds one address at a time, with no JS. The existing primary-tag star picks the main address, and someone's first address is starred by default. The phone number, type, and best time to call now share one row on desktop. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../people/_details_address_fields.html.erb | 20 +++++ app/views/people/_main_star.html.erb | 4 +- app/views/people/details.html.erb | 76 +++++++++---------- spec/requests/people_details_spec.rb | 30 ++++++++ spec/system/person_details_spec.rb | 25 ++++-- 5 files changed, 108 insertions(+), 47 deletions(-) create mode 100644 app/views/people/_details_address_fields.html.erb diff --git a/app/views/people/_details_address_fields.html.erb b/app/views/people/_details_address_fields.html.erb new file mode 100644 index 0000000000..50c1990162 --- /dev/null +++ b/app/views/people/_details_address_fields.html.erb @@ -0,0 +1,20 @@ +<%# locals: (f:, main_by_default: false) %> +<% input_class = "w-full text-lg" %> +
+
+ <%= render "people/main_star", f: f, kind: "address", attribute: :primary, checked: (f.object.primary? || main_by_default) %> + <% if f.object.persisted? %> + + <% end %> +
+
+ <%= f.input :street_address, label: "Street address", wrapper_html: { class: "sm:col-span-6" }, input_html: { class: input_class } %> + <%= f.input :city, label: "City", required: false, wrapper_html: { class: "sm:col-span-3" }, input_html: { class: input_class } %> + <%= f.input :state, label: "State", required: false, wrapper_html: { class: "sm:col-span-1" }, input_html: { class: input_class } %> + <%= f.input :zip_code, label: "Zip code", wrapper_html: { class: "sm:col-span-2" }, input_html: { class: input_class } %> + <%= f.input :locality, label: "Area", required: false, collection: Address::LOCALITIES, include_blank: true, + wrapper_html: { class: "sm:col-span-3" }, input_html: { class: input_class } %> +
+
diff --git a/app/views/people/_main_star.html.erb b/app/views/people/_main_star.html.erb index f3998cb433..a1b3d00422 100644 --- a/app/views/people/_main_star.html.erb +++ b/app/views/people/_main_star.html.erb @@ -1,6 +1,6 @@ -<%# locals: (f:, kind:) %> +<%# locals: (f:, kind:, attribute: :is_primary, checked: nil) %>

Phone

-
- <%= f.simple_fields_for :contact_methods, @person.contact_methods.reject(&:inactive?) do |phone| %> -
- <%= phone.input :value, label: "Phone number", input_html: { class: input_class } %> - <%= phone.input :contact_type, label: "Type", collection: ContactMethod::CONTACT_TYPES.compact.map { |type| [ type.humanize, type ] }, - include_blank: true, input_html: { class: input_class } %> - -
- <% end %> - <%= f.simple_fields_for :contact_methods, ContactMethod.new(kind: "phone"), child_index: 999 do |phone| %> - <%= phone.hidden_field :kind %> -
- <%= phone.input :value, label: @person.contact_methods.reject(&:inactive?).any? ? "Add another phone number" : "Phone number", - required: false, input_html: { class: input_class } %> - <%= phone.input :contact_type, label: "Type", collection: ContactMethod::CONTACT_TYPES.compact.map { |type| [ type.humanize, type ] }, - include_blank: true, input_html: { class: input_class } %> -
- <% end %> +
+
+ <%= f.simple_fields_for :contact_methods, @person.contact_methods.reject(&:inactive?) do |phone| %> +
+ <%= phone.input :value, label: "Phone number", input_html: { class: input_class } %> + <%= phone.input :contact_type, label: "Type", collection: ContactMethod::CONTACT_TYPES.compact.map { |type| [ type.humanize, type ] }, + include_blank: true, input_html: { class: input_class } %> + +
+ <% end %> + <%= f.simple_fields_for :contact_methods, ContactMethod.new(kind: "phone"), child_index: 999 do |phone| %> + <%= phone.hidden_field :kind %> +
+ <%= phone.input :value, label: @person.contact_methods.reject(&:inactive?).any? ? "Add another phone number" : "Phone number", + required: false, input_html: { class: input_class } %> + <%= phone.input :contact_type, label: "Type", collection: ContactMethod::CONTACT_TYPES.compact.map { |type| [ type.humanize, type ] }, + include_blank: true, input_html: { class: input_class } %> +
+ <% end %> +
+ <%= f.input :best_time_to_call, label: "Best time to call", hint: "For example: weekday mornings", input_html: { class: input_class } %>
- <%= f.input :best_time_to_call, label: "Best time to call", hint: "For example: weekday mornings", - wrapper_html: { class: "mt-4 sm:w-96" }, input_html: { class: input_class } %>
-

Mailing address

- <% addresses = @person.addresses.reject(&:inactive?).presence || [ Address.new ] %> -
- <%= f.simple_fields_for :addresses, addresses do |address| %> -
- <%= address.input :street_address, label: "Street address", wrapper_html: { class: "sm:col-span-6" }, input_html: { class: input_class } %> - <%= address.input :city, label: "City", required: false, wrapper_html: { class: "sm:col-span-3" }, input_html: { class: input_class } %> - <%= address.input :state, label: "State", required: false, wrapper_html: { class: "sm:col-span-1" }, input_html: { class: input_class } %> - <%= address.input :zip_code, label: "Zip code", wrapper_html: { class: "sm:col-span-2" }, input_html: { class: input_class } %> - <%= address.input :locality, label: "Area", required: false, collection: Address::LOCALITIES, include_blank: true, - wrapper_html: { class: "sm:col-span-3" }, input_html: { class: input_class } %> - <% if address.object.persisted? %> - +

Mailing address

+ <% addresses = @person.addresses.reject(&:inactive?) %> +

Tap the star on the address we should mail things to.

+
+ <%= f.simple_fields_for :addresses, addresses.presence || [ Address.new ] do |address| %> + <%= render "people/details_address_fields", f: address, main_by_default: addresses.empty? %> + <% end %> + <% if addresses.any? %> +
+ + Add another address + + <%= f.simple_fields_for :addresses, Address.new, child_index: 999 do |address| %> + <%= render "people/details_address_fields", f: address %> <% end %> -
+ <% end %>
diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 83f9368c29..0275eb4c4a 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -156,6 +156,36 @@ def buffered_during_request end end + describe "addresses" do + it "offers a main-address star and a way to add another address" do + create(:address, addressable: person, locality: "LA City", primary: true) + + get details_person_path(person) + + expect(response.body).to include("Add another address", "Make this my main address") + expect(response.body).to include("person[addresses_attributes][999][street_address]") + expect(response.body).to match(/]*checked="checked"[^>]*name="person\[addresses_attributes\]\[0\]\[primary\]"/) + end + + it "stars a person's first address as main" do + get details_person_path(person) + + expect(response.body).to match(/]*checked="checked"[^>]*name="person\[addresses_attributes\]\[0\]\[primary\]"/) + end + + it "adds a second address and moves the main flag to it" do + home = create(:address, addressable: person, locality: "LA City", primary: true) + + save_details(addresses_attributes: { + "0" => { id: home.id, street_address: home.street_address, city: home.city, state: home.state, locality: home.locality, primary: "0" }, + "999" => { street_address: "2 Oak Ave", city: "Pasadena", state: "CA", zip_code: "91101", locality: "LA County", primary: "1" } + }) + + expect(person.reload.addresses.find_by(primary: true).street_address).to eq("2 Oak Ave") + expect(person.addresses.count).to eq(2) + end + end + it "saves the time zone and best time to call" do save_details(best_time_to_call: "Weekday mornings", user_attributes: { id: owner_user.id, time_zone: "Eastern Time (US & Canada)" }) diff --git a/spec/system/person_details_spec.rb b/spec/system/person_details_spec.rb index ecdaa3ac54..69f010e494 100644 --- a/spec/system/person_details_spec.rb +++ b/spec/system/person_details_spec.rb @@ -9,6 +9,7 @@ let!(:children) { create(:category, :published, category_type: age_type, name: "Children (0-12)") } let!(:teens) { create(:category, :published, category_type: age_type, name: "Teens (13-17)") } let!(:old_phone) { create(:contact_method, contactable: person, value: "555-0100") } + let!(:home) { create(:address, addressable: person, locality: "Unknown", primary: true) } before do create(:sectorable_item, sectorable: person, sector: arts) @@ -26,11 +27,21 @@ fill_in "Bio", with: "I teach art." find("label", text: "Remove", exact_text: true).click fill_in "Add another phone number", with: "555-0199" - fill_in "Street address", with: "1 Main St" - fill_in "City", with: "Los Angeles" - fill_in "State", with: "CA" - fill_in "Zip code", with: "90001" - select "LA City", from: "Area" + find("summary", text: "Add another address").click + within(all("[data-primary-tag-target='chip']", text: "Street address").last) do + fill_in "Street address", with: "9 Elm St" + fill_in "City", with: "Pasadena" + fill_in "State", with: "CA" + select "LA County", from: "Area" + find("label[title='Make this my main address']").click + end + within(all("[data-primary-tag-target='chip']", text: "Street address").first) do + fill_in "Street address", with: "1 Main St" + fill_in "City", with: "Los Angeles" + fill_in "State", with: "CA" + fill_in "Zip code", with: "90001" + select "LA City", from: "Area" + end fill_in "LinkedIn", with: "https://linkedin.com/in/jordan" find("label", text: "Arts").click find("label[title='Make this my main sector']", match: :first) # stars render @@ -47,8 +58,8 @@ pronunciation: "JOR-dan", bio: "I teach art.", linked_in_url: "https://linkedin.com/in/jordan") expect(person.contact_methods.map(&:value)).to eq([ "555-0199" ]) - expect(person.addresses.map { |a| [ a.street_address, a.city, a.state, a.zip_code, a.locality ] }) - .to eq([ [ "1 Main St", "Los Angeles", "CA", "90001", "LA City" ] ]) + expect(person.addresses.order(:id).map { |a| [ a.street_address, a.city, a.state, a.zip_code.presence, a.locality, a.primary ] }) + .to eq([ [ "1 Main St", "Los Angeles", "CA", "90001", "LA City", false ], [ "9 Elm St", "Pasadena", "CA", nil, "LA County", true ] ]) expect(person.sectors).to contain_exactly(health) expect(person.primary_sector).to eq(health) expect(person.age_range_categorizable_items.map(&:category)).to contain_exactly(teens) From 368faf0cfdb96215c39c0962817c5948ed96f529 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 09:56:59 -0400 Subject: [PATCH 18/34] Label the main-address star, add licenses like addresses, align email types An unlit star alone didn't say what it did, so it now reads "Mark as main" until picked (CSS only). Licenses use the same "Add a license" toggle as addresses. Each email's type now sits beside the address, not at the far right. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../people/_details_address_fields.html.erb | 2 +- app/views/people/_main_star.html.erb | 5 +++- app/views/people/details.html.erb | 26 ++++++++++++------- spec/requests/people_details_spec.rb | 2 +- spec/system/person_details_spec.rb | 1 + 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/app/views/people/_details_address_fields.html.erb b/app/views/people/_details_address_fields.html.erb index 50c1990162..56f5a11b7d 100644 --- a/app/views/people/_details_address_fields.html.erb +++ b/app/views/people/_details_address_fields.html.erb @@ -2,7 +2,7 @@ <% input_class = "w-full text-lg" %>
- <%= render "people/main_star", f: f, kind: "address", attribute: :primary, checked: (f.object.primary? || main_by_default) %> + <%= render "people/main_star", f: f, kind: "address", attribute: :primary, checked: (f.object.primary? || main_by_default), prompt: "Mark as main" %> <% if f.object.persisted? %>

Edit my profile

- Change anything below, then click Save. To change your organizations, use the button on your profile. + Change anything below, then click Save.

<%= simple_form_for @person, html: { multipart: true, class: "mt-6 space-y-6", data: { controller: "dirty-form" } } do |f| %> <%= hidden_field_tag :return_to, "details" %> @@ -141,6 +141,41 @@ <%= f.input :twitter_url, label: "X (Twitter)", placeholder: "https://", input_html: { class: input_class } %>
+
+

Affiliations

+

AWBW staff keep your organizations up to date. Tell us what's different and we'll change it.

+ <% affiliations = @person.affiliations.select(&:organization).sort_by { |a| [ a.active? ? 0 : 1, a.organization.name.downcase ] } %> + <% pending_affiliation_requests = @person.profile_change_requests.pending.where(field: "affiliation").index_by(&:affiliation_id) %> +
+ <% affiliations.each do |affiliation| %> + <% decorated = affiliation.decorate %> + <% pending = pending_affiliation_requests[affiliation.id] %> +
+
+

<%= affiliation.organization.name %>

+

<%= decorated.display_title %>

+

<%= decorated.status_badge %> <%= decorated.period_label %>

+
+
+ <%= link_to (pending ? edit_profile_change_request_path(pending, return_to: "details") : new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id, return_to: "details")), + target: "_blank", rel: "noopener", class: button_classes(:secondary_outline, size: :sm) do %> + <%= pending ? "See your request" : "Request a change" %> + <% end %> + <% if pending %> + Change requested + <% end %> +
+
+ <% end %> +
+ <% if affiliations.empty? %> +

No organizations yet.

+ <% end %> + <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", add: 1, return_to: "details"), + target: "_blank", rel: "noopener", class: button_classes(:secondary_outline, extra: "mt-4") do %> + Add an organization + <% end %> +

Sectors you work in

Turn on every one that fits. Then tap the star next to your main one.

diff --git a/app/views/profile_change_requests/choose_affiliation.html.erb b/app/views/profile_change_requests/choose_affiliation.html.erb index dcba454314..b46d1b2486 100644 --- a/app/views/profile_change_requests/choose_affiliation.html.erb +++ b/app/views/profile_change_requests/choose_affiliation.html.erb @@ -12,7 +12,7 @@ <% @affiliations.each do |affiliation| %> <% decorated = affiliation.decorate %> <% pending = @pending_by_affiliation[affiliation.id] %> - <% path = pending ? edit_profile_change_request_path(pending) : new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id) %> + <% path = pending ? edit_profile_change_request_path(pending, return_to: return_to) : new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id, return_to: return_to) %> <%= link_to path, class: "#{card_class} border-primary/10 hover:border-primary hover:shadow-md" do %> <%= affiliation.organization.name %> <%= decorated.display_title %> @@ -27,7 +27,7 @@ <% end %> <% end %> <% end %> - <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", add: 1), + <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", add: 1, return_to: return_to), class: "#{card_class} items-center justify-center border-2 border-dashed border-gray-300 text-center hover:border-primary" do %> Add an organization diff --git a/app/views/profile_change_requests/choose_affiliation_change.html.erb b/app/views/profile_change_requests/choose_affiliation_change.html.erb index c3297abd58..1e923e2d7d 100644 --- a/app/views/profile_change_requests/choose_affiliation_change.html.erb +++ b/app/views/profile_change_requests/choose_affiliation_change.html.erb @@ -9,7 +9,7 @@

<%= @request.affiliation_summary %>

<% ProfileChangeRequestDecorator::AFFILIATION_CHANGE_CHOICES.except(ProfileChangeRequest::AFFILIATION_CHANGE_ADD).each do |change, choice| %> - <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: @request.affiliation.id, change: change), + <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: @request.affiliation.id, change: change, return_to: return_to), class: "border-primary/10 flex items-center gap-4 rounded-2xl border bg-white p-5 shadow-sm transition hover:border-primary hover:shadow-md" do %> diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index d35c604b1a..6094c07373 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -41,10 +41,10 @@ def buffered_during_request expect(response.body).not_to include("person[email]", "person[filemaker_code]", "person[profile_show_email]") end - it "leaves Ask a question to the profile banner" do + it "leaves Ask an unrelated question to the profile banner" do get details_person_path(person) - expect(response.body).not_to include("Ask a question") + expect(response.body).not_to include("Ask an unrelated question") end it "saves and returns to the profile" do @@ -162,6 +162,39 @@ def buffered_during_request end end + describe "the affiliations card" do + let(:organization) { create(:organization, name: "Sunrise Center") } + let!(:affiliation) { create(:affiliation, person: person, organization: organization, title: "Facilitator") } + + it "lists each organization with a request-a-change button and an add button" do + get details_person_path(person) + + expect(response.body).to include("Affiliations", "Sunrise Center", "AWBW Facilitator", "Add an organization") + expect(response.body).to include("affiliation_id=#{affiliation.id}&field=affiliation&person_id=#{person.id}&return_to=details") + expect(response.body).to include("add=1&field=affiliation&person_id=#{person.id}&return_to=details") + end + + it "links an organization with a pending request to that request" do + request = create(:profile_change_request, person: person, affiliation: affiliation, requested_by: owner_user, + requested_value: "Title or role", proposed_title: "Lead", details: nil) + + get details_person_path(person) + + expect(response.body).to include(edit_profile_change_request_path(request, return_to: "details"), "See your request") + end + + it "keeps the edit page as the way back through the request steps" do + get new_profile_change_request_path(person_id: person, field: "affiliation", affiliation_id: affiliation.id, return_to: "details") + expect(response.body).to include("Back to editing my profile", "change=Start+or+end+dates&field=affiliation&person_id=#{person.id}&return_to=details") + + post profile_change_requests_path(person_id: person), params: { + return_to: "details", + profile_change_request: { field: "affiliation", affiliation_id: affiliation.id, requested_value: "Title or role", proposed_title: "Lead" } + } + expect(response).to redirect_to(details_person_path(person, anchor: "affiliations")) + end + end + describe "phones" do it "offers a main-phone star, starring the first phone when none is main" do create(:contact_method, contactable: person, value: "555-0100") diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index c2320f31f0..08fe40ad02 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -24,10 +24,10 @@ it "offers a prefilled question form that leads back to the profile" do get person_path(person) - expect(response.body).to include("Ask a question") - expect(response.body).to include(contact_us_path(subject: "Question about my profile", return_to: "person_profile").gsub("&", "&")) + expect(response.body).to include("Ask an unrelated question") + expect(response.body).to include(contact_us_path(subject: "Question for AWBW staff", return_to: "person_profile").gsub("&", "&")) - get contact_us_path(subject: "Question about my profile", return_to: "person_profile") + get contact_us_path(subject: "Question for AWBW staff", return_to: "person_profile") expect(response.body).to include("Back to my profile", %(href="#{person_path(person)}")) end From c02bebc3061e9ac63ae4a43ec880a5ce88161103 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 10:08:25 -0400 Subject: [PATCH 25/34] Put Edit my profile in the banner's button row, drop the question button Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_own_profile_banner.html.erb | 21 +++++++------------ spec/requests/people_details_spec.rb | 6 ------ spec/requests/people_own_profile_hub_spec.rb | 9 -------- 3 files changed, 8 insertions(+), 28 deletions(-) diff --git a/app/views/people/_own_profile_banner.html.erb b/app/views/people/_own_profile_banner.html.erb index a42a6f9752..5e10b280f3 100644 --- a/app/views/people/_own_profile_banner.html.erb +++ b/app/views/people/_own_profile_banner.html.erb @@ -1,18 +1,12 @@ <%# locals: (person:) %> <% pending_requests = person.profile_change_requests.pending.newest_first.includes(affiliation: :organization).decorate %>
-
-
-

This is your profile

-

To update your details, click Edit my profile.

-

AWBW staff keep your email and organizations up to date. Use the buttons below to ask us for a change, or for anything you can't change yourself.

-
- <%= link_to details_person_path(person), - class: "text-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-full bg-brand-yellow-400 px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-brand-yellow-500" do %> - Edit my profile - <% end %> +
+

This is your profile

+

To update your details, click Edit my profile.

+

AWBW staff keep your email and organizations up to date. Use the buttons below to ask us for a change, or for anything you can't change yourself.

-
+
<%= link_to visibility_person_path(person), class: button_classes(:secondary_outline) do %> Choose what others see <% end %> @@ -25,8 +19,9 @@ <%= link_to new_profile_change_request_path(person_id: person, field: ProfileChangeRequest::OTHER_FIELD), class: button_classes(:secondary_outline) do %> Ask for another change <% end %> - <%= link_to contact_us_path(subject: "Question for AWBW staff", return_to: "person_profile"), class: button_classes(:secondary_outline) do %> - Ask an unrelated question + <%= link_to details_person_path(person), + class: "text-primary inline-flex items-center justify-center gap-2 rounded-full bg-brand-yellow-400 px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-brand-yellow-500 sm:ml-auto" do %> + Edit my profile <% end %>
<% if pending_requests.any? %> diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 6094c07373..12e89a4512 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -41,12 +41,6 @@ def buffered_during_request expect(response.body).not_to include("person[email]", "person[filemaker_code]", "person[profile_show_email]") end - it "leaves Ask an unrelated question to the profile banner" do - get details_person_path(person) - - expect(response.body).not_to include("Ask an unrelated question") - end - it "saves and returns to the profile" do save_details(first_name: "Jordan", pronouns: "they/them", bio: "I teach art.", linked_in_url: "https://linkedin.com/in/x") diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index 08fe40ad02..5cbd29cd45 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -22,15 +22,6 @@ expect(response.body).to include(visibility_person_path(person)) end - it "offers a prefilled question form that leads back to the profile" do - get person_path(person) - expect(response.body).to include("Ask an unrelated question") - expect(response.body).to include(contact_us_path(subject: "Question for AWBW staff", return_to: "person_profile").gsub("&", "&")) - - get contact_us_path(subject: "Question for AWBW staff", return_to: "person_profile") - expect(response.body).to include("Back to my profile", %(href="#{person_path(person)}")) - end - it "offers to ask for a change on affiliations and to edit sectors" do get person_path(person) From 73ca9575d71e477be553aabb060281a96d0d9ac8 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 10:16:21 -0400 Subject: [PATCH 26/34] Send the hidden-pieces card's sharing link to Choose what others see Main brought back the collapsed Hidden from profile card. Its "Change sharing settings" link went to the full form, which owners aren't linked to. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_hidden_from_profile_card.html.erb | 4 +++- spec/requests/people_own_profile_hub_spec.rb | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/views/people/_hidden_from_profile_card.html.erb b/app/views/people/_hidden_from_profile_card.html.erb index 7bad371962..629e590aaf 100644 --- a/app/views/people/_hidden_from_profile_card.html.erb +++ b/app/views/people/_hidden_from_profile_card.html.erb @@ -26,7 +26,9 @@ <% if allowed_to?(:edit?, person) %>

These are turned off in the profile's sharing settings. - <%= link_to "Change sharing settings", edit_person_path(person, anchor: "profile-preferences"), class: "font-medium underline hover:text-primary" %> + <%= link_to "Change sharing settings", + (allowed_to?(:edit_own_profile?, person) ? visibility_person_path(person) : edit_person_path(person, anchor: "profile-preferences")), + class: "font-medium underline hover:text-primary" %>

<% end %> <% if contact_pieces.any? %> diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index 5cbd29cd45..eb0e6d48c8 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -22,6 +22,14 @@ expect(response.body).to include(visibility_person_path(person)) end + it "sends the hidden card's sharing link to Choose what others see" do + person.update!(profile_show_bio: false, bio: "Hidden bio") + + get person_path(person) + + expect(response.body).to match(/href="#{visibility_person_path(person)}"[^>]*>Change sharing settings/m) + end + it "offers to ask for a change on affiliations and to edit sectors" do get person_path(person) From f5091e0ee7f31172582e2b28aba5f4269c71ac29 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 10:19:41 -0400 Subject: [PATCH 27/34] Simplify the profile banner and let owners add several items at once The banner is now the heading plus two buttons, "Choose what others can see" and "Edit my data". Change requests live on the edit page next to what they change. On that page, phones, addresses, and licenses share one add pattern: chained details toggles add several before saving, and every card can be removed. Affiliations name each organization once, with its titles nested under it. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_add_more_chain.html.erb | 14 +++ .../people/_details_address_fields.html.erb | 6 +- .../people/_details_license_fields.html.erb | 15 +++ .../people/_details_phone_fields.html.erb | 10 +- app/views/people/_own_profile_banner.html.erb | 37 +++--- app/views/people/_remove_toggle.html.erb | 4 + app/views/people/details.html.erb | 109 +++++++----------- app/views/people/visibility.html.erb | 2 +- config/features.yml | 13 ++- docs/adr/0006-person-profile-field-access.md | 2 +- spec/requests/people_details_spec.rb | 55 +++++++-- spec/requests/people_own_profile_hub_spec.rb | 13 +-- spec/requests/people_visibility_spec.rb | 4 +- spec/system/person_details_spec.rb | 12 +- 14 files changed, 159 insertions(+), 137 deletions(-) create mode 100644 app/views/people/_add_more_chain.html.erb create mode 100644 app/views/people/_details_license_fields.html.erb create mode 100644 app/views/people/_remove_toggle.html.erb diff --git a/app/views/people/_add_more_chain.html.erb b/app/views/people/_add_more_chain.html.erb new file mode 100644 index 0000000000..42ae3400f0 --- /dev/null +++ b/app/views/people/_add_more_chain.html.erb @@ -0,0 +1,14 @@ +<%# locals: (f:, association:, fields_partial:, build:, label:, next_label:, index: 900, remaining: 3, first_locals: {}) %> +<%# Each opened slot reveals the next "Add another" button, so several can be added before saving — native
, no JS. %> +
+ + <%= label %> + + <%= f.simple_fields_for association, build.call, child_index: index do |new_fields| %> + <%= render fields_partial, f: new_fields, **first_locals %> + <% end %> + <% if remaining > 1 %> + <%= render "people/add_more_chain", f: f, association: association, fields_partial: fields_partial, build: build, + label: next_label, next_label: next_label, index: index + 1, remaining: remaining - 1 %> + <% end %> +
diff --git a/app/views/people/_details_address_fields.html.erb b/app/views/people/_details_address_fields.html.erb index b1938d5be3..9a9479abd9 100644 --- a/app/views/people/_details_address_fields.html.erb +++ b/app/views/people/_details_address_fields.html.erb @@ -3,11 +3,7 @@
<%= render "people/main_star", f: f, kind: "address", attribute: :primary, checked: (f.object.primary? || main_by_default), prompt: "Mark as main", inset: false %> - <% if f.object.persisted? %> - - <% end %> + <%= render "people/remove_toggle", f: f, label: "Remove this address" %>
<%= f.input :street_address, label: "Street address", wrapper_html: { class: "sm:col-span-6" }, input_html: { class: input_class } %> diff --git a/app/views/people/_details_license_fields.html.erb b/app/views/people/_details_license_fields.html.erb new file mode 100644 index 0000000000..7769c0ab52 --- /dev/null +++ b/app/views/people/_details_license_fields.html.erb @@ -0,0 +1,15 @@ +<%# locals: (f:) %> +<% input_class = "w-full text-lg" %> +
+ <% if f.object.new_record? || f.object.removable? %> +
+ <%= render "people/remove_toggle", f: f, label: "Remove this license" %> +
+ <% end %> +
+ <%= f.input :kind, label: "Type", placeholder: "For example: LMFT", input_html: { class: input_class } %> + <%= f.input :number, label: "License number", input_html: { class: input_class } %> + <%= f.input :issuing_state, label: "State", collection: us_states, include_blank: true, input_html: { class: input_class } %> + <%= f.input :expires_on, label: "Expires", as: :date, html5: true, input_html: { class: input_class } %> +
+
diff --git a/app/views/people/_details_phone_fields.html.erb b/app/views/people/_details_phone_fields.html.erb index 079587a04f..90c0d66e3b 100644 --- a/app/views/people/_details_phone_fields.html.erb +++ b/app/views/people/_details_phone_fields.html.erb @@ -1,17 +1,13 @@ -<%# locals: (f:, label: "Phone number", main_by_default: false) %> +<%# locals: (f:, main_by_default: false) %> <% input_class = "w-full text-lg" %>
<%= f.hidden_field :kind %>
<%= render "people/main_star", f: f, kind: "phone", attribute: :primary, checked: (f.object.primary? || main_by_default), prompt: "Mark as main", inset: false %> - <% if f.object.persisted? %> - - <% end %> + <%= render "people/remove_toggle", f: f, label: "Remove this phone" %>
- <%= f.input :value, label: label, required: false, input_html: { class: input_class } %> + <%= f.input :value, label: "Phone number", required: false, input_html: { class: input_class } %> <%= f.input :contact_type, label: "Type", collection: ContactMethod::CONTACT_TYPES.compact.map { |type| [ type.humanize, type ] }, include_blank: true, input_html: { class: input_class } %>
diff --git a/app/views/people/_own_profile_banner.html.erb b/app/views/people/_own_profile_banner.html.erb index 5e10b280f3..4a14dbd7ef 100644 --- a/app/views/people/_own_profile_banner.html.erb +++ b/app/views/people/_own_profile_banner.html.erb @@ -1,28 +1,21 @@ <%# locals: (person:) %> <% pending_requests = person.profile_change_requests.pending.newest_first.includes(affiliation: :organization).decorate %>
-
-

This is your profile

-

To update your details, click Edit my profile.

-

AWBW staff keep your email and organizations up to date. Use the buttons below to ask us for a change, or for anything you can't change yourself.

-
-
- <%= link_to visibility_person_path(person), class: button_classes(:secondary_outline) do %> - Choose what others see - <% end %> - <%= link_to new_profile_change_request_path(person_id: person, field: "primary_email"), class: button_classes(:secondary_outline) do %> - Change my email - <% end %> - <%= link_to new_profile_change_request_path(person_id: person, field: "affiliation"), class: button_classes(:secondary_outline) do %> - Change my organizations - <% end %> - <%= link_to new_profile_change_request_path(person_id: person, field: ProfileChangeRequest::OTHER_FIELD), class: button_classes(:secondary_outline) do %> - Ask for another change - <% end %> - <%= link_to details_person_path(person), - class: "text-primary inline-flex items-center justify-center gap-2 rounded-full bg-brand-yellow-400 px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-brand-yellow-500 sm:ml-auto" do %> - Edit my profile - <% end %> +
+
+

This is your profile

+

To update your details, click Edit my data.

+
+
+ <%= link_to visibility_person_path(person), + class: "text-primary inline-flex items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-gray-50" do %> + Choose what others can see + <% end %> + <%= link_to details_person_path(person), + class: "text-primary inline-flex items-center justify-center gap-2 rounded-full bg-brand-yellow-400 px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-brand-yellow-500" do %> + Edit my data + <% end %> +
<% if pending_requests.any? %>
diff --git a/app/views/people/_remove_toggle.html.erb b/app/views/people/_remove_toggle.html.erb new file mode 100644 index 0000000000..5a3e021c45 --- /dev/null +++ b/app/views/people/_remove_toggle.html.erb @@ -0,0 +1,4 @@ +<%# locals: (f:, label:) %> + diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index f65c70658b..7990eb1024 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -2,14 +2,13 @@ <% card_class = "border-primary/10 scroll-mt-4 rounded-2xl border bg-white p-5 shadow-sm sm:p-6" %> <% heading_class = "mb-4 font-display text-2xl text-gray-900" %> <% input_class = "w-full text-lg" %> -<% checkbox_class = "text-primary h-5 w-5 rounded border-gray-300 focus:ring-blue-200" %>
<%= link_to person_path(@person), class: "text-sm #{eyebrow_link_class}" do %> Back to my profile <% end %>
-

Edit my profile

+

Edit my data

Change anything below, then click Save.

@@ -31,7 +30,7 @@
-

Email

+

Emails

AWBW staff change your email addresses for you. You can pick each one's type.

<% pending_email_requests = @person.profile_change_requests.pending.where(field: ProfileChangeRequest::EMAIL_FIELDS).index_by(&:field) %> <% emails = [ @@ -88,47 +87,33 @@ <% end %>
-

Phone

+

Phones

<% phones = @person.contact_methods.reject(&:inactive?) %>

Tap the star on the number we should call first.

<% no_main_phone = phones.none?(&:primary?) %>
- <%= f.simple_fields_for :contact_methods, phones.presence || [ ContactMethod.new(kind: "phone") ] do |phone| %> + <%= f.simple_fields_for :contact_methods, phones do |phone| %> <%= render "people/details_phone_fields", f: phone, main_by_default: no_main_phone && phone.index.zero? %> <% end %> - <% if phones.any? %> -
- - Add another phone - - <%= f.simple_fields_for :contact_methods, ContactMethod.new(kind: "phone"), child_index: 999 do |phone| %> - <%= render "people/details_phone_fields", f: phone, label: "Another phone number" %> - <% end %> -
- <% end %> + <%= render "people/add_more_chain", f: f, association: :contact_methods, fields_partial: "people/details_phone_fields", + build: -> { ContactMethod.new(kind: "phone") }, label: phones.any? ? "Add another phone" : "Add a phone", + next_label: "Add another phone", first_locals: { main_by_default: phones.empty? } %>
<%= f.input :best_time_to_call, label: "Best time to call", hint: "For example: weekday mornings", input_html: { class: input_class } %>
-

Mailing address

+

Mailing addresses

<% addresses = @person.addresses.reject(&:inactive?) %>

Tap the star on the address we should mail things to.

- <%= f.simple_fields_for :addresses, addresses.presence || [ Address.new ] do |address| %> - <%= render "people/details_address_fields", f: address, main_by_default: addresses.empty? %> - <% end %> - <% if addresses.any? %> -
- - Add another address - - <%= f.simple_fields_for :addresses, Address.new, child_index: 999 do |address| %> - <%= render "people/details_address_fields", f: address %> - <% end %> -
+ <%= f.simple_fields_for :addresses, addresses do |address| %> + <%= render "people/details_address_fields", f: address %> <% end %> + <%= render "people/add_more_chain", f: f, association: :addresses, fields_partial: "people/details_address_fields", + build: -> { Address.new }, label: addresses.any? ? "Add another address" : "Add an address", + next_label: "Add another address", first_locals: { main_by_default: addresses.empty? } %>
@@ -147,22 +132,28 @@ <% affiliations = @person.affiliations.select(&:organization).sort_by { |a| [ a.active? ? 0 : 1, a.organization.name.downcase ] } %> <% pending_affiliation_requests = @person.profile_change_requests.pending.where(field: "affiliation").index_by(&:affiliation_id) %>
- <% affiliations.each do |affiliation| %> - <% decorated = affiliation.decorate %> - <% pending = pending_affiliation_requests[affiliation.id] %> -
-
-

<%= affiliation.organization.name %>

-

<%= decorated.display_title %>

-

<%= decorated.status_badge %> <%= decorated.period_label %>

-
-
- <%= link_to (pending ? edit_profile_change_request_path(pending, return_to: "details") : new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id, return_to: "details")), - target: "_blank", rel: "noopener", class: button_classes(:secondary_outline, size: :sm) do %> - <%= pending ? "See your request" : "Request a change" %> - <% end %> - <% if pending %> - Change requested + <% affiliations.group_by(&:organization).each do |organization, org_affiliations| %> +
+

<%= organization.name %>

+
+ <% org_affiliations.each do |affiliation| %> + <% decorated = affiliation.decorate %> + <% pending = pending_affiliation_requests[affiliation.id] %> +
+
+

<%= decorated.display_title %>

+

<%= decorated.status_badge %> <%= decorated.period_label %>

+
+
+ <%= link_to (pending ? edit_profile_change_request_path(pending, return_to: "details") : new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id, return_to: "details")), + target: "_blank", rel: "noopener", class: button_classes(:secondary_outline, size: :sm) do %> + <%= pending ? "See your request" : "Request a change" %> + <% end %> + <% if pending %> + Change requested + <% end %> +
+
<% end %>
@@ -246,17 +237,7 @@ <% @person.professional_licenses.each_with_index do |license, index| %> <% if allowed_to?(:edit?, license) %> <%= f.simple_fields_for :professional_licenses, license, child_index: index do |lf| %> -
- <%= lf.input :kind, label: "Type", placeholder: "For example: LMFT", input_html: { class: input_class } %> - <%= lf.input :number, label: "License number", input_html: { class: input_class } %> - <%= lf.input :issuing_state, label: "State", collection: us_states, include_blank: true, input_html: { class: input_class } %> - <%= lf.input :expires_on, label: "Expires", as: :date, html5: true, input_html: { class: input_class } %> -
- <% if license.removable? %> - - <% end %> + <%= render "people/details_license_fields", f: lf %> <% end %> <% else %>
@@ -268,21 +249,9 @@
<% end %> <% end %> -
- - <%= @person.professional_licenses.any? ? "Add another license" : "Add a license" %> - - <%= f.simple_fields_for :professional_licenses, ProfessionalLicense.new, child_index: 999 do |lf| %> -
-
- <%= lf.input :kind, label: "Type", placeholder: "For example: LMFT", input_html: { class: input_class } %> - <%= lf.input :number, label: "License number", input_html: { class: input_class } %> - <%= lf.input :issuing_state, label: "State", collection: us_states, include_blank: true, input_html: { class: input_class } %> - <%= lf.input :expires_on, label: "Expires", as: :date, html5: true, input_html: { class: input_class } %> -
-
- <% end %> -
+ <%= render "people/add_more_chain", f: f, association: :professional_licenses, fields_partial: "people/details_license_fields", + build: -> { ProfessionalLicense.new }, label: @person.professional_licenses.any? ? "Add another license" : "Add a license", + next_label: "Add another license" %>
diff --git a/app/views/people/visibility.html.erb b/app/views/people/visibility.html.erb index e26e5e93f9..e761bad552 100644 --- a/app/views/people/visibility.html.erb +++ b/app/views/people/visibility.html.erb @@ -6,7 +6,7 @@ Back to my profile <% end %>
-

Choose what others see

+

Choose what others can see

Turn on the parts of your profile other AWBW facilitators can see. AWBW staff can always see everything.

diff --git a/config/features.yml b/config/features.yml index ad4f8346f8..b96cfae470 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4602,14 +4602,15 @@ released_on: 2026-10-14 pr_number: 2671 summary: >- - Your own profile page is now the place to keep your details up to date. A - big "Edit my profile" button sits at the top, along with buttons to ask - AWBW staff to change your email, your organizations, or anything else. + Your own profile page is now the place to keep your details up to date. + Click "Edit my data" at the top to change them, or "Choose what others can + see" to decide what other facilitators see. pro_tips: - "Open it from \"My profile\" in the menu under your picture." - - "\"Edit my profile\" opens one simple page for your photo, name, emails, bio, phone, address, social media, sectors, ages, and licenses." - - "Your emails show on that page too. Pick each one's type there, or ask AWBW staff to change the address." + - "\"Edit my data\" opens one simple page for your photo, name, emails, phones, addresses, bio, social media, organizations, sectors, ages, and licenses." + - "Emails and organizations are kept by AWBW staff. Use \"Request a change\" beside each one, and staff will update it." + - "You can add several phones, addresses, or licenses before saving, and star the main phone and address." - "Each section of your profile has its own Edit button that jumps straight to that part of the page." - "To change an organization, pick it, then pick what to change — dates use a date picker." - "Any change you've asked for shows at the top until staff have looked at it." - - "\"Choose what others see\" opens a short page of on/off switches for what other AWBW facilitators can see, plus how your name shows." + - "\"Choose what others can see\" opens a short page of on/off switches for what other AWBW facilitators can see, plus how your name shows." diff --git a/docs/adr/0006-person-profile-field-access.md b/docs/adr/0006-person-profile-field-access.md index bddcde84d6..31fc26b486 100644 --- a/docs/adr/0006-person-profile-field-access.md +++ b/docs/adr/0006-person-profile-field-access.md @@ -72,7 +72,7 @@ What each audience gets today, as of rubyforgood/awbw#2673. "Index" is the peopl (`people_results`). Non-admins only see published people there, and on their own row the owner sees hidden pieces like an admin does. "Profile" is `people#show`. "Edit · admin" is the person form. "Edit · owner" is the set of pages an owner reaches from their profile: -"Edit my profile" (`people#details`), "Choose what others see" (`people#visibility`, the +"Edit my data" (`people#details`), "Choose what others can see" (`people#visibility`, the `profile_show_*` toggles plus search, display-name, and anonymity preferences), and the change-request pages for 📨 fields. Owners aren't linked to the person form. diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 12e89a4512..5b1a809123 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -# The simple "Edit my profile" page saves through PeopleController#update as +# The simple "Edit my data" page saves through PeopleController#update as # the owner, so it must survive the owner params filter, skip its blank add-new # rows, and land on the person's change log. RSpec.describe "Editing your own profile details", type: :request do @@ -53,10 +53,10 @@ def buffered_during_request expect { save_details( first_name: person.first_name, - contact_methods_attributes: { "999" => { kind: "phone", value: "", contact_type: "" } }, + contact_methods_attributes: { "900" => { kind: "phone", value: "", contact_type: "" } }, addresses_attributes: { "0" => { street_address: "", city: "", state: "", zip_code: "", locality: "" } }, sectorable_items_attributes: { "0" => { sector_id: arts.id, _destroy: "1" } }, - professional_licenses_attributes: { "999" => { kind: "", number: "", issuing_state: "", expires_on: "" } } + professional_licenses_attributes: { "900" => { kind: "", number: "", issuing_state: "", expires_on: "" } } ) }.not_to change { [ person.contact_methods.count, person.addresses.count, person.sectorable_items.count, person.professional_licenses.count ] } @@ -160,6 +160,15 @@ def buffered_during_request let(:organization) { create(:organization, name: "Sunrise Center") } let!(:affiliation) { create(:affiliation, person: person, organization: organization, title: "Facilitator") } + it "names an organization once with each title nested under it" do + create(:affiliation, person: person, organization: organization, title: "Program Coordinator") + + get details_person_path(person) + + expect(response.body.scan("Sunrise Center").size).to eq(1) + expect(response.body).to include("AWBW Facilitator", "Program Coordinator") + end + it "lists each organization with a request-a-change button and an add button" do get details_person_path(person) @@ -189,6 +198,32 @@ def buffered_during_request end end + describe "adding several at once" do + it "offers a chain of add buttons for phones, addresses, and licenses" do + get details_person_path(person) + + expect(response.body).to include("Add a phone", "Add an address", "Add a license", "Add another phone") + %w[contact_methods addresses professional_licenses].each do |association| + (900..902).each { |index| expect(response.body).to include("person[#{association}_attributes][#{index}]") } + end + end + + it "saves two new phones from the chain" do + save_details(contact_methods_attributes: { + "900" => { kind: "phone", value: "555-0101", primary: "1", _destroy: "0" }, + "901" => { kind: "phone", value: "555-0102", primary: "0", _destroy: "0" } + }) + + expect(person.reload.contact_methods.map(&:value)).to contain_exactly("555-0101", "555-0102") + end + + it "skips a new row marked Remove" do + save_details(contact_methods_attributes: { "900" => { kind: "phone", value: "555-0101", _destroy: "1" } }) + + expect(person.reload.contact_methods).to be_empty + end + end + describe "phones" do it "offers a main-phone star, starring the first phone when none is main" do create(:contact_method, contactable: person, value: "555-0100") @@ -197,7 +232,7 @@ def buffered_during_request expect(response.body).to include("Add another phone", "Make this my main phone", "Mark as main") expect(response.body).to match(/]*checked="checked"[^>]*name="person\[contact_methods_attributes\]\[0\]\[primary\]"/) - expect(response.body).to include("person[contact_methods_attributes][999][value]") + expect(response.body).to include("person[contact_methods_attributes][900][value]") end it "adds a second phone and moves the main flag to it" do @@ -205,7 +240,7 @@ def buffered_during_request save_details(contact_methods_attributes: { "0" => { id: first.id, kind: "phone", value: "555-0100", primary: "0" }, - "999" => { kind: "phone", value: "555-0199", primary: "1" } + "900" => { kind: "phone", value: "555-0199", primary: "1" } }) expect(person.reload.contact_methods.find_by(primary: true).value).to eq("555-0199") @@ -220,14 +255,14 @@ def buffered_during_request get details_person_path(person) expect(response.body).to include("Add another address", "Make this my main address", "Mark as main") - expect(response.body).to include("person[addresses_attributes][999][street_address]") + expect(response.body).to include("person[addresses_attributes][900][street_address]") expect(response.body).to match(/]*checked="checked"[^>]*name="person\[addresses_attributes\]\[0\]\[primary\]"/) end it "stars a person's first address as main" do get details_person_path(person) - expect(response.body).to match(/]*checked="checked"[^>]*name="person\[addresses_attributes\]\[0\]\[primary\]"/) + expect(response.body).to match(/]*checked="checked"[^>]*name="person\[addresses_attributes\]\[900\]\[primary\]"/) end it "adds a second address and moves the main flag to it" do @@ -235,7 +270,7 @@ def buffered_during_request save_details(addresses_attributes: { "0" => { id: home.id, street_address: home.street_address, city: home.city, state: home.state, locality: home.locality, primary: "0" }, - "999" => { street_address: "2 Oak Ave", city: "Pasadena", state: "CA", zip_code: "91101", locality: "LA County", primary: "1" } + "900" => { street_address: "2 Oak Ave", city: "Pasadena", state: "CA", zip_code: "91101", locality: "LA County", primary: "1" } }) expect(person.reload.addresses.find_by(primary: true).street_address).to eq("2 Oak Ave") @@ -266,7 +301,7 @@ def buffered_during_request end it "adds a license from the add row" do - save_details(professional_licenses_attributes: { "999" => { kind: "LMFT", number: "12345", issuing_state: "CA", expires_on: "2027-01-31" } }) + save_details(professional_licenses_attributes: { "900" => { kind: "LMFT", number: "12345", issuing_state: "CA", expires_on: "2027-01-31" } }) expect(person.reload.professional_licenses.map(&:number)).to eq([ "12345" ]) end @@ -285,7 +320,7 @@ def buffered_during_request save_details(first_name: "") expect(response).to have_http_status(:unprocessable_content) - expect(response.body).to include("Edit my profile", "Sectors you work in") + expect(response.body).to include("Edit my data", "Sectors you work in") end it "logs field and sector changes to the person's change log as the owner" do diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index eb0e6d48c8..cc87cd8865 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -11,18 +11,15 @@ describe "viewing their own profile" do before { sign_in owner_user } - it "shows the edit button and the ask-for-a-change buttons" do + it "shows the edit and sharing buttons" do get person_path(person) - expect(response.body).to include("This is your profile") - expect(response.body).to include("Edit my profile") - expect(response.body).to include("field=primary_email") - expect(response.body).to include("field=affiliation") - expect(response.body).to include("field=other") - expect(response.body).to include(visibility_person_path(person)) + expect(response.body).to include("This is your profile", "Edit my data", "Choose what others can see") + expect(response.body).to include(details_person_path(person), visibility_person_path(person)) + expect(response.body).not_to include("Change my email", "Ask for another change") end - it "sends the hidden card's sharing link to Choose what others see" do + it "sends the hidden card's sharing link to Choose what others can see" do person.update!(profile_show_bio: false, bio: "Hidden bio") get person_path(person) diff --git a/spec/requests/people_visibility_spec.rb b/spec/requests/people_visibility_spec.rb index 3c484bf121..68eec58216 100644 --- a/spec/requests/people_visibility_spec.rb +++ b/spec/requests/people_visibility_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -# The "Choose what others see" page saves through PeopleController#update as the +# The "Choose what others can see" page saves through PeopleController#update as the # owner, so every setting it shows must survive the owner params filter and land # on the person's change log. RSpec.describe "Choosing what others see on your profile", type: :request do @@ -88,7 +88,7 @@ def buffered_during_request save_visibility(first_name: "") expect(response).to have_http_status(:unprocessable_content) - expect(response.body).to include("Choose what others see") + expect(response.body).to include("Choose what others can see") end it "is closed to other people" do diff --git a/spec/system/person_details_spec.rb b/spec/system/person_details_spec.rb index cbb8e3cbb5..ccae849278 100644 --- a/spec/system/person_details_spec.rb +++ b/spec/system/person_details_spec.rb @@ -26,12 +26,14 @@ fill_in "How to say your name", with: "JOR-dan" fill_in "Bio", with: "I teach art." find("label", text: "Remove this phone").click - find("summary", text: "Add another phone").click - within(find("[data-primary-tag-target='chip']", text: "Another phone number")) do - fill_in "Another phone number", with: "555-0199" + first("summary", text: "Add another phone").click + within(all("#phones [data-primary-tag-target='chip']").last) do + fill_in "Phone number", with: "555-0199" find("label[title='Make this my main phone']").click end - find("summary", text: "Add another address").click + first("summary", text: "Add another phone").click + within(all("#phones [data-primary-tag-target='chip']").last) { fill_in "Phone number", with: "555-0188" } + first("summary", text: "Add another address").click within(all("[data-primary-tag-target='chip']", text: "Street address").last) do fill_in "Street address", with: "9 Elm St" fill_in "City", with: "Pasadena" @@ -62,7 +64,7 @@ expect(person).to have_attributes(first_name: "Jordan", last_name: "Rivera", pronouns: "they/them", pronunciation: "JOR-dan", bio: "I teach art.", linked_in_url: "https://linkedin.com/in/jordan") - expect(person.contact_methods.map { |phone| [ phone.value, phone.primary ] }).to eq([ [ "555-0199", true ] ]) + expect(person.contact_methods.map { |phone| [ phone.value, phone.primary ] }).to contain_exactly([ "555-0199", true ], [ "555-0188", false ]) expect(person.addresses.order(:id).map { |a| [ a.street_address, a.city, a.state, a.zip_code.presence, a.locality, a.primary ] }) .to eq([ [ "1 Main St", "Los Angeles", "CA", "90001", "LA City", false ], [ "9 Elm St", "Pasadena", "CA", nil, "LA County", true ] ]) expect(person.sectors).to contain_exactly(health) From f7bc225c3b36da4917dcef87304dbf4a7ce80297 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 10:30:18 -0400 Subject: [PATCH 28/34] Let owners see and pick the work address on each affiliation People had no way to see or set which of an organization's addresses is their work address. The edit page now shows it under each title as a plain dropdown of that organization's addresses. The policy lets owners change only that field on affiliations they already have. A missing address goes to staff through a new "Add my work address" request. Person's affiliation reject_if now applies only to new rows, so the narrowed update isn't dropped. Also: "Your activity" and consistent "you" wording on Choose what others see. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/decorators/person_decorator.rb | 2 +- .../profile_change_request_decorator.rb | 1 + app/models/person.rb | 3 +- app/models/profile_change_request.rb | 10 ++-- app/policies/person_policy.rb | 12 ++++ app/views/people/_own_profile_banner.html.erb | 2 +- app/views/people/details.html.erb | 17 ++++++ app/views/people/visibility.html.erb | 8 +-- .../profile_change_requests/_fields.html.erb | 4 +- config/features.yml | 3 +- docs/adr/0006-person-profile-field-access.md | 7 ++- spec/policies/person_policy_spec.rb | 13 ++++- spec/requests/people_details_spec.rb | 57 ++++++++++++++++++- spec/requests/people_own_profile_hub_spec.rb | 4 +- spec/requests/people_visibility_spec.rb | 4 +- spec/system/person_visibility_spec.rb | 4 +- 16 files changed, 128 insertions(+), 23 deletions(-) diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 7e5c8c8ffe..78118219d1 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -14,7 +14,7 @@ class PersonDecorator < ApplicationDecorator profile_show_phone: "Phone", profile_show_social_media: "Social media links" }, - "Your work" => { + "Your activity" => { profile_show_events_registered: "Events you attended", profile_show_workshops: "Workshops you wrote", profile_show_workshop_variations: "Workshop variations you wrote", diff --git a/app/decorators/profile_change_request_decorator.rb b/app/decorators/profile_change_request_decorator.rb index 4575cacd94..0c430c22bb 100644 --- a/app/decorators/profile_change_request_decorator.rb +++ b/app/decorators/profile_change_request_decorator.rb @@ -4,6 +4,7 @@ class ProfileChangeRequestDecorator < ApplicationDecorator 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 diff --git a/app/models/person.rb b/app/models/person.rb index d6ec744b8f..53f0edc750 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -155,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. diff --git a/app/models/profile_change_request.rb b/app/models/profile_change_request.rb index 067a80bd5a..50a6c38898 100644 --- a/app/models/profile_change_request.rb +++ b/app/models/profile_change_request.rb @@ -28,6 +28,7 @@ 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, @@ -35,11 +36,12 @@ class ProfileChangeRequest < ApplicationRecord 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 } @@ -145,7 +147,7 @@ def snapshot_target end def requires_details? - other? || (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 diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index f1505c13a4..0db52da3c0 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -12,6 +12,9 @@ class PersonPolicy < ApplicationPolicy ADMIN_ONLY_USER_FIELDS = %i[person_id email inactive super_user notes].freeze ADMIN_ONLY_SECTOR_FIELDS = %i[is_leader].freeze + # Owners can pick which of an organization's addresses is their work address on an + # affiliation they already have. Everything else about an affiliation goes through a change request. + OWNER_AFFILIATION_FIELDS = %i[id organization_address_id].freeze # People-index filters only an admin may apply. The index itself is readable by # any signed-in user while profiles preview, but each of these narrows the list @@ -222,6 +225,7 @@ def send_form_link? owner_params = permitted.except(*ADMIN_ONLY_FIELDS) owner_params[:user_attributes] = owner_params[:user_attributes].except(*ADMIN_ONLY_USER_FIELDS) if owner_params[:user_attributes] owner_params[:sectorable_items_attributes] = without_admin_sector_flags(owner_params[:sectorable_items_attributes]) if owner_params[:sectorable_items_attributes] + owner_params[:affiliations_attributes] = owner_affiliation_fields(permitted[:affiliations_attributes]) if permitted[:affiliations_attributes] owner_params end @@ -233,6 +237,14 @@ def without_admin_sector_flags(items) items.transform_values { |item| item.except(*ADMIN_ONLY_SECTOR_FIELDS) } end + # Only existing rows (with an id) — an owner can't create an affiliation this way. + def owner_affiliation_fields(items) + narrow = ->(item) { item.slice(*OWNER_AFFILIATION_FIELDS) if item[:id].present? } + return items.filter_map(&narrow) if items.is_a?(Array) + + items.transform_values(&narrow).reject { |_key, item| item.nil? } + end + def profiles_visible_to_users? authenticated? && !Rails.env.production? end diff --git a/app/views/people/_own_profile_banner.html.erb b/app/views/people/_own_profile_banner.html.erb index 4a14dbd7ef..8011b25822 100644 --- a/app/views/people/_own_profile_banner.html.erb +++ b/app/views/people/_own_profile_banner.html.erb @@ -9,7 +9,7 @@
<%= link_to visibility_person_path(person), class: "text-primary inline-flex items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-gray-50" do %> - Choose what others can see + Choose what others see <% end %> <%= link_to details_person_path(person), class: "text-primary inline-flex items-center justify-center gap-2 rounded-full bg-brand-yellow-400 px-6 py-3 text-lg font-semibold shadow-sm transition hover:bg-brand-yellow-500" do %> diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index 7990eb1024..655c3ad27d 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -154,6 +154,23 @@ <% end %>
+ <% work_addresses = organization.addresses.select { |address| !address.inactive? || address.id == affiliation.organization_address_id } %> +
+ <% if work_addresses.any? %> + <%= f.simple_fields_for :affiliations, affiliation, child_index: affiliation.id do |af| %> + <%= af.input :organization_address_id, label: "Your work address", required: false, + collection: work_addresses.map { |address| [ address.name, address.id ] }, + include_blank: "Not picked yet", wrapper_html: { class: "sm:max-w-xl" }, input_html: { class: input_class } %> + <% end %> + <% else %> +

We don't have an address on file for <%= organization.name %> yet.

+ <% end %> + <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id, + change: ProfileChangeRequest::AFFILIATION_CHANGE_WORK_ADDRESS, return_to: "details"), + target: "_blank", rel: "noopener", class: "mt-1 inline-flex items-center gap-1 text-sm font-medium text-blue-600 underline hover:text-blue-800" do %> + <%= work_addresses.any? ? "Address not listed? Tell us" : "Tell us your work address" %> + <% end %> +
<% end %>
diff --git a/app/views/people/visibility.html.erb b/app/views/people/visibility.html.erb index e761bad552..6c445c2a87 100644 --- a/app/views/people/visibility.html.erb +++ b/app/views/people/visibility.html.erb @@ -6,7 +6,7 @@ Back to my profile <% end %>
-

Choose what others can see

+

Choose what others see

Turn on the parts of your profile other AWBW facilitators can see. AWBW staff can always see everything.

@@ -16,7 +16,7 @@

Your name

- Show my name as + Show your name as

This is how your name shows throughout the site.

<% current_preference = @person.display_name_preference.presence || "full_name" %> @@ -37,9 +37,9 @@

Your privacy

<%= render "people/switch_row", f: f, attribute: :profile_is_searchable, - label: "Let other AWBW facilitators find and see my profile", on_text: "Yes", off_text: "No" %> + label: "Let other AWBW facilitators find and see your profile", on_text: "Yes", off_text: "No" %> <%= render "people/switch_row", f: f, attribute: :anonymous_contributions, - label: "Stay anonymous on stories, workshops, and variations I write", on_text: "Yes", off_text: "No" %> + label: "Stay anonymous on stories, workshops, and variations you write", on_text: "Yes", off_text: "No" %>
<% PersonDecorator::PROFILE_VISIBILITY_GROUPS.each do |heading, fields| %> diff --git a/app/views/profile_change_requests/_fields.html.erb b/app/views/profile_change_requests/_fields.html.erb index b2708dc7ed..dab6a14582 100644 --- a/app/views/profile_change_requests/_fields.html.erb +++ b/app/views/profile_change_requests/_fields.html.erb @@ -57,7 +57,9 @@ <% elsif change == ProfileChangeRequest::AFFILIATION_CHANGE_REMOVE %>

We'll mark <%= current&.organization&.name %> as an organization you no longer work with.

<% end %> - <% if change == ProfileChangeRequest::AFFILIATION_CHANGE_OTHER %> + <% if change == ProfileChangeRequest::AFFILIATION_CHANGE_WORK_ADDRESS %> + <%= f.input :details, as: :text, label: "Your work address", hint: "Street, city, state, and zip code.", input_html: { rows: 3, class: "w-full text-lg" } %> + <% elsif change == ProfileChangeRequest::AFFILIATION_CHANGE_OTHER %> <%= f.input :details, as: :text, label: "What would you like us to change?", input_html: { rows: 5, class: "w-full text-lg" } %> <% else %> <%= f.input :details, as: :text, label: "Anything else we should know? (optional)", input_html: { rows: 3, class: "w-full" } %> diff --git a/config/features.yml b/config/features.yml index b96cfae470..db459d611a 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4610,7 +4610,8 @@ - "\"Edit my data\" opens one simple page for your photo, name, emails, phones, addresses, bio, social media, organizations, sectors, ages, and licenses." - "Emails and organizations are kept by AWBW staff. Use \"Request a change\" beside each one, and staff will update it." - "You can add several phones, addresses, or licenses before saving, and star the main phone and address." + - "Under each organization, pick which of its addresses is your work address. If yours isn't listed, tell us and staff will add it." - "Each section of your profile has its own Edit button that jumps straight to that part of the page." - "To change an organization, pick it, then pick what to change — dates use a date picker." - "Any change you've asked for shows at the top until staff have looked at it." - - "\"Choose what others can see\" opens a short page of on/off switches for what other AWBW facilitators can see, plus how your name shows." + - "\"Choose what others see\" opens a short page of on/off switches for what other AWBW facilitators can see, plus how your name shows." diff --git a/docs/adr/0006-person-profile-field-access.md b/docs/adr/0006-person-profile-field-access.md index 31fc26b486..3a2e061f52 100644 --- a/docs/adr/0006-person-profile-field-access.md +++ b/docs/adr/0006-person-profile-field-access.md @@ -51,7 +51,9 @@ Ask in order, and stop at the first yes: - **The policy is a denylist.** For non-admins, `PersonPolicy`'s `params_filter` permits the full list and then strips `ADMIN_ONLY_FIELDS`, `ADMIN_ONLY_USER_FIELDS` (login account), and - `ADMIN_ONLY_SECTOR_FIELDS` (the leader flag). Request-a-change, admin-set, and admin-only fields all go on + `ADMIN_ONLY_SECTOR_FIELDS` (the leader flag). Affiliations are the one + narrowed exception: owners keep only `OWNER_AFFILIATION_FIELDS` (the work address) on rows + they already have. Request-a-change, admin-set, and admin-only fields all go on these lists. - **Adding a field to the permit list makes it owner-editable by default**, so classify it here and add it to the right strip list in the same change. @@ -72,7 +74,7 @@ What each audience gets today, as of rubyforgood/awbw#2673. "Index" is the peopl (`people_results`). Non-admins only see published people there, and on their own row the owner sees hidden pieces like an admin does. "Profile" is `people#show`. "Edit · admin" is the person form. "Edit · owner" is the set of pages an owner reaches from their profile: -"Edit my data" (`people#details`), "Choose what others can see" (`people#visibility`, the +"Edit my data" (`people#details`), "Choose what others see" (`people#visibility`, the `profile_show_*` toggles plus search, display-name, and anonymity preferences), and the change-request pages for 📨 fields. Owners aren't linked to the person form. @@ -105,6 +107,7 @@ read-only with a "Request a change" link · 🔵 admin-only (admin styling) · | Age ranges, incl. primary ⭐ | Shared by choice | 🔀 primary | primary | 🔀 | 👁 | 👁 | ✅ | ✅ | | Professional licenses | Shared by choice (credentials suffix) | — | — | 🔀 suffix | 👁 suffix | 🔵 full list | ✅, 🔒 once tied to CE | ✅ | | Affiliations, organization name | Request a change | 🔀 | ✅ | 🔀 | 👁 | 👁 | 📨 | ✅ | +| Work address (an affiliation's organization address) | Private | — | — | — | — | 🔵 | ✅ pick from the org's addresses, 📨 if missing | ✅ | | Facilitator since (derived) | Shared by choice | 🔀 year | year | 🔀 | 👁 | 👁 | 🔒 | 🔒 | | Member since (override) | Admin-set | as the year | as the year | via facilitator since | via facilitator since | ✅ | — | 🔵 with `?admin` | | Authored work, registrations | Shared by choice | — | admin quick links | 🔀 | 👁 | 👁 | — | — | diff --git a/spec/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index 8e377284be..58105084c7 100644 --- a/spec/policies/person_policy_spec.rb +++ b/spec/policies/person_policy_spec.rb @@ -427,10 +427,21 @@ def write_params_for(user) expect(filtered.keys).to contain_exactly( "first_name", "email_type", "email_2_type", "sectorable_items_attributes", - "user_attributes", "professional_licenses_attributes" + "user_attributes", "professional_licenses_attributes", "affiliations_attributes" ) end + it "narrows an owner's affiliation changes to the work address on existing rows" do + submitted = ActionController::Parameters.new(affiliations_attributes: { + "0" => { id: "1", title: "Changed title", organization_address_id: "7" }, + "1" => { organization_id: "9", title: "New row" } + }) + + filtered = policy_for(record: owned_person, user: owner_user).apply_scope(submitted, type: :action_controller_params) + + expect(filtered[:affiliations_attributes].to_h).to eq("0" => { "id" => "1", "organization_address_id" => "7" }) + end + it "strips admin-only user attributes while retaining the rest" do expect(write_params_for(owner_user)[:user_attributes].to_h) .to eq("id" => "1", "phone" => "555-0100", "time_zone" => "Hawaii") diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 5b1a809123..8d6a1369b6 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -160,12 +160,67 @@ def buffered_during_request let(:organization) { create(:organization, name: "Sunrise Center") } let!(:affiliation) { create(:affiliation, person: person, organization: organization, title: "Facilitator") } + describe "work address" do + let!(:office) { create(:address, addressable: organization, street_address: "1 Office Way", city: "Los Angeles", state: "CA", locality: "LA City") } + + it "shows a work address picker from the organization's addresses and a way to report a missing one" do + get details_person_path(person) + + expect(response.body).to include("Your work address", "1 Office Way") + expect(response.body).to include("person[affiliations_attributes][#{affiliation.id}][organization_address_id]") + expect(response.body).to include("change=Work+address") + end + + it "lets the owner pick it, and nothing else about the affiliation" do + save_details(affiliations_attributes: { + affiliation.id.to_s => { id: affiliation.id, organization_address_id: office.id, title: "Director", end_date: "2020-01-01" }, + "1" => { organization_id: create(:organization).id, title: "New role" } + }) + + affiliation.reload + expect(affiliation.organization_address).to eq(office) + expect(affiliation.title).to eq("Facilitator") + expect(affiliation.end_date).to be_nil + expect(person.affiliations.count).to eq(1) + end + + it "rejects an address from a different organization" do + elsewhere = create(:address, addressable: create(:organization), locality: "LA City") + + save_details(affiliations_attributes: { affiliation.id.to_s => { id: affiliation.id, organization_address_id: elsewhere.id } }) + + expect(response).to have_http_status(:unprocessable_content) + expect(affiliation.reload.organization_address).to be_nil + end + + it "asks for the address when it isn't listed" do + get new_profile_change_request_path(person_id: person, field: "affiliation", affiliation_id: affiliation.id, + change: ProfileChangeRequest::AFFILIATION_CHANGE_WORK_ADDRESS, return_to: "details") + expect(response.body).to include("Add my work address", "Your work address") + + post profile_change_requests_path(person_id: person), params: { + return_to: "details", + profile_change_request: { field: "affiliation", affiliation_id: affiliation.id, + requested_value: ProfileChangeRequest::AFFILIATION_CHANGE_WORK_ADDRESS, details: "" } + } + expect(response).to have_http_status(:unprocessable_content) + + post profile_change_requests_path(person_id: person), params: { + return_to: "details", + profile_change_request: { field: "affiliation", affiliation_id: affiliation.id, + requested_value: ProfileChangeRequest::AFFILIATION_CHANGE_WORK_ADDRESS, details: "9 Elm St, Pasadena, CA" } + } + expect(person.profile_change_requests.last).to have_attributes(requested_value: "Work address", details: "9 Elm St, Pasadena, CA") + expect(person.profile_change_requests.last).not_to be_auto_appliable + end + end + it "names an organization once with each title nested under it" do create(:affiliation, person: person, organization: organization, title: "Program Coordinator") get details_person_path(person) - expect(response.body.scan("Sunrise Center").size).to eq(1) + expect(response.body.scan(%r{>Sunrise Center

}).size).to eq(1) expect(response.body).to include("AWBW Facilitator", "Program Coordinator") end diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index cc87cd8865..8012a72743 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -14,12 +14,12 @@ it "shows the edit and sharing buttons" do get person_path(person) - expect(response.body).to include("This is your profile", "Edit my data", "Choose what others can see") + expect(response.body).to include("This is your profile", "Edit my data", "Choose what others see") expect(response.body).to include(details_person_path(person), visibility_person_path(person)) expect(response.body).not_to include("Change my email", "Ask for another change") end - it "sends the hidden card's sharing link to Choose what others can see" do + it "sends the hidden card's sharing link to Choose what others see" do person.update!(profile_show_bio: false, bio: "Hidden bio") get person_path(person) diff --git a/spec/requests/people_visibility_spec.rb b/spec/requests/people_visibility_spec.rb index 68eec58216..3c484bf121 100644 --- a/spec/requests/people_visibility_spec.rb +++ b/spec/requests/people_visibility_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -# The "Choose what others can see" page saves through PeopleController#update as the +# The "Choose what others see" page saves through PeopleController#update as the # owner, so every setting it shows must survive the owner params filter and land # on the person's change log. RSpec.describe "Choosing what others see on your profile", type: :request do @@ -88,7 +88,7 @@ def buffered_during_request save_visibility(first_name: "") expect(response).to have_http_status(:unprocessable_content) - expect(response.body).to include("Choose what others can see") + expect(response.body).to include("Choose what others see") end it "is closed to other people" do diff --git a/spec/system/person_visibility_spec.rb b/spec/system/person_visibility_spec.rb index 91ffe4f125..940fa8ba78 100644 --- a/spec/system/person_visibility_spec.rb +++ b/spec/system/person_visibility_spec.rb @@ -8,8 +8,8 @@ it "saves every switch and the name choice from the page" do labels = [ - "Let other AWBW facilitators find and see my profile", - "Stay anonymous on stories, workshops, and variations I write" + "Let other AWBW facilitators find and see your profile", + "Stay anonymous on stories, workshops, and variations you write" ] + PersonDecorator::PROFILE_VISIBILITY_GROUPS.values.flat_map(&:values) attributes = %i[profile_is_searchable anonymous_contributions] + PersonDecorator::PROFILE_VISIBILITY_GROUPS.values.flat_map(&:keys) From 07548b6cb16c1e0919248fd063b501b373f109bd Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 14 Oct 2026 11:16:37 -0400 Subject: [PATCH 29/34] Prompt for a hidden bio inside the Hidden from profile card (#2688) An owner who toggled their bio off still saw a standalone "Add a bio" section, as if a bio would show publicly. The prompt now sits with the other hidden pieces so the page reflects what others will actually see. Co-authored-by: Claude Opus 5.5 (1M context) --- app/views/people/_hidden_from_profile_card.html.erb | 8 +++++++- app/views/people/profile_pieces/_bio_prompt.html.erb | 4 ++++ app/views/people/show.html.erb | 9 +++------ spec/requests/people_own_profile_hub_spec.rb | 10 ++++++++++ 4 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 app/views/people/profile_pieces/_bio_prompt.html.erb diff --git a/app/views/people/_hidden_from_profile_card.html.erb b/app/views/people/_hidden_from_profile_card.html.erb index 629e590aaf..a677ce6f27 100644 --- a/app/views/people/_hidden_from_profile_card.html.erb +++ b/app/views/people/_hidden_from_profile_card.html.erb @@ -1,6 +1,7 @@ <%# Profile pieces the person turned off, shown open (and collapsible) to them and admins. %> <% pieces = person.hidden_profile_pieces %> -<% return if pieces.empty? %> +<% bio_prompt = !person.profile_show_bio? && person.bio.blank? && allowed_to?(:edit_own_profile?, person) %> +<% return if pieces.empty? && !bio_prompt %> <% contact_pieces = pieces & %i[profile_show_credentials profile_show_pronouns profile_show_member_since profile_show_email profile_show_phone profile_show_social_media] %> <% chip_class = "inline-flex items-center gap-1.5 rounded-full border border-black/5 bg-white px-3 py-1 text-gray-700" %>
Bio <%= render "people/profile_pieces/bio", person: person %>
+ <% elsif bio_prompt %> +
+

Bio

+ <%= render "people/profile_pieces/bio_prompt", person: person %> +
<% end %> <% %i[workshops workshop_variations stories resources].each do |section| %> <% if pieces.include?(:"profile_show_#{section}") %> diff --git a/app/views/people/profile_pieces/_bio_prompt.html.erb b/app/views/people/profile_pieces/_bio_prompt.html.erb new file mode 100644 index 0000000000..3e7fad770b --- /dev/null +++ b/app/views/people/profile_pieces/_bio_prompt.html.erb @@ -0,0 +1,4 @@ +

You haven't added a bio yet. A few sentences about you and your work helps others get to know you.

+
+ <%= render "people/profile_section_action", person: person, anchor: "bio", label: "Add a bio" %> +
diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index a784d09c0c..256b26f55e 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -215,13 +215,10 @@
<%= render "people/profile_pieces/bio", person: @person %>
- <% elsif @person.bio.blank? && own_profile %> + <% elsif @person.bio.blank? && @person.profile_show_bio? && own_profile %>
-

Bio

-

You haven't added a bio yet. A few sentences about you and your work helps others get to know you.

-
- <%= render "people/profile_section_action", person: @person, anchor: "bio", label: "Add a bio" %> -
+

Bio

+ <%= render "people/profile_pieces/bio_prompt", person: @person %>
<% end %> diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index 8012a72743..7450b0a947 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -43,6 +43,16 @@ expect(response.body).to include(details_person_path(person, anchor: "bio")) end + it "prompts for a hidden bio inside the hidden card, not as its own section" do + person.update!(bio: nil, profile_show_bio: false) + + get person_path(person) + + page = Capybara.string(response.body) + expect(page).to have_css("#hidden_from_profile", text: "Add a bio") + expect(page).to have_text("Add a bio", count: 1) + end + it "lists pending change requests" do request = create(:profile_change_request, :primary_email, person: person, requested_by: owner_user, requested_value: "new@example.com") From 2bbc36408cbcb1a7326467ee132e81765c43bd83 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 11:20:36 -0400 Subject: [PATCH 30/34] Fold the blank-bio prompt into Hidden from profile whatever the toggle A blank bio shows nothing to others even with sharing on, so a standalone "Add a bio" card still read as a public section. The prompt now always sits in the owner-and-admin card. The card's "turned off in sharing settings" note shows only when something really is turned off. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_hidden_from_profile_card.html.erb | 5 +++-- app/views/people/show.html.erb | 5 ----- spec/requests/people_own_profile_hub_spec.rb | 10 ++++++++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/views/people/_hidden_from_profile_card.html.erb b/app/views/people/_hidden_from_profile_card.html.erb index a677ce6f27..52cb60a22e 100644 --- a/app/views/people/_hidden_from_profile_card.html.erb +++ b/app/views/people/_hidden_from_profile_card.html.erb @@ -1,6 +1,7 @@ <%# Profile pieces the person turned off, shown open (and collapsible) to them and admins. %> <% pieces = person.hidden_profile_pieces %> -<% bio_prompt = !person.profile_show_bio? && person.bio.blank? && allowed_to?(:edit_own_profile?, person) %> +<%# A blank bio shows nothing to others whatever the toggle, so its prompt lives here too. %> +<% bio_prompt = person.bio.blank? && allowed_to?(:edit_own_profile?, person) %> <% return if pieces.empty? && !bio_prompt %> <% contact_pieces = pieces & %i[profile_show_credentials profile_show_pronouns profile_show_member_since profile_show_email profile_show_phone profile_show_social_media] %> <% chip_class = "inline-flex items-center gap-1.5 rounded-full border border-black/5 bg-white px-3 py-1 text-gray-700" %> @@ -24,7 +25,7 @@
- <% if allowed_to?(:edit?, person) %> + <% if pieces.any? && allowed_to?(:edit?, person) %>

These are turned off in the profile's sharing settings. <%= link_to "Change sharing settings", diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index 256b26f55e..9e3e35842e 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -215,11 +215,6 @@

<%= render "people/profile_pieces/bio", person: @person %>
- <% elsif @person.bio.blank? && @person.profile_show_bio? && own_profile %> -
-

Bio

- <%= render "people/profile_pieces/bio_prompt", person: @person %> -
<% end %> <% authored_sections = %i[workshops workshop_variations stories resources].select { |section| @person.public_send(:"profile_show_#{section}?") } %> diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index 7450b0a947..9374981a45 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -43,6 +43,16 @@ expect(response.body).to include(details_person_path(person, anchor: "bio")) end + it "prompts for a blank bio inside the hidden card even when the bio is shared" do + person.update!(bio: nil, profile_show_bio: true) + + get person_path(person) + + page = Capybara.string(response.body) + expect(page).to have_css("#hidden_from_profile", text: "Add a bio") + expect(page).to have_text("Add a bio", count: 1) + end + it "prompts for a hidden bio inside the hidden card, not as its own section" do person.update!(bio: nil, profile_show_bio: false) From 7e2dc2f8405f18af3e640a1118670f6324bddf41 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 11:21:11 -0400 Subject: [PATCH 31/34] Show the dotted Add a bio card again when the bio is shared Reverts the previous commit. With bio sharing on, the owner should see the standalone prompt in its usual spot. Only a bio switched off folds into Hidden from profile, as in #2688. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_hidden_from_profile_card.html.erb | 5 ++--- app/views/people/show.html.erb | 5 +++++ spec/requests/people_own_profile_hub_spec.rb | 10 ---------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/app/views/people/_hidden_from_profile_card.html.erb b/app/views/people/_hidden_from_profile_card.html.erb index 52cb60a22e..a677ce6f27 100644 --- a/app/views/people/_hidden_from_profile_card.html.erb +++ b/app/views/people/_hidden_from_profile_card.html.erb @@ -1,7 +1,6 @@ <%# Profile pieces the person turned off, shown open (and collapsible) to them and admins. %> <% pieces = person.hidden_profile_pieces %> -<%# A blank bio shows nothing to others whatever the toggle, so its prompt lives here too. %> -<% bio_prompt = person.bio.blank? && allowed_to?(:edit_own_profile?, person) %> +<% bio_prompt = !person.profile_show_bio? && person.bio.blank? && allowed_to?(:edit_own_profile?, person) %> <% return if pieces.empty? && !bio_prompt %> <% contact_pieces = pieces & %i[profile_show_credentials profile_show_pronouns profile_show_member_since profile_show_email profile_show_phone profile_show_social_media] %> <% chip_class = "inline-flex items-center gap-1.5 rounded-full border border-black/5 bg-white px-3 py-1 text-gray-700" %> @@ -25,7 +24,7 @@
- <% if pieces.any? && allowed_to?(:edit?, person) %> + <% if allowed_to?(:edit?, person) %>

These are turned off in the profile's sharing settings. <%= link_to "Change sharing settings", diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index 9e3e35842e..256b26f55e 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -215,6 +215,11 @@

<%= render "people/profile_pieces/bio", person: @person %>
+ <% elsif @person.bio.blank? && @person.profile_show_bio? && own_profile %> +
+

Bio

+ <%= render "people/profile_pieces/bio_prompt", person: @person %> +
<% end %> <% authored_sections = %i[workshops workshop_variations stories resources].select { |section| @person.public_send(:"profile_show_#{section}?") } %> diff --git a/spec/requests/people_own_profile_hub_spec.rb b/spec/requests/people_own_profile_hub_spec.rb index 9374981a45..7450b0a947 100644 --- a/spec/requests/people_own_profile_hub_spec.rb +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -43,16 +43,6 @@ expect(response.body).to include(details_person_path(person, anchor: "bio")) end - it "prompts for a blank bio inside the hidden card even when the bio is shared" do - person.update!(bio: nil, profile_show_bio: true) - - get person_path(person) - - page = Capybara.string(response.body) - expect(page).to have_css("#hidden_from_profile", text: "Add a bio") - expect(page).to have_text("Add a bio", count: 1) - end - it "prompts for a hidden bio inside the hidden card, not as its own section" do person.update!(bio: nil, profile_show_bio: false) From 56000e2c0a54ad9e22c0d44638c3f481b2d4fc0f Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 11:22:25 -0400 Subject: [PATCH 32/34] Let admins fold the Admin details card on a profile It matches the collapsible lilac cards beside it. It starts open, and the title and a chevron toggle it through the existing expandable-card controller. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/_admin_details.html.erb | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/views/people/_admin_details.html.erb b/app/views/people/_admin_details.html.erb index 8a4b71a944..588c99c059 100644 --- a/app/views/people/_admin_details.html.erb +++ b/app/views/people/_admin_details.html.erb @@ -5,21 +5,28 @@ <% addresses = person.addresses.to_a %> <% staff_tag_names = person.staff_tags.map(&:name) %> <% topic_labels = person.topic_subscriptions.active.map(&:topic_label).compact_blank %> -
+
- - - -

Admin details

+
<% if allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %> <%= link_to "Activity", admin_activities_events_path(person_id: person.id, time_period: "all_time", audience: %w[visitors users staff], hide_account: "1", hide_interactions: "1"), class: admin_button_classes %> <% end %> <%= render "people/admin_only_badge" %> +
-
+
<% program_categories = person.categorizable_items.filter_map(&:category).select { |c| c.category_type&.profile_specific? && c.category_type.name != "AgeRange" }.group_by(&:category_type) %> <% other_settings = person.other_workshop_setting_responses %> <% if program_categories.any? || other_settings.any? %> From 702854d4d4f7d74bef9fb661771e14dfa08c05a6 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 11:26:18 -0400 Subject: [PATCH 33/34] Put the work address beside each affiliation title The work address card under every title made the list long and pulled titles apart. On desktop each title is now one row: title and dates, a compact work address dropdown, then Request a change. "Address not listed?" is the dropdown's hint link. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/details.html.erb | 41 ++++++++++++++-------------- spec/requests/people_details_spec.rb | 2 +- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index 655c3ad27d..c26a938d18 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -135,16 +135,34 @@ <% affiliations.group_by(&:organization).each do |organization, org_affiliations| %>

<%= organization.name %>

-
+
<% org_affiliations.each do |affiliation| %> <% decorated = affiliation.decorate %> <% pending = pending_affiliation_requests[affiliation.id] %> -
+ <% work_addresses = organization.addresses.select { |address| !address.inactive? || address.id == affiliation.organization_address_id } %> +

<%= decorated.display_title %>

<%= decorated.status_badge %> <%= decorated.period_label %>

-
+
+ <% missing_address_link = link_to(new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id, + change: ProfileChangeRequest::AFFILIATION_CHANGE_WORK_ADDRESS, return_to: "details"), + target: "_blank", rel: "noopener", class: "underline hover:text-blue-800") do %> + <%= work_addresses.any? ? "Address not listed?" : "Tell us your work address" %> + <% end %> + <% if work_addresses.any? %> + <%= f.simple_fields_for :affiliations, affiliation, child_index: affiliation.id do |af| %> + <%= af.input :organization_address_id, label: "Work address", required: false, + collection: work_addresses.map { |address| [ address.name, address.id ] }, include_blank: "Not picked yet", + hint: missing_address_link, wrapper_html: { class: "mb-0" }, label_html: { class: "text-sm" }, input_html: { class: "w-full" } %> + <% end %> + <% else %> +

Work address

+

None on file yet. <%= missing_address_link %>

+ <% end %> +
+
<%= link_to (pending ? edit_profile_change_request_path(pending, return_to: "details") : new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id, return_to: "details")), target: "_blank", rel: "noopener", class: button_classes(:secondary_outline, size: :sm) do %> <%= pending ? "See your request" : "Request a change" %> @@ -154,23 +172,6 @@ <% end %>
- <% work_addresses = organization.addresses.select { |address| !address.inactive? || address.id == affiliation.organization_address_id } %> -
- <% if work_addresses.any? %> - <%= f.simple_fields_for :affiliations, affiliation, child_index: affiliation.id do |af| %> - <%= af.input :organization_address_id, label: "Your work address", required: false, - collection: work_addresses.map { |address| [ address.name, address.id ] }, - include_blank: "Not picked yet", wrapper_html: { class: "sm:max-w-xl" }, input_html: { class: input_class } %> - <% end %> - <% else %> -

We don't have an address on file for <%= organization.name %> yet.

- <% end %> - <%= link_to new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id, - change: ProfileChangeRequest::AFFILIATION_CHANGE_WORK_ADDRESS, return_to: "details"), - target: "_blank", rel: "noopener", class: "mt-1 inline-flex items-center gap-1 text-sm font-medium text-blue-600 underline hover:text-blue-800" do %> - <%= work_addresses.any? ? "Address not listed? Tell us" : "Tell us your work address" %> - <% end %> -
<% end %>
diff --git a/spec/requests/people_details_spec.rb b/spec/requests/people_details_spec.rb index 8d6a1369b6..4245d88830 100644 --- a/spec/requests/people_details_spec.rb +++ b/spec/requests/people_details_spec.rb @@ -166,7 +166,7 @@ def buffered_during_request it "shows a work address picker from the organization's addresses and a way to report a missing one" do get details_person_path(person) - expect(response.body).to include("Your work address", "1 Office Way") + expect(response.body).to include("Work address", "1 Office Way") expect(response.body).to include("person[affiliations_attributes][#{affiliation.id}][organization_address_id]") expect(response.body).to include("change=Work+address") end From 046743e86e4bccfa3d55a644b5d9818cd016d629 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 11:27:51 -0400 Subject: [PATCH 34/34] Line up Request a change with the work address dropdown Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/people/details.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb index c26a938d18..270e33711f 100644 --- a/app/views/people/details.html.erb +++ b/app/views/people/details.html.erb @@ -162,7 +162,7 @@

None on file yet. <%= missing_address_link %>

<% end %>
-
+
<%= link_to (pending ? edit_profile_change_request_path(pending, return_to: "details") : new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: affiliation.id, return_to: "details")), target: "_blank", rel: "noopener", class: button_classes(:secondary_outline, size: :sm) do %> <%= pending ? "See your request" : "Request a change" %>