diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 1a66c3dfce..80c307602b 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 details ] # The profile's "Submitted content" sections — private to the person and admins, # not part of the public profile even after profile viewing opens up. @@ -243,6 +243,15 @@ def edit set_form_variables end + def visibility + authorize! @person + end + + def details + authorize! @person + set_form_variables + end + def create @person = Person.new(person_params.except(:user_attributes)) authorize! @person @@ -303,7 +312,7 @@ def update redirect_to person_update_return_path, notice: "Person was successfully updated." else set_form_variables - render :edit, status: :unprocessable_content + render(params[:return_to].presence_in(%w[visibility details]) || :edit, status: :unprocessable_content) end end diff --git a/app/controllers/profile_change_requests_controller.rb b/app/controllers/profile_change_requests_controller.rb index 6c548dd954..8967a91512 100644 --- a/app/controllers/profile_change_requests_controller.rb +++ b/app/controllers/profile_change_requests_controller.rb @@ -17,13 +17,10 @@ def new @person = Person.find(params[:person_id]) @request = @person.profile_change_requests.new(field: requested_field, requested_by: current_user) authorize! @request - - # Affiliation requests target a specific affiliation chosen on the form, so we - # can't resolve "the" existing one here — only the single-target fields dedupe. - return if requested_field == "affiliation" + return prepare_affiliation_request if requested_field == "affiliation" existing = @person.profile_change_requests.pending.find_by(field: requested_field) - redirect_to edit_profile_change_request_path(existing) if existing + redirect_to edit_profile_change_request_path(existing, return_to: return_to) if existing end def create @@ -34,7 +31,7 @@ def create if @request.save notify_admins_and_submitter(@request) - redirect_to edit_person_path(@person, anchor: "affiliations"), status: :see_other, + redirect_to @request.decorate.profile_return_path(return_to), status: :see_other, notice: "Thanks — we've sent your change request to AWBW staff." else @person = @request.person @@ -51,7 +48,7 @@ def update authorize! @request if @request.update(profile_change_request_params) - redirect_to edit_person_path(@request.person, anchor: "affiliations"), status: :see_other, + redirect_to @request.decorate.profile_return_path(return_to), status: :see_other, notice: "Your change request was updated." else @person = @request.person @@ -102,6 +99,51 @@ def review_note params[:reviewer_note].presence end + # One question per page: which organization (skipped when there's only one), + # then what to change, then a form with just those inputs. + def prepare_affiliation_request + @affiliations = @person.affiliations.includes(:organization).select(&:organization) + .sort_by { |a| [ a.active? ? 0 : 1, a.organization.name.downcase ] } + organizations_path = new_profile_change_request_path(person_id: @person, field: "affiliation", return_to: return_to) + profile_link = if return_to + [ details_person_path(@person, anchor: "affiliations"), "Back to editing my profile" ] + else + [ person_path(@person, anchor: "affiliations"), "Back to my profile" ] + end + organizations_link = @affiliations.many? ? [ organizations_path, "Back to my organizations" ] : profile_link + + if params[:add].present? || @affiliations.empty? + @request.requested_value = ProfileChangeRequest::AFFILIATION_CHANGE_ADD + @back_path, @back_label = @affiliations.any? ? [ organizations_path, "Back to my organizations" ] : profile_link + return + end + + @request.affiliation = params[:affiliation_id].present? ? @affiliations.find { |a| a.id.to_s == params[:affiliation_id].to_s } : (@affiliations.first if @affiliations.one?) + unless @request.affiliation + @pending_by_affiliation = @person.profile_change_requests.pending.where(field: "affiliation").index_by(&:affiliation_id) + @back_path, @back_label = profile_link + return render :choose_affiliation + end + + existing = @person.profile_change_requests.pending.find_by(field: "affiliation", affiliation: @request.affiliation) + return redirect_to edit_profile_change_request_path(existing, return_to: return_to) if existing + + change = params[:change].presence_in(ProfileChangeRequest::AFFILIATION_CHANGE_TYPES - [ ProfileChangeRequest::AFFILIATION_CHANGE_ADD ]) + unless change + @back_path, @back_label = organizations_link + return render :choose_affiliation_change + end + + @request.requested_value = change + @back_path = new_profile_change_request_path(person_id: @person, field: "affiliation", affiliation_id: @request.affiliation.id, return_to: return_to) + @back_label = "Back" + end + + def return_to + params[:return_to].presence_in(%w[details]) + end + helper_method :return_to + def profile_change_request_params params.require(:profile_change_request).permit( :field, :requested_value, :details, :affiliation_id, :organization_id, diff --git a/app/decorators/affiliation_decorator.rb b/app/decorators/affiliation_decorator.rb index 333c33498d..34bafa3534 100644 --- a/app/decorators/affiliation_decorator.rb +++ b/app/decorators/affiliation_decorator.rb @@ -3,6 +3,12 @@ def detail(length: nil) "#{person.full_name}: #{title.presence || Affiliation::FACILITATOR_TITLE} - #{organization.name}" end + # A blank title means a facilitator affiliation, the same default used elsewhere. + def display_title + shown = title.presence || Affiliation::FACILITATOR_TITLE + shown.strip == Affiliation::FACILITATOR_TITLE ? "AWBW #{Affiliation::FACILITATOR_TITLE}" : shown + end + def status_badge classes, icon = case status_on when "Active" then [ "bg-green-50 text-green-800 border-green-300", "fa-solid fa-circle-check" ] diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index ada6260be9..78118219d1 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 activity" => { + profile_show_events_registered: "Events you attended", + profile_show_workshops: "Workshops you wrote", + profile_show_workshop_variations: "Workshop variations you wrote", + profile_show_stories: "Stories you wrote or were featured in", + profile_show_resources: "Resources you wrote" + } + }.freeze + def title "#{first_name} #{last_name}" end diff --git a/app/decorators/profile_change_request_decorator.rb b/app/decorators/profile_change_request_decorator.rb index 0f40ddd781..0c430c22bb 100644 --- a/app/decorators/profile_change_request_decorator.rb +++ b/app/decorators/profile_change_request_decorator.rb @@ -1,4 +1,19 @@ class ProfileChangeRequestDecorator < ApplicationDecorator + AFFILIATION_CHANGE_CHOICES = { + ProfileChangeRequest::AFFILIATION_CHANGE_TITLE => { label: "Change my title or role", icon: "fa-solid fa-id-badge" }, + ProfileChangeRequest::AFFILIATION_CHANGE_DATES => { label: "Fix my start or end date", icon: "fa-solid fa-calendar-days" }, + ProfileChangeRequest::AFFILIATION_CHANGE_ORGANIZATION => { label: "Fix the organization's name", icon: "fa-solid fa-building" }, + ProfileChangeRequest::AFFILIATION_CHANGE_REMOVE => { label: "I don't work with them anymore", icon: "fa-solid fa-door-open" }, + ProfileChangeRequest::AFFILIATION_CHANGE_WORK_ADDRESS => { label: "Add my work address", icon: "fa-solid fa-location-dot" }, + ProfileChangeRequest::AFFILIATION_CHANGE_OTHER => { label: "Something else", icon: "fa-solid fa-comment" }, + ProfileChangeRequest::AFFILIATION_CHANGE_ADD => { label: "Add an organization", icon: "fa-solid fa-plus" } + }.freeze + def heading + return "Request a profile data change" if other? + + AFFILIATION_CHANGE_CHOICES.dig(requested_value, :label) if field == "affiliation" + end + def status_badge classes, icon, label = case status when "resolved" then [ "bg-green-50 text-green-800 border-green-300", "fa-solid fa-circle-check", "Resolved" ] @@ -18,12 +33,32 @@ def requested_on created_at.strftime("%b %-d, %Y") end + # One plain line the requester recognizes their request by, e.g. + # "Primary email: new@example.com" or "Title or role: Riverside Center". + def requester_summary + parts = if field == "affiliation" + [ requested_value, (affiliation&.organization || organization)&.name || proposed_organization_name ] + else + [ field_label, requested_value.presence || details&.truncate(60) ] + end + parts.compact_blank.join(": ") + end + + # `return_to: "details"` comes back to the email card on the simple edit page. + def profile_return_path(return_to = nil) + return h.details_person_path(person, anchor: (field == "affiliation" ? "affiliations" : "emails")) if return_to == "details" + + h.person_path(person, anchor: ("affiliations" if field == "affiliation")) + end + # Confirmation prompt for "Approve", spelling out the blast radius where the # change reaches beyond this one person (renaming a shared organization). def approve_confirm case field when "primary_email" "Approve and send a confirmation email to #{requested_value}?" + when "secondary_email" + "Approve and set the secondary email to #{requested_value}?" when "organization_name" org_rename_confirm(target_organization, requested_value) when "affiliation" @@ -56,8 +91,12 @@ def manual_edit_path case field when "primary_email" person.user ? h.edit_user_path(person.user) : h.edit_person_path(person) + when "secondary_email" + h.edit_person_path(person) when "organization_name" person.primary_organization ? h.edit_organization_path(person.primary_organization) : h.edit_person_path(person, anchor: "affiliations") + when ProfileChangeRequest::OTHER_FIELD + h.edit_person_path(person) else anchor = affiliation ? h.dom_id(affiliation) : "affiliations" h.edit_person_path(person, anchor: anchor) diff --git a/app/models/person.rb b/app/models/person.rb index 891505e036..53f0edc750 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -136,7 +136,9 @@ def self.normalize_name(name) # Nested attributes accepts_nested_attributes_for :addresses, allow_destroy: true, reject_if: proc { |attrs| attrs.slice("locality", "city", "state", "street_address", "zip_code").values.all?(&:blank?) } - accepts_nested_attributes_for :contact_methods, allow_destroy: true, reject_if: :all_blank + # A new row's hidden kind is always filled in, so a row counts as blank by its number. + accepts_nested_attributes_for :contact_methods, allow_destroy: true, + reject_if: proc { |attrs| attrs["id"].blank? && attrs["value"].blank? } accepts_nested_attributes_for :sectorable_items, allow_destroy: true, reject_if: proc { |attrs| attrs["sector_id"].blank? } # Age ranges edit through cocoon nested fields like sectors. A scoped view of @@ -153,8 +155,9 @@ def self.normalize_name(name) # both INSERT and hit the DB unique index. Collapse duplicates before validation. before_validation :dedupe_age_range_items accepts_nested_attributes_for :user, update_only: true + # Only a new row needs an organization; an owner's work-address pick sends just id + address. accepts_nested_attributes_for :affiliations, allow_destroy: true, - reject_if: proc { |attrs| attrs["organization_id"].blank? } + reject_if: proc { |attrs| attrs["id"].blank? && attrs["organization_id"].blank? } accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } # A blank row (number + kind + state + expiry all empty) is ignored rather than # creating an empty license. diff --git a/app/models/profile_change_request.rb b/app/models/profile_change_request.rb index 94d462baea..50a6c38898 100644 --- a/app/models/profile_change_request.rb +++ b/app/models/profile_change_request.rb @@ -7,14 +7,18 @@ class ProfileChangeRequest < ApplicationRecord belongs_to :organization, optional: true belongs_to :affiliation, optional: true - FIELDS = %w[primary_email organization_name affiliation].freeze + OTHER_FIELD = "other".freeze + EMAIL_FIELDS = %w[primary_email secondary_email].freeze + FIELDS = [ *EMAIL_FIELDS, "organization_name", "affiliation", OTHER_FIELD ].freeze FIELD_LABELS = { "primary_email" => "Primary email", + "secondary_email" => "Secondary email", "organization_name" => "Organization name", - "affiliation" => "Affiliation details" + "affiliation" => "Affiliation details", + OTHER_FIELD => "Profile data" }.freeze # Fields whose new value is concrete enough for "Approve" to write directly. - AUTO_APPLIABLE_FIELDS = %w[primary_email organization_name].freeze + AUTO_APPLIABLE_FIELDS = [ *EMAIL_FIELDS, "organization_name" ].freeze STATUSES = %w[pending resolved declined].freeze RESOLUTION_METHODS = %w[approved manual].freeze # What an affiliation request is asking to change (stored in requested_value), @@ -24,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, @@ -31,18 +36,19 @@ 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 } validates :resolution_method, inclusion: { in: RESOLUTION_METHODS }, allow_nil: true - validates :requested_value, presence: true + validates :requested_value, presence: true, unless: :other? validates :requested_value, format: { with: URI::MailTo::EMAIL_REGEXP, message: "must be a valid email address" }, - if: -> { field == "primary_email" && requested_value.present? } + if: -> { field.in?(EMAIL_FIELDS) && requested_value.present? } validates :details, presence: true, if: :requires_details? validate :affiliation_belongs_to_person validate :affiliation_target_present, if: -> { field == "affiliation" } @@ -60,10 +66,16 @@ class ProfileChangeRequest < ApplicationRecord def pending? = status == "pending" def resolved? = status == "resolved" def declined? = status == "declined" + def other? = field == OTHER_FIELD def auto_appliable? return true if field.in?(AUTO_APPLIABLE_FIELDS) - field == "affiliation" && requested_value.in?(AFFILIATION_AUTO_CATEGORIES) + field == "affiliation" && requested_value.in?(AFFILIATION_AUTO_CATEGORIES) && !new_organization? + end + + # An add request for an organization that isn't in the portal yet — staff create it by hand. + def new_organization? + requested_value == AFFILIATION_CHANGE_ADD && organization_id.blank? && proposed_organization_name.present? end def field_label @@ -79,10 +91,14 @@ def proposed_change_summary when AFFILIATION_CHANGE_DATES then proposed_dates_label when AFFILIATION_CHANGE_ORGANIZATION then proposed_organization_name when AFFILIATION_CHANGE_REMOVE then "End-date and mark inactive" - when AFFILIATION_CHANGE_ADD then [ proposed_title, organization&.name, proposed_dates_label ].compact_blank.join(" · ") + when AFFILIATION_CHANGE_ADD then [ proposed_title, organization&.name || new_organization_label, proposed_dates_label ].compact_blank.join(" · ") end end + def new_organization_label + "#{proposed_organization_name} (not in the portal yet)" if new_organization? + end + def proposed_dates_label start_text = proposed_start_date&.strftime("%b %-d, %Y") end_text = proposed_end_date&.strftime("%b %-d, %Y") @@ -101,6 +117,7 @@ def target_organization def current_value case field when "primary_email" then person.user&.email + when "secondary_email" then person.email_2 when "organization_name" then target_organization&.name end end @@ -110,7 +127,7 @@ def affiliation_summary return unless affiliation org = affiliation.organization&.name - [ affiliation.title.presence || Affiliation::FACILITATOR_TITLE, org ].compact.join(" @ ") + [ affiliation.decorate.display_title, org ].compact.join(" @ ") end def resolve!(method:, reviewer:, note: nil) @@ -130,7 +147,7 @@ def snapshot_target end def requires_details? - field == "affiliation" && requested_value == AFFILIATION_CHANGE_OTHER + other? || (field == "affiliation" && requested_value.in?([ AFFILIATION_CHANGE_WORK_ADDRESS, AFFILIATION_CHANGE_OTHER ])) end # A category's hidden sibling inputs still post their (stale) values; drop the @@ -143,7 +160,8 @@ def clear_irrelevant_affiliation_fields self.proposed_start_date = nil self.proposed_end_date = nil end - self.proposed_organization_name = nil unless requested_value == AFFILIATION_CHANGE_ORGANIZATION + self.proposed_organization_name = nil unless requested_value.in?([ AFFILIATION_CHANGE_ORGANIZATION, AFFILIATION_CHANGE_ADD ]) + self.proposed_organization_name = nil if requested_value == AFFILIATION_CHANGE_ADD && organization_id.present? end def affiliation_belongs_to_person @@ -166,7 +184,7 @@ def affiliation_proposed_values when AFFILIATION_CHANGE_ORGANIZATION errors.add(:proposed_organization_name, "can't be blank") if proposed_organization_name.blank? when AFFILIATION_CHANGE_ADD - errors.add(:organization_id, "must be selected") if organization_id.blank? + errors.add(:base, "Pick an organization, or type its name if it's not on the list") if organization_id.blank? && proposed_organization_name.blank? errors.add(:proposed_title, "can't be blank") if proposed_title.blank? end end diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 811df2c219..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 @@ -67,6 +70,18 @@ def update? admin? || (profiles_visible_to_users? && owner?) end + def visibility? + edit? + end + + def details? + edit? + end + + def edit_own_profile? + profiles_visible_to_users? && owner? + end + def destroy? admin? && record.persisted? && !has_associated_data? end @@ -210,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 @@ -221,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/services/profile_change_requests/apply.rb b/app/services/profile_change_requests/apply.rb index 1f20260922..21f438cfb2 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.") @@ -76,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, @@ -103,6 +106,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/_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/_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? %> 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..9a9479abd9 --- /dev/null +++ b/app/views/people/_details_address_fields.html.erb @@ -0,0 +1,16 @@ +<%# 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), prompt: "Mark as main", inset: false %> + <%= 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 } %> + <%= 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/_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 new file mode 100644 index 0000000000..90c0d66e3b --- /dev/null +++ b/app/views/people/_details_phone_fields.html.erb @@ -0,0 +1,14 @@ +<%# 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 %> + <%= render "people/remove_toggle", f: f, label: "Remove this phone" %> +
+
+ <%= 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/_form.html.erb b/app/views/people/_form.html.erb index 5b82b571e7..1267850bc6 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 %> -
+
@@ -179,7 +179,7 @@ <% if @age_ranges_collection.present? %> -
+
Age ranges
@@ -392,7 +392,7 @@
-
+
Background
@@ -592,13 +592,20 @@
<% end %> + <% 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), + 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), 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) && !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/_hidden_from_profile_card.html.erb b/app/views/people/_hidden_from_profile_card.html.erb index 7bad371962..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" %>

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? %> @@ -71,6 +74,11 @@

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/_main_star.html.erb b/app/views/people/_main_star.html.erb new file mode 100644 index 0000000000..113966e6bc --- /dev/null +++ b/app/views/people/_main_star.html.erb @@ -0,0 +1,10 @@ +<%# locals: (f:, kind:, attribute: :is_primary, checked: nil, prompt: nil, inset: true) %> + 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..8011b25822 --- /dev/null +++ b/app/views/people/_own_profile_banner.html.erb @@ -0,0 +1,33 @@ +<%# 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 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 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? %> +
+

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..7631ee5e17 --- /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 (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/_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/_switch_row.html.erb b/app/views/people/_switch_row.html.erb new file mode 100644 index 0000000000..f134de11cb --- /dev/null +++ b/app/views/people/_switch_row.html.erb @@ -0,0 +1,15 @@ +<%# 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 %> + +
diff --git a/app/views/people/details.html.erb b/app/views/people/details.html.erb new file mode 100644 index 0000000000..270e33711f --- /dev/null +++ b/app/views/people/details.html.erb @@ -0,0 +1,285 @@ +<% 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" %> +
+
+ <%= link_to person_path(@person), class: "text-sm #{eyebrow_link_class}" do %> + Back to my profile + <% end %> +
+

Edit my data

+

+ 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" %> + <%= 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 & 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 :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 } %> +
+
+
+

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 = [ + { 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"), + 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}") %> + + <% 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.", + 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 } %> + <% if @person.user %> + <%= f.fields_for :user do |user_form| %> + <%= user_form.input :time_zone, label: "Your time zone", collection: us_time_zone_fundamentals, + selected: user_form.object.time_zone.presence || "Pacific Time (US & Canada)", include_blank: false, + hint: "Dates and times show in this time zone.", wrapper_html: { class: "mt-4 sm:w-96" }, input_html: { class: input_class } %> + <% end %> + <% end %> +
+
+

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 do |phone| %> + <%= render "people/details_phone_fields", f: phone, main_by_default: no_main_phone && phone.index.zero? %> + <% 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 addresses

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

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

+
+ <%= 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? } %> +
+
+
+

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 } %> +
+
+
+

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.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" %> + <% end %> + <% if pending %> + Change requested + <% end %> +
+
+ <% 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.

