diff --git a/app/decorators/application_decorator.rb b/app/decorators/application_decorator.rb index 28d7b6fc58..69a87af4ea 100644 --- a/app/decorators/application_decorator.rb +++ b/app/decorators/application_decorator.rb @@ -23,4 +23,29 @@ def published? def external_link? false end + + # One-line summary of the tagged sectors for a collapsed form section, primary + # first. The primary sector is bold with a ⭐; a sector leader gets a 👑 and a + # trailing "(sector leader)". Excludes the "Other" catch-all. HTML-safe. + def sectors_summary + items = object.sectorable_items_primary_first.reject { |item| item.sector&.name == Sector::OTHER_SECTOR_NAME } + return "None selected" if items.empty? + + h.safe_join(items.map { |item| sector_summary_chip(item) }, ", ") + end + + private + + # A single sector's chip, kept on one line (whitespace-nowrap) so the icon, + # name, and "(sector leader)" never split across a wrap — breaks fall between + # sectors, at the joining comma. + def sector_summary_chip(item) + name = item.sector&.name.to_s + pieces = [] + pieces << h.content_tag(:i, "", class: "fa-solid fa-crown text-lime-600") if item.is_leader? + pieces << h.content_tag(:i, "", class: "fa-solid fa-star text-amber-400") if item.is_primary? + pieces << (item.is_primary? ? h.content_tag(:strong, name) : name) + pieces << "(sector leader)" if item.is_leader? + h.content_tag(:span, h.safe_join(pieces, " "), class: "whitespace-nowrap") + end end diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 34d02d9671..a2193132c4 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -56,6 +56,31 @@ def agency_type_option Organization::AGENCY_TYPE_OTHER end + # Optional Background-info fields, mapped to the short pill label shown in the + # collapsed section summary when they're filled in. + BACKGROUND_FIELD_LABELS = { + email: "Email", + website_url: "Website", + description: "Description", + mission_vision_values: "Mission/vision/values" + }.freeze + + # One-line summary for the collapsed Background Info section: the organization + # type (the specify-text when it's "Other"), followed by a pill for each filled + # optional field. FileMaker ID is intentionally left out — it's admin-only. + # HTML-safe. + def background_summary + type = agency_type_option.presence + type = "#{type}: #{object.agency_type_other}" if type == Organization::AGENCY_TYPE_OTHER && object.agency_type_other.present? + + parts = [ h.content_tag(:span, type || "Type not set", class: "text-gray-600") ] + BACKGROUND_FIELD_LABELS.each do |attr, pill_label| + next if object.public_send(attr).blank? + parts << h.content_tag(:span, pill_label, class: "text-xs font-normal px-2 py-0.5 rounded-full bg-gray-100 text-gray-600") + end + h.safe_join(parts, " ") + end + def detail(length: nil) length ? description&.truncate(length) : description end @@ -103,6 +128,29 @@ def facilitator_status_as_of(date) active ? :ongoing : :reinstated end + # Profile display toggles in form order, mapped to the noun used on each + # checkbox ("Show email" => "email"). Drives the collapsed form section's + # one-line summary. + PROFILE_DISPLAY_LABELS = { + profile_show_email: "email", + profile_show_phone: "phone", + profile_show_website: "website", + profile_show_description: "description", + profile_show_sectors: "sectors", + profile_show_workshops: "workshops", + profile_show_stories: "stories", + profile_show_events_registered: "events hosted", + profile_show_workshop_logs: "workshop logs" + }.freeze + + # One-line summary of the profile display preferences for the collapsed form + # section. Most orgs show everything, so it names only what's hidden + # ("Hide phone and website") and says "All shown" when nothing is hidden. + def profile_display_summary + hidden = PROFILE_DISPLAY_LABELS.reject { |attr, _| object.public_send(attr) }.values + hidden.any? ? "Hide #{hidden.to_sentence}" : "All shown" + end + def badges earliest = affiliations.minimum(:start_date) || start_date years = earliest ? (Time.zone.now.year - earliest.year) : nil diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 09bb4d5421..2ca2d68c30 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -76,6 +76,76 @@ def badges @badges ||= compute_badges end + # Profile display toggles in form order, mapped to the noun used on each + # checkbox ("Show email" => "email"). Drives the collapsed form section's + # one-line summary. + PROFILE_DISPLAY_LABELS = { + profile_show_credentials: "credentials", + profile_show_pronouns: "pronouns", + profile_show_email: "email", + profile_show_phone: "phone", + profile_show_social_media: "social media", + profile_show_member_since: "facilitator since", + profile_show_bio: "bio", + profile_show_affiliations: "affiliations", + profile_show_sectors: "sectors", + profile_show_workshops: "workshops", + profile_show_workshop_variations: "workshop variations", + profile_show_stories: "stories", + profile_show_resources: "resources", + profile_show_events_registered: "registrations", + profile_show_story_ideas: "story ideas", + profile_show_workshop_ideas: "workshop ideas", + profile_show_workshop_variation_ideas: "variation ideas", + profile_show_workshop_logs: "workshop logs" + }.freeze + + # One-line summary of the profile display preferences for the collapsed form + # section. Most people show everything, so it names only what's hidden + # ("Hide phone and bio") and says "All shown" when nothing is hidden. + def profile_display_summary + hidden = PROFILE_DISPLAY_LABELS.reject { |attr, _| object.public_send(attr) }.values + hidden.any? ? "Hide #{hidden.to_sentence}" : "All shown" + end + + # Social-media URL fields mapped to the platform label shown as a pill in the + # collapsed section summary when the field is filled in. + SOCIAL_MEDIA_LABELS = { + linked_in_url: "LinkedIn", + facebook_url: "Facebook", + instagram_url: "Instagram", + youtube_url: "YouTube", + twitter_url: "Twitter" + }.freeze + + # One-line summary of the social-media links for the collapsed form section: a + # grey pill for each platform with a URL on file, or "None". HTML-safe. + def social_media_summary + present = SOCIAL_MEDIA_LABELS.select { |attr, _| object.public_send(attr).present? }.values + return "None" if present.empty? + + h.safe_join(present.map { |label| + h.content_tag(:span, label, class: "text-xs font-normal px-2 py-0.5 rounded-full bg-gray-100 text-gray-600") + }, " ") + end + + # One-line summary of the tagged age ranges for a collapsed form section. The + # primary age group is bold with a ⭐ (age ranges have no leader flag). HTML-safe. + def age_ranges_summary + items = object.age_range_items_ordered + return "None selected" if items.empty? + + h.safe_join(items.map { |item| + name = item.category&.name.to_s + inner = if item.is_primary? + h.safe_join([ h.content_tag(:i, "", class: "fa-solid fa-star text-amber-400"), h.content_tag(:strong, name) ], " ") + else + name + end + h.content_tag(:span, inner, class: "whitespace-nowrap") + }, ", ") + end + def facilitator_since_date @facilitator_since_date ||= begin facilitator_affiliations = affiliations.facilitators diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 99b790e99c..db6f05f767 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -293,7 +293,10 @@ def form_field_option_source(field) # model identity (DomainTheme color + default label + default icon + count # + default index path). Pass `params:` for filter params, or `path:` to # override entirely (e.g. nested routes). - def index_button(collection, params: {}, path: nil, label: nil, icon: nil, hide_count: false, hide_icon: false, data: {}) + # wrap: renders a fixed two-line-tall button whose label wraps (rather than + # truncating on one line) — used where buttons share a row at equal width, so + # long labels stay readable and every button lines up to the same height. + def index_button(collection, params: {}, path: nil, label: nil, icon: nil, hide_count: false, hide_icon: false, wrap: false, data: {}) klass = collection.klass key = klass.name.underscore.pluralize.to_sym label ||= key.to_s.humanize @@ -308,24 +311,25 @@ def index_button(collection, params: {}, path: nil, label: nil, icon: nil, hide_ link_to path, data: { turbo_prefetch: false }.merge(data), class: "group flex items-center gap-3 w-full px-3 py-2 rounded-lg + #{'h-full min-h-[3.5rem]' if wrap} border #{border} #{bg} #{hover_bg} transition-colors duration-200 shadow-sm" do icon_tag = if hide_icon "".html_safe else - content_tag(:span, class: "#{text} w-5 text-center") do + content_tag(:span, class: "#{text} w-5 text-center shrink-0") do content_tag(:i, "", class: "fa-solid #{icon}") end end - label_tag = content_tag(:span, label, class: "font-medium #{text} truncate") + label_tag = content_tag(:span, label, class: "font-medium #{text} #{wrap ? 'line-clamp-2' : 'truncate'}") count_tag = if hide_count "".html_safe else content_tag(:span, number_with_delimiter(collection.count), - class: "ml-auto inline-flex items-center justify-center min-w-[2.25rem] px-2 py-0.5 text-sm font-semibold rounded-full bg-white #{text} border #{border}") + class: "ml-auto shrink-0 inline-flex items-center justify-center min-w-[2.25rem] px-2 py-0.5 text-sm font-semibold rounded-full bg-white #{text} border #{border}") end icon_tag + label_tag + count_tag diff --git a/app/views/contact_methods/_contact_method_fields.html.erb b/app/views/contact_methods/_contact_method_fields.html.erb index ee19ec2736..773d730901 100644 --- a/app/views/contact_methods/_contact_method_fields.html.erb +++ b/app/views/contact_methods/_contact_method_fields.html.erb @@ -1,8 +1,10 @@
<%= f.hidden_field :kind, value: :phone %> -
+ <%# Flex-wrap (not a fixed 5-col grid) so the row reflows to multiple lines in a + narrow container, e.g. the half-width Phones column on the person form. %> +
-
+
<%= f.input :contact_type, as: :select, required: true, @@ -12,7 +14,7 @@
-
+
<%= f.input :value, wrapper: false, label: "Value", @@ -39,7 +41,7 @@ input_html: { class: "mr-2 text-blue-600 focus:ring-blue-500" } %>
-
+
<%= link_to_remove_association "Remove", f, diff --git a/app/views/notifications/_communications.html.erb b/app/views/notifications/_communications.html.erb index 99aa780bcc..6985939f89 100644 --- a/app/views/notifications/_communications.html.erb +++ b/app/views/notifications/_communications.html.erb @@ -26,23 +26,28 @@ id) rather than the fresh query row, so an admin's unsaved inline edits survive a validation re-render instead of reverting to the saved values. %> <% own_notifications_by_id = admin ? record.notifications.index_by(&:id) : {} %> -
-
+<%# Collapsible (expanded by default) so long communication logs don't dominate + the form; the header row is the disclosure summary. %> +
+

