diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index c556a2f87e..5aaf4fdce5 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -721,13 +721,16 @@ def format_duplicate(person, exact: true, entered_email: nil, entered_emails: ni end # Where to send the admin after a successful update. Defaults to the person's - # profile, but returns to Data health or the registration org-linking page when - # the edit was opened from there (preserving that page's own return_to). + # profile, but returns to Data health, the registration org-linking page, or the + # comments & communications feed when the edit was opened from there + # (preserving that page's own return_to). def person_update_return_path if params[:return_to] == "data_health" helpers.data_health_return_path(params[:check]) elsif params[:return_to] == "dedupe" dedupe_index_people_path(anchor: "dedupe-record-#{@person.id}") + elsif params[:return_to] == CommentsHelper::FEED_RETURN_TO + helpers.feed_return_path elsif params[:return_to] == "registration_link" && params[:event_registration_id].present? link_organization_event_registration_path(params[:event_registration_id], return_to: params[:link_org_return_to].presence) else diff --git a/app/decorators/affiliation_decorator.rb b/app/decorators/affiliation_decorator.rb index 9888b34098..3e02ad9324 100644 --- a/app/decorators/affiliation_decorator.rb +++ b/app/decorators/affiliation_decorator.rb @@ -32,10 +32,31 @@ def period_label "Dates not recorded" end + # E.g. "Started March 2023, ending next month" — relative wording for months + # close to now, so an imminent lapse reads at a glance. + def period_note + parts = [] + parts << "#{start_date > Date.current ? 'starting' : 'started'} #{relative_month(start_date)}" if start_date + parts << "#{end_date < Date.current ? 'ended' : 'ending'} #{relative_month(end_date)}" if end_date + parts.join(", ").upcase_first.presence || "Dates not recorded" + end + # Where a back link should land on the person/organization editor. An inactive # row sits on the Inactive tab, so jumping to the row itself would scroll to # something the page isn't showing — land on the section instead. def return_anchor active? ? h.dom_id(object) : "affiliations" end + + private + + def relative_month(date) + today = Date.current + case (date.year * 12 + date.month) - (today.year * 12 + today.month) + when -1 then "last month" + when 0 then "this month" + when 1 then "next month" + else date.strftime("%B %Y") + end + end end diff --git a/app/decorators/comment_decorator.rb b/app/decorators/comment_decorator.rb index 87b36cf4dd..907e177529 100644 --- a/app/decorators/comment_decorator.rb +++ b/app/decorators/comment_decorator.rb @@ -27,6 +27,13 @@ def dedupe_label body.to_s.squish.truncate(ModelDeduper::SNIPPET_LENGTH, separator: " ") end + # The person this comment is about: whoever its source chip names, or the person + # behind any other record it's on (affiliation, author, …). nil for records that + # belong to no one person, like an event or organization. + def about_person + h.record_chip_person(commentable) || person_behind_commentable + end + def author_name (updated_by || created_by)&.full_name || "System" end @@ -57,6 +64,15 @@ def event private + def person_behind_commentable + case commentable + when User, Affiliation, TopicSubscription, FormSubmission then commentable.person + when StaffTagging then commentable.staff_taggable if commentable.staff_taggable.is_a?(Person) + when Grant then commentable.funder if commentable.funder.is_a?(Person) + else commentable.author_person if commentable.respond_to?(:author_person) + end + end + # Root record the shared event chip resolves a registration/event from. def event_chip_record commentable diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 22af74e630..d33d5ff7bc 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -205,6 +205,19 @@ def facilitator_status_label "Inactive" end + # Facilitator rows running now, earliest first. + def active_facilitator_affiliations + affiliations.select { |affiliation| affiliation.facilitator? && affiliation.active? } + .sort_by { |affiliation| affiliation.start_date || Date.current } + end + + # Their latest facilitator-training registrations, newest event first, whatever + # the attendance outcome. + def recent_facilitator_training_registrations(limit: 3) + event_registrations.joins(:event).merge(Event.facilitator_trainings) + .includes(:event).order(events: { start_date: :desc }).limit(limit) + end + def facilitator_since_range date_range_display(facilitator_since_date, facilitation_end_date, ended_title: "No active facilitator affiliations") end diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb index d496228f41..bad95d68cf 100644 --- a/app/helpers/comments_helper.rb +++ b/app/helpers/comments_helper.rb @@ -1,5 +1,6 @@ module CommentsHelper FEED_FILTER_FORM_ID = "comments-and-communications-filters" + FEED_RETURN_TO = "comments_and_communications".freeze # The full feed narrowed to one comment or communication, carrying the person # it's about and the record to return to for the eyebrow. @@ -13,6 +14,31 @@ def feed_entry_path(entry, person: nil, return_to: nil) }.compact) end + # A person chip's edit link, remembering which feed (a person's, or everyone's) + # to return to. + def feed_person_edit_path(person, feed_person: nil) + edit_person_path(person, { return_to: FEED_RETURN_TO, feed_person_id: feed_person&.id }.compact) + end + + def feed_return_path + comments_and_communications_path({ person_id: params[:feed_person_id].presence }.compact) + end + + # Names who a row on the everyone feed is about. A communication's contact + # falls back to their raw email when no person is on file. + def feed_person_chip(entry) + if entry.is_a?(Comment) + person = entry.decorate.about_person + email = nil + else + person = entry.decorate.contact_person + email = entry.recipient_email + end + return if person.nil? && email.blank? + + render "comments_and_communications/about_person_chip", person: person, email: email + end + # Stimulus wiring for a feed row the chevron and "Expand/Collapse all" toggle. # clamp: a one-line row unclamped to show the full entry; otherwise a full row # whose body hides on collapse. key lets expandable-cards remember a row toggled diff --git a/app/helpers/person_helper.rb b/app/helpers/person_helper.rb index c1b5fcc1d3..d191f4425e 100644 --- a/app/helpers/person_helper.rb +++ b/app/helpers/person_helper.rb @@ -85,7 +85,7 @@ def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name: # its own line below, mirroring person_profile_button. Admin-facing, so it shows # the full name — the person's display-name preference (first-name-only, etc.) # governs public credits, not what an admin sees here. - def person_edit_button(person, layout: :prefix, truncate_at: nil, subtitle: nil, display_name: nil, data: {}, inactive: false, tint: nil, path_params: {}, width_class: "w-full", compact: false) + def person_edit_button(person, layout: :prefix, truncate_at: nil, subtitle: nil, display_name: nil, data: {}, inactive: false, tint: nil, path_params: {}, width_class: "w-full", compact: false, show_title: true) padding = compact ? "px-2 py-1" : "px-4 py-2" name_text_size = compact ? "text-xs" : "text-sm" @@ -98,7 +98,7 @@ def person_edit_button(person, layout: :prefix, truncate_at: nil, subtitle: nil, link_to edit_person_path(person, **path_params), data: { turbo_prefetch: false }.merge(data), - title: hover_title, + title: (hover_title if show_title), class: "group relative flex items-center gap-2 #{width_class} #{padding} border #{palette[:border]} #{palette[:bg]} #{palette[:hover_bg]} rounded-lg @@ -156,6 +156,13 @@ def user_link(user, data: {}) # A piece the person hid (a `profile_show_*` flag turned off) stays visible to # them and to admins under the blue admin-or-owner wash. `flag` is a # `profile_show_*` attribute name (symbol). + # Memoized per person so a feed naming the same person on every row renders + # (and queries) their card once. + def facilitator_hover_card(person, header: false) + @facilitator_hover_cards ||= {} + @facilitator_hover_cards[[ person.id, header ]] ||= render("people/facilitator_hover_card", person: person, header: header) + end + def profile_piece_shown?(person, flag) person.public_send(:"#{flag}?") || profile_piece_hidden_for_viewer?(person, flag) end diff --git a/app/views/comments_and_communications/_about_person_chip.html.erb b/app/views/comments_and_communications/_about_person_chip.html.erb new file mode 100644 index 0000000000..69197a2b8b --- /dev/null +++ b/app/views/comments_and_communications/_about_person_chip.html.erb @@ -0,0 +1,18 @@ +<%# Who an everyone-feed row is about: a link to edit that person, with their + facilitator hover card, or a plain email chip when no person is on + file. Locals: person (may be nil), email. %> +<% chip_class = "inline-flex max-w-48 shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium #{DomainTheme.border_class_for(:people, intensity: 200)} #{DomainTheme.bg_class_for(:people, intensity: 50)} #{DomainTheme.text_class_for(:people)}" %> +<% if person %> + + <%= link_to feed_person_edit_path(person), target: "_blank", rel: "noopener", class: "#{chip_class} hover:underline" do %> + + <%= person.full_name %> + <% end %> + <%= facilitator_hover_card(person, header: true) %> + +<% else %> + + + <%= email %> + +<% end %> diff --git a/app/views/comments_and_communications/_comment_row.html.erb b/app/views/comments_and_communications/_comment_row.html.erb index 5f57f453fb..68ba13e4b5 100644 --- a/app/views/comments_and_communications/_comment_row.html.erb +++ b/app/views/comments_and_communications/_comment_row.html.erb @@ -7,6 +7,8 @@ person feed's records the comment can be moved to). %> <% person = local_assigns[:person] %> <% source_chip = feed_source_chip(entry.commentable, icon: content_tag(:i, "", class: "fa-solid fa-comment text-gray-500", "aria-hidden": "true", title: "Comment")) %> +<%# Only the everyone feed names who each row is about. %> +<% leading = local_assigns[:person] ? source_chip : safe_join([ feed_person_chip(entry), source_chip || entry.decorate.type_chip ].compact, " ") %> <% edit_button = content_tag(:button, safe_join([ content_tag(:i, "", class: "fa-solid fa-pen text-2xs"), " Edit" ]), type: "button", class: "ml-auto shrink-0 text-xs #{eyebrow_link_class}", @@ -16,7 +18,7 @@ data: { edit_toggle_target: "view", **expandable_feed_row_data(clamp: false, key: dom_id(entry)) } do %>
<%= render "comments/flag_toggle", comment: entry %> - <%= render "comments/comment_line", comment: entry, truncate: false, leading: source_chip, trailing: edit_button, expandable: true, + <%= render "comments/comment_line", comment: entry, truncate: false, leading: leading, trailing: edit_button, expandable: true, date_path: feed_entry_path(entry, person: person) %>
<% end %> diff --git a/app/views/comments_and_communications/_communication_row.html.erb b/app/views/comments_and_communications/_communication_row.html.erb index 9229535fd3..d648fc8342 100644 --- a/app/views/comments_and_communications/_communication_row.html.erb +++ b/app/views/comments_and_communications/_communication_row.html.erb @@ -8,6 +8,8 @@ on the save so the re-rendered row stays scoped to them). %> <% person = local_assigns[:person] %> <% source_chip = feed_source_chip(entry.noticeable, icon: entry.decorate.channel_icon) %> +<%# Only the everyone feed names who each row is about. %> +<% leading = local_assigns[:person] ? source_chip : safe_join([ feed_person_chip(entry), source_chip || entry.decorate.channel_chip ].compact, " ") %> <% edit_button = content_tag(:button, safe_join([ content_tag(:i, "", class: "fa-solid fa-pen text-2xs"), " Edit" ]), type: "button", class: "shrink-0 text-xs #{eyebrow_link_class}", @@ -17,8 +19,8 @@
<%= tag.div data: { edit_toggle_target: "view", **expandable_feed_row_data(clamp: false, key: dom_id(entry)) } do %> <%= render "notifications/communication_line", notification: entry, admin: true, truncate: false, expandable: true, - date_path: feed_entry_path(entry, person: person), - leading: source_chip, trailing: trailing, flag: render("notifications/flag_toggle", notification: entry) %> + date_path: feed_entry_path(entry, person: person), feed_person: person, + leading: leading, trailing: trailing, flag: render("notifications/flag_toggle", notification: entry) %> <% end %>