Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/decorators/application_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 48 additions & 0 deletions app/decorators/organization_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions app/decorators/person_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 6 additions & 4 deletions app/views/contact_methods/_contact_method_fields.html.erb
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<div class="nested-fields rounded-lg bg-white border border-gray-200 p-6 mb-4">
<%= f.hidden_field :kind, value: :phone %>
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
<%# 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. %>
<div class="flex flex-wrap items-end gap-x-4 gap-y-3 mb-6">
<!-- Contact Type -->
<div>
<div class="min-w-[8rem] flex-1">
<%= f.input :contact_type,
as: :select,
required: true,
Expand All @@ -12,7 +14,7 @@
</div>

<!-- Value -->
<div>
<div class="min-w-[8rem] flex-1">
<%= f.input :value,
wrapper: false,
label: "Value",
Expand All @@ -39,7 +41,7 @@
input_html: { class: "mr-2 text-blue-600 focus:ring-blue-500" } %>
</div>

<div class="text-right text-end">
<div class="ml-auto text-end">
<!-- Remove button -->
<%= link_to_remove_association "Remove",
f,
Expand Down
29 changes: 17 additions & 12 deletions app/views/notifications/_communications.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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) : {} %>
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm" data-controller="edit-toggle">
<div class="flex items-center gap-3 border-b border-gray-100 px-4 py-3">
<%# Collapsible (expanded by default) so long communication logs don't dominate
the form; the header row is the disclosure summary. %>
<details class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm group" data-controller="edit-toggle" open>
<summary class="flex items-center gap-3 border-b border-gray-100 px-4 py-3 cursor-pointer list-none [&::-webkit-details-marker]:hidden hover:bg-gray-50">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg <%= DomainTheme.bg_class_for(:users, intensity: 100) %> <%= DomainTheme.text_class_for(:users, intensity: 600) %>">
<i class="fa-solid fa-bell"></i>
</span>
<h2 class="text-sm font-semibold text-gray-700"><%= t(title_key) %></h2>
<% 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
<i class="fa-solid fa-arrow-up-right-from-square text-[0.6rem]"></i>
<% end %>
<% end %>
</div>
<i class="fa-solid fa-chevron-down ml-auto text-xs text-gray-400 transition-transform group-open:rotate-180"></i>
</summary>

<div class="space-y-3 p-4">
<% if email.present? && admin %>
<div class="flex justify-end">
<%= 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
<i class="fa-solid fa-arrow-up-right-from-square text-[0.6rem]"></i>
<% end %>
</div>
<% end %>
<div data-controller="paginated-fields" data-paginated-fields-per-page-value="5" class="space-y-2">
<div id="<%= list_id %>" class="space-y-2">
<% notifications.each do |notification| %>
Expand Down Expand Up @@ -93,4 +98,4 @@
</div>
<% end %>
</div>
</section>
</details>
18 changes: 11 additions & 7 deletions app/views/organizations/_associated_records.html.erb
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
<% return unless organization.persisted? %>
<% org_filter = { organization_id: organization.id } %>
<% monthly_reports = MonthlyReport.where(org_filter) %>
<ul class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-x-6 gap-y-2">
<li><%= index_button CommunityNews.where(org_filter), params: org_filter %></li>
<li><%= index_button Story.where(org_filter), params: org_filter %></li>
<li><%= index_button organization.workshop_logs, params: org_filter %></li>
<li><%= index_button organization.event_registrations, params: org_filter %></li>
<li><%= index_button StoryIdea.where(org_filter), params: org_filter %></li>
<%# 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. %>
<ul class="flex flex-col md:flex-row gap-x-6 gap-y-2">
<li class="md:flex-1 md:min-w-0"><%= index_button CommunityNews.where(org_filter), params: org_filter, wrap: true %></li>
<li class="md:flex-1 md:min-w-0"><%= index_button organization.event_registrations, params: org_filter, wrap: true %></li>
<% if monthly_reports.exists? %>
<li><%= index_button monthly_reports, params: org_filter %></li>
<li class="md:flex-1 md:min-w-0"><%= index_button monthly_reports, params: org_filter, wrap: true %></li>
<% end %>
<li class="md:flex-1 md:min-w-0"><%= index_button Story.where(org_filter), params: org_filter, wrap: true %></li>
<li class="md:flex-1 md:min-w-0"><%= index_button StoryIdea.where(org_filter), params: org_filter, wrap: true, label: safe_join([ "Story", tag.br, "ideas" ]) %></li>
<li class="md:flex-1 md:min-w-0"><%= index_button organization.workshop_logs, params: org_filter, wrap: true %></li>
</ul>
Loading