+ <% 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", + leading: render("people/main_star", f: sector, kind: "sector"), + input_data: { primary_tag_target: "member", action: "primary-tag#selectMember" } %> +
+ <% end %> + <% end %> +
+
+ <% if @age_ranges_collection.present? %> +
+

Ages you work with

+

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", + leading: render("people/main_star", f: age, kind: "age range"), + input_data: { primary_tag_target: "member", action: "primary-tag#selectMember" } %> +
+ <% end %> + <% end %> +
+
+ <% 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.

+
+ <% @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| %> + <%= render "people/details_license_fields", f: lf %> + <% 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 %> + <%= 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" %> +
+
+
+ <%= 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 %> +

+ 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/edit.html.erb b/app/views/people/edit.html.erb index 0285354c9e..9e88cb6b33 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) && !allowed_to?(:manage?, 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/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 2330973c01..256b26f55e 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: "age-ranges" %> +
<%= render "shared/age_group_tags", primary: primary_age_groups, additional: additional_age_groups, compact: true %>
<% end %> @@ -198,9 +209,17 @@ <% 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? && @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/app/views/people/visibility.html.erb b/app/views/people/visibility.html.erb new file mode 100644 index 0000000000..6c445c2a87 --- /dev/null +++ b/app/views/people/visibility.html.erb @@ -0,0 +1,60 @@ +<% content_for(:page_bg_class, "admin-or-owner") %> +<% 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

+

+ 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", data: { controller: "dirty-form" } } do |f| %> + <%= hidden_field_tag :return_to, "visibility" %> + <%= render "shared/errors", resource: @person if @person.errors.any? %> +
+

Your name

+
+ Show your 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| %> + + <% end %> +
+
+
+
+

Your privacy

+
+ <%= render "people/switch_row", f: f, attribute: :profile_is_searchable, + 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 you write", on_text: "Yes", off_text: "No" %> +
+
+ <% PersonDecorator::PROFILE_VISIBILITY_GROUPS.each do |heading, fields| %> +
+

<%= heading %>

+
+ <% fields.each do |attribute, label| %> + <%= render "people/switch_row", f: f, attribute: attribute, label: label %> + <% end %> +
+
+ <% end %> +
+ <%= 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/_fields.html.erb b/app/views/profile_change_requests/_fields.html.erb index 94d57bbda5..dab6a14582 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.field.in?(ProfileChangeRequest::AUTO_APPLIABLE_FIELDS) %> <%= f.input :requested_value, label: "Change #{request.field_label.downcase} to", input_html: { class: "w-full" } %> @@ -17,49 +23,45 @@ 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" } } %> - - - - - -
" hidden> - <%= f.input :proposed_title, label: "Title or role", input_html: { class: "w-full" } %> + <% change = request.requested_value %> + <% current = request.affiliation&.decorate %> + <%= f.hidden_field :requested_value %> + <% if change == ProfileChangeRequest::AFFILIATION_CHANGE_ADD %> + <%= f.input :organization_id, + include_blank: true, + 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 %> + <% end %> + <% if change == ProfileChangeRequest::AFFILIATION_CHANGE_TITLE %> + <%= f.input :proposed_title, label: "New title or role", + hint: ("Right now: #{current.display_title}" if current), + input_html: { class: "w-full text-lg" } %> + <% elsif change.in?([ ProfileChangeRequest::AFFILIATION_CHANGE_DATES, ProfileChangeRequest::AFFILIATION_CHANGE_ADD ]) %> + <% if current %> +

Right now: <%= current.period_label %>. Leave a date blank to keep it as it is.

+ <% end %> +
+ <%= f.input :proposed_start_date, as: :date, html5: true, label: "Start date", wrapper_html: { class: "flex-1" }, input_html: { class: "w-full text-lg" } %> + <%= f.input :proposed_end_date, as: :date, html5: true, label: "End date", wrapper_html: { class: "flex-1" }, input_html: { class: "w-full text-lg" } %>
- -
" - class="flex flex-col gap-4 sm:flex-row" hidden> - <%= f.input :proposed_start_date, as: :date, html5: true, label: "Start date", input_html: { class: "w-full" } %> - <%= f.input :proposed_end_date, as: :date, html5: true, label: "End date", input_html: { class: "w-full" } %> -
- - - - <%= f.input :details, as: :text, label: "Additional details (required for \"Other\")", input_html: { rows: 4, class: "w-full" } %> -
+ <% 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_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" } %> + <% end %> <% end %> 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..b46d1b2486 --- /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 @back_path, class: "text-sm #{eyebrow_link_class}" do %> + <%= @back_label %> + <% 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, 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 %> + + <%= 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, 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 + 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..1e923e2d7d --- /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, 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 %> + + + + <%= choice[:label] %> + <% end %> + <% end %> +
+
diff --git a/app/views/profile_change_requests/edit.html.erb b/app/views/profile_change_requests/edit.html.erb index ce9254aab3..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 edit_person_path(@person, anchor: "affiliations"), class: "text-sm #{eyebrow_link_class}" do %> - Back to 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 6d51e0beca..b3329429fd 100644 --- a/app/views/profile_change_requests/new.html.erb +++ b/app/views/profile_change_requests/new.html.erb @@ -1,20 +1,30 @@ <% 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 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 %>
-

Request a change

+

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

+ <% if @request.affiliation %> +

<%= @request.affiliation_summary %>

+ <% end %>

- <%= @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. + <% 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. + <% end %>

<%= 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/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..db459d611a 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4593,3 +4593,25 @@ 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. + 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 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 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 0ac8257e14..6801961c11 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -300,6 +300,8 @@ get :checkout get :bio get :all_comments + get :visibility + get :details post :send_form_link end resources :comments, only: [ :create, :update ] diff --git a/docs/adr/0006-person-profile-field-access.md b/docs/adr/0006-person-profile-field-access.md index c9aa1cb010..3a2e061f52 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). @@ -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. @@ -70,8 +72,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 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. **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 @@ -102,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 | 🔀 | 👁 | 👁 | — | — | @@ -111,7 +117,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) | — | — | — | — | 🔵 | 🔒 | ✅ | 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/decorators/profile_change_request_decorator_spec.rb b/spec/decorators/profile_change_request_decorator_spec.rb new file mode 100644 index 0000000000..a0c556f973 --- /dev/null +++ b/spec/decorators/profile_change_request_decorator_spec.rb @@ -0,0 +1,56 @@ +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 "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 data: 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")) + 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 "#heading" do + it "titles the free-text request form as a profile data change" do + expect(build_stubbed(:profile_change_request, :other).decorate.heading).to eq("Request a profile data change") + end + + it "leaves the email form on the default title" do + expect(build_stubbed(:profile_change_request, :primary_email).decorate.heading).to be_nil + end + + it "titles an organization request by the chosen change" do + request = build_stubbed(:profile_change_request, requested_value: ProfileChangeRequest::AFFILIATION_CHANGE_DATES) + expect(request.decorate.heading).to eq("Fix my start or end date") + 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 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}") + end + end +end diff --git a/spec/factories/profile_change_requests.rb b/spec/factories/profile_change_requests.rb index 514b87b76e..2c1a6e6273 100644 --- a/spec/factories/profile_change_requests.rb +++ b/spec/factories/profile_change_requests.rb @@ -13,12 +13,24 @@ 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" } 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..7144c27695 100644 --- a/spec/models/profile_change_request_spec.rb +++ b/spec/models/profile_change_request_spec.rb @@ -19,6 +19,46 @@ 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 "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 + + 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 @@ -37,6 +77,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/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index 912b5d13f0..58105084c7 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) } @@ -412,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_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..4245d88830 --- /dev/null +++ b/spec/requests/people_details_spec.rb @@ -0,0 +1,408 @@ +require "rails_helper" + +# 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 + 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: { "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: { "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 ] } + + 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 "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) + + 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 + + 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]\"") + 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 + 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 + + describe "the affiliations card" do + 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("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(%r{>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) + + 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 "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") + + get details_person_path(person) + + 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][900][value]") + end + + it "adds a second phone and moves the main flag to it" do + first = create(:contact_method, contactable: person, value: "555-0100", primary: true) + + save_details(contact_methods_attributes: { + "0" => { id: first.id, kind: "phone", value: "555-0100", primary: "0" }, + "900" => { kind: "phone", value: "555-0199", primary: "1" } + }) + + expect(person.reload.contact_methods.find_by(primary: true).value).to eq("555-0199") + expect(person.contact_methods.count).to eq(2) + 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", "Mark as main") + 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\]\[900\]\[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" }, + "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") + 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)" }) + + 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: { "900" => { 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 data", "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 new file mode 100644 index 0000000000..7450b0a947 --- /dev/null +++ b/spec/requests/people_own_profile_hub_spec.rb @@ -0,0 +1,137 @@ +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 and sharing buttons" do + get person_path(person) + + 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 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) + + expect(response.body).to include("Ask for a change") + expect(response.body).to include(details_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(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") + + 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/) + expect(response.body).to include("Ask AWBW staff to change it") + 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 "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?") + + post profile_change_requests_path(person_id: person), params: { + 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 + 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 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/requests/profile_change_request_organization_picker_spec.rb b/spec/requests/profile_change_request_organization_picker_spec.rb new file mode 100644 index 0000000000..75b380e7ef --- /dev/null +++ b/spec/requests/profile_change_request_organization_picker_spec.rb @@ -0,0 +1,128 @@ +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 "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("Fix my start or end date") + expect(response.body).to include("Back to my organizations") + end + + it "links an organization with a pending request to that request" do + pending_request = create(:profile_change_request, person: person, affiliation: harbor, + requested_value: "Title or role", proposed_title: "Chair", details: nil) + + new_affiliation_request + + expect(response.body).to include(edit_profile_change_request_path(pending_request)) + end + + it "sends a chosen organization with a pending request to that request" do + pending_request = create(:profile_change_request, person: person, affiliation: harbor, + requested_value: "Title or role", proposed_title: "Chair", details: nil) + + new_affiliation_request(affiliation_id: harbor.id) + + expect(response).to redirect_to(edit_profile_change_request_path(pending_request)) + end + end + + it "skips the organization list for someone with one organization" do + new_affiliation_request + + expect(response.body).not_to include("Which organization?") + expect(response.body).to include("What would you like to change?") + expect(response.body).to include("AWBW Facilitator @ Sunrise Center") + end + + it "shows only date pickers for a date change, without a show-hide dropdown" do + new_affiliation_request(affiliation_id: sunrise.id, change: ProfileChangeRequest::AFFILIATION_CHANGE_DATES) + + expect(response.body).to include("Fix my start or end date") + expect(response.body).to include('type="date" name="profile_change_request[proposed_start_date]"') + expect(response.body).to include('type="date" name="profile_change_request[proposed_end_date]"') + expect(response.body).not_to include("conditional-fields") + expect(response.body).not_to include("profile_change_request[proposed_title]") + end + + it "shows only the title box for a title change" do + new_affiliation_request(affiliation_id: sunrise.id, change: ProfileChangeRequest::AFFILIATION_CHANGE_TITLE) + + expect(response.body).to include("profile_change_request[proposed_title]") + expect(response.body).to include("Right now: AWBW Facilitator") + expect(response.body).not_to include("profile_change_request[proposed_start_date]") + end + + it "saves a date change sent from the focused form" do + post profile_change_requests_path(person_id: person), params: { + profile_change_request: { + field: "affiliation", affiliation_id: sunrise.id, + requested_value: ProfileChangeRequest::AFFILIATION_CHANGE_DATES, proposed_end_date: "2026-06-30" + } + } + + expect(response).to redirect_to(person_path(person, anchor: "affiliations")) + 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) + + expect(response.body).to include("Add an organization") + expect(response.body).to include(%(value="#{ProfileChangeRequest::AFFILIATION_CHANGE_ADD}")) + expect(response.body).to include("profile_change_request[organization_id]") + end +end + +RSpec.describe "Editing a pending organization request", type: :request do + it "shows the organization form, not a plain text box" do + owner_user = create(:user, :with_person) + affiliation = create(:affiliation, person: owner_user.person, organization: create(:organization)) + request = create(:profile_change_request, person: owner_user.person, requested_by: owner_user, affiliation: affiliation, + requested_value: "Title or role", proposed_title: "Lead", details: nil) + sign_in owner_user + + get edit_profile_change_request_path(request) + + expect(response.body).to include("profile_change_request[proposed_title]") + expect(response.body).not_to include("Change affiliation details to") + end +end diff --git a/spec/requests/profile_change_requests_spec.rb b/spec/requests/profile_change_requests_spec.rb index 9bed17eabd..fdb9cf6019 100644 --- a/spec/requests/profile_change_requests_spec.rb +++ b/spec/requests/profile_change_requests_spec.rb @@ -70,6 +70,25 @@ 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 "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) diff --git a/spec/system/person_details_spec.rb b/spec/system/person_details_spec.rb new file mode 100644 index 0000000000..ccae849278 --- /dev/null +++ b/spec/system/person_details_spec.rb @@ -0,0 +1,76 @@ +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") } + let!(:home) { create(:address, addressable: person, locality: "Unknown", primary: true) } + + 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 this phone").click + 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 + 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" + 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 + 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 + within(find("[data-primary-tag-target='chip']", text: "Teens (13-17)")) { find("label[title='Make this my main age range']").click } + find("summary", text: "Add a license").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 { |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) + 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 diff --git a/spec/system/person_visibility_spec.rb b/spec/system/person_visibility_spec.rb new file mode 100644 index 0000000000..940fa8ba78 --- /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 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) + 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/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 3a5455523b..faf0a76852 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -90,10 +90,14 @@ "app/views/notifications/show.html.erb" => "admin-or-owner", "app/views/profile_change_requests/new.html.erb" => "admin-or-owner", "app/views/profile_change_requests/edit.html.erb" => "admin-or-owner", + "app/views/profile_change_requests/choose_affiliation.html.erb" => "admin-or-owner", + "app/views/profile_change_requests/choose_affiliation_change.html.erb" => "admin-or-owner", "app/views/organizations/show.html.erb" => "admin-or-auth", "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/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", 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