<%= t(title_key) %>

- <% if email.present? && admin %> - <%= link_to notifications_path(email: email), - class: "ml-auto inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:underline", - target: "_blank", rel: "noopener" do %> - View all - - <% end %> - <% end %> -
+ +
+ <% if email.present? && admin %> +
+ <%= link_to notifications_path(email: email), + class: "inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:underline", + target: "_blank", rel: "noopener" do %> + View all + + <% end %> +
+ <% end %>
<% notifications.each do |notification| %> @@ -93,4 +98,4 @@
<% end %>
-
+ diff --git a/app/views/organizations/_associated_records.html.erb b/app/views/organizations/_associated_records.html.erb index 7e83c7a073..8ee1312402 100644 --- a/app/views/organizations/_associated_records.html.erb +++ b/app/views/organizations/_associated_records.html.erb @@ -1,13 +1,17 @@ <% return unless organization.persisted? %> <% org_filter = { organization_id: organization.id } %> <% monthly_reports = MonthlyReport.where(org_filter) %> -
    -
  • <%= index_button CommunityNews.where(org_filter), params: org_filter %>
  • -
  • <%= index_button Story.where(org_filter), params: org_filter %>
  • -
  • <%= index_button organization.workshop_logs, params: org_filter %>
  • -
  • <%= index_button organization.event_registrations, params: org_filter %>
  • -
  • <%= index_button StoryIdea.where(org_filter), params: org_filter %>
  • +<%# Stacked on mobile, a single equal-width row on md+ (each item flex-1). Buttons + are two lines tall (wrap: true) so long labels wrap and every button lines up + to the same height. Alphabetical by label, which keeps Stories and Story ideas + adjacent. %> +
      +
    • <%= index_button CommunityNews.where(org_filter), params: org_filter, wrap: true %>
    • +
    • <%= index_button organization.event_registrations, params: org_filter, wrap: true %>
    • <% if monthly_reports.exists? %> -
    • <%= index_button monthly_reports, params: org_filter %>
    • +
    • <%= index_button monthly_reports, params: org_filter, wrap: true %>
    • <% end %> +
    • <%= index_button Story.where(org_filter), params: org_filter, wrap: true %>
    • +
    • <%= index_button StoryIdea.where(org_filter), params: org_filter, wrap: true, label: safe_join([ "Story", tag.br, "ideas" ]) %>
    • +
    • <%= index_button organization.workshop_logs, params: org_filter, wrap: true %>
    diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 490e115f1c..045a6522a4 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -5,32 +5,36 @@ <% automanaged_notice = "Auto-managed by affiliations" %>
    - -
    - -
    -
    - <%= f.input :name, - label: "Organization Name", - as: :text, - required: true, - input_html: { - autofocus: true, - rows: 1, - class: "w-full rounded-md border-gray-300 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200" - } %> -
    -
    - - -
    -
    -
    - Sectors -
    + +
    + + <%= f.input :name, + label: "Organization Name", + as: :text, + required: true, + input_html: { + autofocus: true, + rows: 1, + class: "w-full rounded-md border-gray-300 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200" + } %> -
    + + <%# Sectors is collapsible — collapses to the list of tagged sectors. Open on + a new org or when there are errors. %> + <% sectors_open = !f.object.persisted? || f.object.errors.any? %> +
    +
    > + <%# Title is a fixed column; the names flow in the flex-1 column and wrap + indented under themselves (hanging indent), chevron trailing. %> + + Sectors: + <%= f.object.decorate.sectors_summary %> + + +
    <%= f.simple_fields_for :sectorable_items do |sfi| %> <%= render "shared/sectorable_item_fields", f: sfi %> <% end %> @@ -47,61 +51,85 @@ }, class: "btn btn-secondary-outline" %>
    -
    + + + + <% windows_open = !f.object.persisted? || f.object.errors.any? %> +
    > + + Windows audience: + <%= f.object.windows_type&.name.presence || "None selected" %> + + +
    + <%= f.association :windows_type, + label: false, + include_blank: true, + required: false, + input_html: { + class: "rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200" + } %> +
    +
    +
    -
    - <%= f.association :windows_type, - label: "Windows audience", - include_blank: true, - required: false, - input_html: { - class: "rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200" - } %> + + <% if @org_categories_grouped.present? %> + <% primary_age_ids = @organization.primary_age_category_ids %> +
    + <%# Ensures the primary-age param is always submitted so unchecking every + toggle clears the primary flags. %> + <%= hidden_field_tag "organization[primary_age_category_ids][]", "" %> + <% @org_categories_grouped.each do |type, cats| %> + <% is_age = type.name == "AgeRange" %> + <%# Collapsed to a one-line summary of the current selections so the long + option grid doesn't have to be scrolled past every edit — these rarely + change. Native
    /, no JavaScript. %> + <% selected_names = cats.select { |category| @organization.category_ids.include?(category.id) }.map(&:name) %> +
    + + <%= type.display_label %>: + <%= selected_names.any? ? selected_names.to_sentence : "None selected" %> + + +
    + <% if is_age %> +

    Check every age group served, then mark the primary ones.

    + <% end %> +
    + <% cats.each do |category| %> + <%= render "shared/category_checkbox", param_key: "organization", category: category, + checked: @organization.category_ids.include?(category.id), + is_age: is_age, + primary_checked: is_age && primary_age_ids.include?(category.id) %> + <% end %> +
    +
    +
    + <% end %>
    -
    + <% end %>
    -
    <%= render "shared/form_image_field", f: f, field_name: :logo %>
    +
    <%= render "shared/form_image_field", f: f, field_name: :logo, size: "w-24 h-24" %>
    - - <% if @org_categories_grouped.present? %> - <% primary_age_ids = @organization.primary_age_category_ids %> -
    - <%# Ensures the primary-age param is always submitted so unchecking every - toggle clears the primary flags. %> - <%= hidden_field_tag "organization[primary_age_category_ids][]", "" %> - <% @org_categories_grouped.each do |type, cats| %> - <% is_age = type.name == "AgeRange" %> -
    <%= type.display_label %>
    -
    - <% if is_age %> -

    Check every age group served, then mark the primary ones.

    - <% end %> -
    - <% cats.each do |category| %> - <%= render "shared/category_checkbox", param_key: "organization", category: category, - checked: @organization.category_ids.include?(category.id), - is_age: is_age, - primary_checked: is_age && primary_age_ids.include?(category.id) %> - <% end %> -
    -
    - <% end %> -
    - <% end %> - -
    -
    - Background Info -
    - -
    + <%# Collapsible like Workshop settings above. Stays open on a new org (required + fields) or when the form has errors so nothing hides behind the summary. %> + <% background_open = !@organization.persisted? || @organization.errors.any? %> +
    > + + Background Info: + <%= f.object.decorate.background_summary %> + + + +
    @@ -211,14 +239,29 @@
    -
    - -
    -
    - Addresses -
    - -
    +
    + + <%# Collapsible like the sections above — there's usually just one address, so + collapse to a one-line summary. Stays open on a new org or when an address + (or the org) has errors so validation isn't hidden. %> + <% org_addresses = f.object.addresses.reject(&:marked_for_destruction?) %> + <% addresses_open = !f.object.persisted? || f.object.errors.any? || + org_addresses.any? { |address| address.errors.any? } %> + <% address_summary = if org_addresses.none? + "None" + elsif org_addresses.one? + org_addresses.first.name + else + pluralize(org_addresses.size, "address") + end %> +
    > + + Addresses: + <%= address_summary %> + + + +
    <%= f.simple_fields_for :addresses do |sfi| %> <%= render "organizations/address_fields", f: sfi %> @@ -235,14 +278,19 @@ }, class: "btn btn-secondary-outline" %>
    -
    - -
    -
    - Affiliations -
    +
    + +
    + + Affiliations + <% affiliation_count = f.object.affiliations.reject(&:marked_for_destruction?).size %> + <% if affiliation_count.positive? %> + <%= pluralize(affiliation_count, "affiliation") %> + <% end %> + + -
    +
    <% if show_status_select %>
    <%= f.input :organization_status_id, @@ -272,7 +320,7 @@ -
    -
    -
    - Profile display preferences: -
    +
    + + Profile display preferences + <%= f.object.decorate.profile_display_summary %> + + -
    +
    <%= f.input :profile_show_email, label: "Show email" %> @@ -394,15 +433,22 @@
    -
    +
    <% if f.object.persisted? %> <% if allowed_to?(:manage?, Comment) %> -
    -
    Comments
    +
    + <% comment_count = f.object.comments.reject(&:marked_for_destruction?).size %> + + Comments + <% if comment_count.positive? %> + <%= pluralize(comment_count, "comment") %> + <% end %> + + -
    +
    <%= f.simple_fields_for :comments do |cf| %> <%= render "comments/comment_fields", f: cf %> <% end %> @@ -425,9 +471,8 @@ partial: "comments/comment_fields", class: "btn btn-secondary-outline" %>
    -
    +
    <% end %> - <% end %> <% if params[:admin] && allowed_to?(:manage?, Organization) %> diff --git a/app/views/organizations/edit.html.erb b/app/views/organizations/edit.html.erb index 67331a1c8b..e8fa99ea70 100644 --- a/app/views/organizations/edit.html.erb +++ b/app/views/organizations/edit.html.erb @@ -20,5 +20,15 @@ <%= render "form" %> <%= render "shared/audit_info", resource: @organization %>
    + <% if @organization.persisted? %> +
    +
    + Associated records +
    +
    + <%= render "associated_records", organization: @organization %> +
    +
    + <% end %>
    diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 948c079c1c..2039296131 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -19,97 +19,126 @@
    -
    <%= f.input :first_name, input_html: { value: f.object.first_name || @user&.first_name } %><%= f.input :legal_first_name, label: "Legal First Name", hint: "If different from first name" %><%= f.input :last_name, input_html: { value: f.object.last_name || @user&.last_name } %><%= f.input :pronouns %>
    - -
    -

    Emails

    - -
    -
    - <% if f.object.user %> -
    - - <%= f.object.user&.email %> - <% if f.object.user.unconfirmed_email.present? && allowed_to?(:show_email_change?, f.object) %> -
    - - Change to <%= f.object.user.unconfirmed_email %> awaiting confirmation -
    - <% end %> - -

    - Only - <% if allowed_to?(:edit?, f.object.user) %> - <%= link_to "editable by admins", - edit_user_path(f.object.user), - class: "underline" %> - <% else %> - editable by admins + <%# 2-up on mobile (First | Legal, Last | Pronouns), 4-across on md+. %> +

    <%= f.input :first_name, input_html: { value: f.object.first_name || @user&.first_name } %><%= f.input :legal_first_name, label: "Legal First Name", hint: "If different from first name" %><%= f.input :last_name, input_html: { value: f.object.last_name || @user&.last_name } %><%= f.input :pronouns %>
    + + <%# Emails and Phones sit parallel (50/50). Each collapses to the value(s) + on file; both open on a new person or when there are errors. %> + <% primary_email = f.object.user&.email.presence || f.object.email.presence %> + <% email_summary = [ primary_email, f.object.email_2.presence ].compact %> + <% contacts_open = !f.object.persisted? || f.object.errors.any? %> + <% phone_values = f.object.contact_methods.reject(&:marked_for_destruction?).filter_map { |cm| cm.value.presence } %> +
    +
    > + + Emails: + <%= email_summary.any? ? email_summary.to_sentence : "None" %> + + + +
    + <%# Two per row so the secondary email + type wrap to the next line + in the narrow half-width column. %> +
    + <% if f.object.user %> +
    + + <%= f.object.user&.email %> + <% if f.object.user.unconfirmed_email.present? && allowed_to?(:show_email_change?, f.object) %> +
    + + Change to <%= f.object.user.unconfirmed_email %> awaiting confirmation +
    <% end %> -

    -
    - <% else %> - <%= f.input :email, label: "Primary email" %> - <% end %> - <%= f.input :email_type, - label: "Primary email type", - as: :select, - collection: Person::CONTACT_TYPES.compact.map { |type| [type.to_s.humanize, type] }, - selected: f.object.email_type || f.object.user&.email_type, - include_blank: true %> +

    + Only + <% if allowed_to?(:edit?, f.object.user) %> + <%= link_to "editable by admins", + edit_user_path(f.object.user), + class: "underline" %> + <% else %> + editable by admins + <% end %> +

    +
    + <% else %> + <%= f.input :email, label: "Primary email" %> + <% end %> + + <%= f.input :email_type, + label: "Primary email type", + as: :select, + collection: Person::CONTACT_TYPES.compact.map { |type| [type.to_s.humanize, type] }, + selected: f.object.email_type || f.object.user&.email_type, + include_blank: true %> + + <%= f.input :email_2, label: "Secondary email" %> + + <%= f.input :email_2_type, + label: "Secondary email type", + as: :select, + collection: Person::CONTACT_TYPES.compact.map { |type| [type.to_s.humanize, type] }, + include_blank: true %> +
    +
    + + +
    > + + Phones: + <%= phone_values.any? ? phone_values.to_sentence : "None" %> + + + +
    + <% if @person.best_time_to_call.present? || params[:admin] == "true" %> + <%= f.input :best_time_to_call %> + <% end %> - <%= f.input :email_2, label: "Secondary email" %> + <%= f.simple_fields_for :contact_methods do |cf| %> + <%= render "contact_methods/contact_method_fields", f: cf %> + <% end %> - <%= f.input :email_2_type, - label: "Secondary email type", - as: :select, - collection: Person::CONTACT_TYPES.compact.map { |type| [type.to_s.humanize, type] }, - include_blank: true %> + <%= link_to_add_association "➕ Add phone", + f, + :contact_methods, + partial: "contact_methods/contact_method_fields", + class: "btn btn-secondary-outline" %>
    -
    +
    -
    <%= render "shared/form_image_field", f: f, field_name: :avatar %>
    -
    - -
    -
    -
    - Phones -
    - -
    - <% if @person.best_time_to_call.present? || params[:admin] == "true" %> - <%= f.input :best_time_to_call %> - <% end %> - - <%= f.simple_fields_for :contact_methods do |f| %> - <%= render "contact_methods/contact_method_fields", f: f %> - <% end %> - - <%= link_to_add_association "➕ Add phone", - f, - :contact_methods, - partial: "contact_methods/contact_method_fields", - class: "btn btn-secondary-outline" %> -
    -
    +
    <%= render "shared/form_image_field", f: f, field_name: :avatar, size: "w-24 h-24" %>
    <%# = f.input :primary_address, collection: [['Work', 1], ['Home', 2]], prompt: "Select Primary Address" %>
    -
    -
    - Addresses -
    - -
    + <%# Collapsible — usually just one address, so collapse to a one-line summary. + Stays open on a new person or when an address (or the person) has errors. %> + <% person_addresses = f.object.addresses.reject(&:marked_for_destruction?) %> + <% addresses_open = !f.object.persisted? || f.object.errors.any? || + person_addresses.any? { |address| address.errors.any? } %> + <% address_summary = if person_addresses.none? + "None" + elsif person_addresses.one? + person_addresses.first.name + else + pluralize(person_addresses.size, "address") + end %> +
    > + + Addresses: + <%= address_summary %> + + + +
    <%= f.simple_fields_for :addresses do |f| %> <%= render "addresses/address_fields", f: f %> <% end %> @@ -119,7 +148,7 @@ :addresses, class: "btn btn-secondary-outline" %>
    -
    +
    @@ -135,21 +164,26 @@ <% (@managed_category_type_ids || []).each do |type_id| %> <%= hidden_field_tag "person[managed_category_type_ids][]", type_id %> <% end %> -
    + <%# Sectors and Age ranges collapse to a one-line summary of their tagged chips + — they rarely change. items-start (not stretch) so a collapsed column keeps + its natural height. Both open on a new person or when there are errors. %> +
    -
    -
    - Sectors -
    -
    + <% editable_sector_items = sectors_owner.sectorable_items_ordered.reject { |item| item.sector&.name == Sector::OTHER_SECTOR_NAME } %> + <% sectors_open = !sectors_owner.persisted? || sectors_owner.errors.any? %> +
    > + + Sectors: + <%= sectors_owner.decorate.sectors_summary %> + +
    - <% sectors_owner = f.object.respond_to?(:object) ? f.object.object : f.object %> - <%# "Other" is never a real sector tag (it's captured as an OtherResponse), - so drop any stray legacy tagging from the editable list. %> - <% editable_sector_items = sectors_owner.sectorable_items_ordered.reject { |item| item.sector&.name == Sector::OTHER_SECTOR_NAME } %> <%= f.simple_fields_for :sectorable_items, editable_sector_items do |sfi| %> <%= render "shared/sectorable_item_fields", f: sfi, show_admin_flags: true %> <% end %> @@ -165,20 +199,22 @@ class: "btn btn-secondary-outline" %> <%= render "people/other_sector_responses", responses: @person.other_sector_responses, dismissable: true, curatable: true, return_to: "person_edit" %>
    -
    + <% if @age_ranges_collection.present? %> -
    -
    - Age ranges -
    -
    > + + Age ranges: + <%= age_owner.decorate.age_ranges_summary %> + +
    - <% age_owner = f.object.respond_to?(:object) ? f.object.object : f.object %> <%= f.simple_fields_for :age_range_categorizable_items, age_owner.age_range_items_ordered do |afi| %> <%= render "shared/age_range_item_fields", f: afi %> <% end %> @@ -192,7 +228,7 @@ .reject { |_, id| (@current_age_range_category_ids || []).include?(id) } } }, class: "btn btn-secondary-outline" %>
    -
    + <% end %>
    @@ -201,17 +237,26 @@ <% if other_category_types.present? %>
    <% other_category_types.each do |type, cats| %> -
    -

    <%= type.display_label %>

    -
    - <% cats.each do |category| %> - <%= render "shared/category_checkbox", param_key: "person", category: category, - checked: @person.category_ids.include?(category.id), - is_age: false, - primary_checked: false %> - <% end %> + <%# Collapsed to a one-line summary of the current selections so the long + option grid doesn't have to be scrolled past every edit. %> + <% selected_names = cats.select { |category| @person.category_ids.include?(category.id) }.map(&:name) %> +
    + + <%= type.display_label %>: + <%= selected_names.any? ? selected_names.to_sentence : "None selected" %> + + +
    +
    + <% cats.each do |category| %> + <%= render "shared/category_checkbox", param_key: "person", category: category, + checked: @person.category_ids.include?(category.id), + is_age: false, + primary_checked: false %> + <% end %> +
    -
    + <% end %> <% workshop_other = @person.other_workshop_setting_responses %> <% if workshop_other.any? %> @@ -225,12 +270,18 @@ <% person = f.object.respond_to?(:object) ? f.object.object : f.object %> <% decorated = person.decorate %> -
    -
    - Affiliations (only editable by admins) -
    +
    + + Affiliations + <% affiliation_count = person.affiliations.reject(&:marked_for_destruction?).size %> + <% if affiliation_count.positive? %> + <%= pluralize(affiliation_count, "affiliation") %> + <% end %> + (only editable by admins) + + -
    +
    <% if allowed_to?(:manage?, Person) && params[:admin].present? %>
    <%= f.input :member_since, @@ -256,7 +307,7 @@ -
    -
    -
    - Background -
    + <%# Collapsible like the sections above. Stays open on a new person or when the + form has errors so nothing hides behind the summary. %> + <% background_open = !f.object.persisted? || f.object.errors.any? %> +
    > + + Background + <% if f.object.bio.present? %> + Bio present + <% end %> + <% if f.object.shoutout_text.present? %> + Shout-out present + <% end %> + + -
    +
    <%# Bio and Shout-out share one row, each half width. %>
    @@ -371,13 +432,19 @@
    -
    - -
    -
    - Professional licenses -
    -
    +
    + + <%# Collapsible — collapses to a count of licenses on file. Open on a new person + or when there are errors so an invalid license stays visible. %> + <% licenses = f.object.professional_licenses.reject(&:marked_for_destruction?) %> + <% licenses_open = !f.object.persisted? || f.object.errors.any? %> +
    > + + Professional licenses: + <%= licenses.any? ? licenses.map(&:name).to_sentence : "None" %> + + +

    Used for continuing-education credit. Once a license has CE registrations it can't be removed, and only an admin can edit it.

    <%= f.simple_fields_for :professional_licenses do |license_form| %> <%= render "professional_licenses/professional_license_fields", f: license_form %> @@ -388,9 +455,18 @@ partial: "professional_licenses/professional_license_fields", class: "btn btn-secondary-outline" %>
    -
    - -
    + + + <%# Extras collapses the less-frequently-edited fields — birthday, time zone, + and the admin-only demographics/FileMaker block. Open on a new person or + when there are errors. %> + <% extras_open = !f.object.persisted? || f.object.errors.any? %> +
    > + + Extras + + +
    @@ -455,13 +531,16 @@
    <% end %>
    +
    -
    -
    - Social media links -
    +
    + + Social media links + <%= @person.decorate.social_media_summary %> + + -
    +
    <%= f.input :linked_in_url, as: :text, input_html: { rows: 1 } %> <%= f.input :facebook_url, as: :text, input_html: { rows: 1 } %> @@ -470,14 +549,16 @@ <%= f.input :twitter_url, as: :text, input_html: { rows: 1 } %>
    -
    +
    -
    -
    - Profile display preferences -
    +
    + + Profile display preferences + <%= f.object.decorate.profile_display_summary %> + + -
    +
    <%= f.input :profile_is_searchable, @@ -525,7 +606,7 @@ <%= f.input :profile_show_workshop_logs, label: "Show workshop logs" %>
    -
    +
    <% if f.object.persisted? %> <%# Communications — subjects are visible to the person; the body and the @@ -535,10 +616,17 @@ <% if allowed_to?(:manage?, Comment) %> -
    -
    Comments
    +
    + <% comment_count = f.object.comments.reject(&:marked_for_destruction?).size %> + + Comments + <% if comment_count.positive? %> + <%= pluralize(comment_count, "comment") %> + <% end %> + + -
    <%= f.simple_fields_for :comments do |cf| %> @@ -560,7 +648,7 @@ partial: "comments/comment_fields", class: "btn btn-secondary-outline" %>
    -
    + <% end %> <% end %> diff --git a/app/views/shared/_form_image_field.html.erb b/app/views/shared/_form_image_field.html.erb index e9bb300a0e..f0ef6184f5 100644 --- a/app/views/shared/_form_image_field.html.erb +++ b/app/views/shared/_form_image_field.html.erb @@ -16,6 +16,9 @@ <% hint ||= nil %> <% rounded ||= false %> <% multiple ||= false %> +<%# Tailwind size classes for the preview/placeholder box; callers can pass a + smaller box (e.g. "w-24 h-24") to keep a section compact. %> +<% size ||= "w-32 h-32" %> <% file ||= nil %> <% use_profile_placeholder ||= false %> <% show_file_field = show_file_field.to_s.present? ? show_file_field : true %> @@ -52,7 +55,7 @@ id: image_dom_id, alt: "#{label}", data: { file_preview_target: "preview" }, - class: "w-32 h-32 object-cover border border-gray-300 shadow-sm #{rounded ? 'rounded-full' : ''}" %> + class: "#{size} object-cover border border-gray-300 shadow-sm #{rounded ? 'rounded-full' : ''}" %> <%# ---------- PDF PREVIEW ---------- %> <% elsif is_pdf && file.previewable? %> @@ -60,14 +63,14 @@ id: image_dom_id, alt: "#{label} PDF preview", data: { file_preview_target: "preview" }, - class: "w-32 h-32 object-contain border border-gray-300 shadow-sm" %> + class: "#{size} object-contain border border-gray-300 shadow-sm" %> <%# ---------- PLACEHOLDER ---------- %> <% else %> -
    +
    + class="hidden <%= size %> object-cover border border-gray-300 shadow-sm">