Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,12 +285,12 @@ end
- `cocoon` β€” Nested form handling (cocoon gem)
- `collection` β€” Filter form auto-submit with debounce
- `column_toggle` β€” Toggle table column visibility
- `comment_edit_toggle` β€” Inline comment editing mode
- `comment_required` β€” Require a comment body once a topic or body is filled in
- `confirm_email` β€” Email confirmation UI
- `dirty_form` β€” Unsaved changes detection
- `dismiss` β€” Dismissable elements
- `dropdown` β€” Dropdown menus with keyboard/click-outside handling
- `edit_toggle` β€” Inline view/edit toggle for the comments and communications boxes (configurable view/edit CSS classes)
- `event_staff_bio` β€” Loads a selected person's read-only profile bio (with edit link) alongside the editable event-specific bio on the staff form
- `file_preview` β€” File upload preview
- `grant_details` β€” Swaps a grant's eligibility criteria + tasks when the grant picker changes
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ def event_registration_params
organization_ids: [],
registrant_attributes: [ :id, :shoutout_text ],
comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ],
notifications_attributes: [ :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id ]
notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ]
)
end

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ def person_params
:_destroy
],
comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ],
notifications_attributes: [ :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id ]
notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ]
)
end
end
2 changes: 1 addition & 1 deletion app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def scholarship_params
params.require(:scholarship).permit(
:amount_dollars, :amount_cents, :tasks_completed, :grant_id, :recipient_id,
comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ],
notifications_attributes: [ :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id ]
notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ]
)
end

Expand Down

This file was deleted.

64 changes: 64 additions & 0 deletions app/frontend/javascript/controllers/edit_toggle_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Controller } from "@hotwired/stimulus";

// Connects to data-controller="edit-toggle"
//
// Toggles a list of records between read-only view blocks and inline edit forms
// (used by the comments and communications boxes on the Person / Registration /
// Scholarship edit forms). Each persisted record renders a `.{view}` block and a
// hidden `.{edit}` block; clicking the toggle flips which is shown and swaps the
// button label.
//
// Configure the block classes per caller:
// data-edit-toggle-view-class-value="comment-view"
// data-edit-toggle-edit-class-value="comment-edit"
// data-edit-toggle-body-class-value="comment-body" (optional)
//
// When a body class is given, leaving edit mode syncs each edit textarea back
// into the matching truncated `.{body}` span so the view reflects unsaved edits.
//
export default class extends Controller {
static targets = ["editLabel", "viewLabel"];
static values = {
viewClass: { type: String, default: "editable-view" },
editClass: { type: String, default: "editable-edit" },
bodyClass: { type: String, default: "" },
Comment on lines +22 to +24

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
viewClass: { type: String, default: "editable-view" },
editClass: { type: String, default: "editable-edit" },
bodyClass: { type: String, default: "" },

I believe these could all be done with stimulus targets instead of setting a class as a selector.

truncate: { type: Number, default: 135 }
};

connect() {
this.editing = false;
}

toggle() {
this.editing = !this.editing;

// When leaving edit mode, sync edit textareas into their truncated view.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Generalized from comment-edit-toggle. The body-sync (updating the truncated view from the textarea on exit) is now opt-in via bodyClassValue β€” comments set it, communications leave it blank since their view row has no single truncated body span.

if (!this.editing && this.bodyClassValue) {
this.element.querySelectorAll(".nested-fields").forEach((item) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a good opportunity to update this controller to use stimulus targets vs. depending on class targets.

const textarea = item.querySelector(`.${this.editClassValue} textarea`);
const viewBody = item.querySelector(
`.${this.viewClassValue} .${this.bodyClassValue}`
);
if (textarea && viewBody) {
const text = textarea.value;
const max = this.truncateValue;
viewBody.textContent =
text.length > max ? text.substring(0, max - 3) + "..." : text;
viewBody.title = text;
}
});
}

this.element
.querySelectorAll(`.${this.viewClassValue}`)
.forEach((el) => (el.style.display = this.editing ? "none" : ""));
this.element
.querySelectorAll(`.${this.editClassValue}`)
.forEach((el) => (el.style.display = this.editing ? "" : "none"));

if (this.hasEditLabelTarget && this.hasViewLabelTarget) {
this.editLabelTarget.style.display = this.editing ? "none" : "";
this.viewLabelTarget.style.display = this.editing ? "" : "none";
}
}
}
4 changes: 2 additions & 2 deletions app/frontend/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ application.register("confirm-email", ConfirmEmailController)
import ColumnToggleController from "./column_toggle_controller"
application.register("column-toggle", ColumnToggleController)

import CommentEditToggleController from "./comment_edit_toggle_controller"
application.register("comment-edit-toggle", CommentEditToggleController)
import EditToggleController from "./edit_toggle_controller"
application.register("edit-toggle", EditToggleController)

import CommentRequiredController from "./comment_required_controller"
application.register("comment-required", CommentRequiredController)
Expand Down
2 changes: 1 addition & 1 deletion app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class EventRegistration < ApplicationRecord
has_many :checklist_completions, class_name: "EventRegistrationChecklistCompletion", dependent: :destroy

accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? }
accepts_nested_attributes_for :notifications, reject_if: proc { |attrs| attrs["email_subject"].blank? }
accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? }
# Lets the registration edit form edit the registrant's shout-out text (which
# lives on the Person) inline, alongside the registration's own shout-out flag.
accepts_nested_attributes_for :registrant
Expand Down
2 changes: 1 addition & 1 deletion app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class Person < ApplicationRecord
accepts_nested_attributes_for :affiliations, allow_destroy: true,
reject_if: proc { |attrs| attrs["organization_id"].blank? }
accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? }
accepts_nested_attributes_for :notifications, reject_if: proc { |attrs| attrs["email_subject"].blank? }
accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: allow_destroy: true (with :_destroy permitted in the controller) lets the inline "Remove" link delete a logged entry β€” the reject_if blank-subject guard is bypassed for destroys, matching how comments work.


# Search Cop
include SearchCop
Expand Down
2 changes: 1 addition & 1 deletion app/models/scholarship.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class Scholarship < ApplicationRecord
has_many :notifications, as: :noticeable, dependent: :destroy

accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? }
accepts_nested_attributes_for :notifications, reject_if: proc { |attrs| attrs["email_subject"].blank? }
accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? }

validates :amount_cents, numericality: { greater_than_or_equal_to: 0 }
validate :recipient_must_match_allocation_registrant
Expand Down
8 changes: 4 additions & 4 deletions app/views/event_registrations/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@
<% end %>

<%# ---- Comments ---- %>
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm scroll-mt-24" id="comments-section" data-controller="comment-edit-toggle">
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm scroll-mt-24" id="comments-section" data-controller="edit-toggle" data-edit-toggle-view-class-value="comment-view" data-edit-toggle-edit-class-value="comment-edit" data-edit-toggle-body-class-value="comment-body">
<div class="flex items-center gap-3 border-b border-gray-100 px-4 py-3">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg <%= DomainTheme.bg_class_for(:comments, intensity: 100) %> <%= DomainTheme.text_class_for(:comments, intensity: 600) %>">
<i class="fa-solid fa-message"></i>
Expand Down Expand Up @@ -337,11 +337,11 @@
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 cursor-pointer"
data-action="click->comment-edit-toggle#toggle"
data-action="click->edit-toggle#toggle"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
data-action="click->edit-toggle#toggle"
data-action="edit-toggle#toggle"

Click is implicit on buttons

>
<i class="fa-solid fa-pen text-[0.6rem]"></i>
<span data-comment-edit-toggle-target="editLabel">Edit comments</span>
<span data-comment-edit-toggle-target="viewLabel" style="display:none">Done editing</span>
<span data-edit-toggle-target="editLabel">Edit comments</span>
<span data-edit-toggle-target="viewLabel" style="display:none">Done editing</span>
</button>
</div>
</div>
Expand Down
64 changes: 38 additions & 26 deletions app/views/event_registrations/_notifications.html.erb
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<%# ---- Notifications β€” communications for this registrant, newest first.
Persisted entries are read-only and paginated like the comments box; new
manual log entries (email/phone/text/video) can be added inline and are
saved with the registration form. Links open in a new tab so unsaved edits
aren't lost. ---- %>
Automated entries are read-only; manually logged entries addressed to this
registration can be edited inline (toggle "Edit communications", like the
comments box) and new ones added, both saved with the registration form.
Links open in a new tab so unsaved edits aren't lost. ---- %>
<% registration = f.object %>
<% email = registration.registrant.preferred_email %>
<% notifications = email.present? ? Notification.email(email).order(created_at: :desc) : Notification.none %>
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<% new_notifications = registration.notifications.select(&:new_record?) %>
<% editable_notifications = notifications.select { |n| n.manual_log? && n.noticeable_type == EventRegistration.name && n.noticeable_id == registration.id } %>
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm" data-controller="edit-toggle" data-edit-toggle-view-class-value="notification-view" data-edit-toggle-edit-class-value="notification-edit">
<div class="flex items-center gap-3 border-b border-gray-100 px-4 py-3">
<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>
Expand All @@ -23,40 +25,50 @@
</div>

<div class="space-y-3 p-4">
<% if notifications.any? %>
<div data-controller="paginated-fields" data-paginated-fields-per-page-value="5" class="space-y-2">
<div data-controller="paginated-fields" data-paginated-fields-per-page-value="5" class="space-y-2">
<div id="registration-notification-list" class="space-y-2">
<% notifications.each do |notification| %>
<% subject = notification.email_subject.presence || notification.kind.to_s.humanize %>
<% body = notification.email_body_text.to_s %>
<div data-paginated-fields-target="item" class="flex items-baseline gap-x-2">
<span class="shrink-0 whitespace-nowrap text-xs text-gray-400"><%= notification.created_at.strftime("%-m/%-d/%Y") %></span>
<span class="shrink-0 whitespace-nowrap text-xs font-medium text-gray-500"><%= notification.sender&.full_name.presence || "AWBW Portal" %></span>
<span class="min-w-0 flex-1 truncate text-sm" title="<%= [ subject, body.presence ].compact.join(" β€” ") %>">
<span class="font-semibold text-gray-900"><%= subject %></span>
<% if body.present? %>
<span class="text-gray-500"><%= body %></span>
<% end %>
</span>
</div>
<% if editable_notifications.include?(notification) %>
<%= f.simple_fields_for :notifications, notification do |nf| %>
<%= render "notifications/notification_fields", f: nf, record: registration %>
<% end %>
<% else %>
<div data-paginated-fields-target="item">
<%= render "notifications/notification_row", notification: notification %>
</div>
<% end %>
<% end %>
<%# New, unsaved entries β€” re-rendered here on validation error. %>
<%= f.simple_fields_for :notifications, new_notifications do |nf| %>
<%= render "notifications/notification_fields", f: nf, record: registration %>
<% end %>
<div data-paginated-fields-target="nav"></div>
</div>
<% else %>
<div data-paginated-fields-target="nav"></div>
</div>

<% if notifications.empty? && new_notifications.empty? %>
<p class="text-sm text-gray-400"><%= t("communications.none_for_registrant") %></p>
<% end %>

<%# Inline add β€” manual log entries, saved with the registration form. %>
<div class="space-y-2 border-t border-gray-100 pt-3">
<%= f.simple_fields_for :notifications, registration.notifications.select(&:new_record?) do |nf| %>
<%= render "notifications/notification_fields", f: nf, record: registration %>
<% end %>
<div class="flex items-center justify-between gap-2 border-t border-gray-100 pt-3">
<%= link_to_add_association f, :notifications,
partial: "notifications/notification_fields",
render_options: { locals: { record: registration } },
data: { association_insertion_node: "#registration-notification-list", association_insertion_method: "append" },
class: "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 cursor-pointer" do %>
<i class="fa-solid fa-plus text-[0.6rem]"></i>
<%= t("communications.add") %>
<% end %>

<% if editable_notifications.any? %>
<button type="button"
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 cursor-pointer"
data-action="click->edit-toggle#toggle">
<i class="fa-solid fa-pen text-[0.6rem]"></i>
<span data-edit-toggle-target="editLabel"><%= t("communications.edit") %></span>
<span data-edit-toggle-target="viewLabel" style="display:none"><%= t("communications.done_editing") %></span>
</button>
<% end %>
</div>
</div>
</section>
51 changes: 23 additions & 28 deletions app/views/notifications/_notification_fields.html.erb
Original file line number Diff line number Diff line change
@@ -1,41 +1,36 @@
<%# New, unsaved manual notification log β€” sender, channel, and subject on the
first row, body beneath. Fields use SimpleForm styling. Amber until the
registration form is saved; a blank sender means the system ("AWBW Portal").
Persisted notifications render read-only in the registration's notifications
box, not through this partial. ---- %>
<%# ---- One manual-log communication, editable inline (mirrors the comment
edit-mode pattern). A persisted entry renders a read-only `.notification-view`
row plus a hidden `.notification-edit` form; the shared `edit-toggle`
controller flips between them. A new, unsaved entry renders the amber add
form directly. Only manually logged communications addressed to this record
render through this partial β€” automated notifications stay read-only in the
notifications box. A blank sender means the system ("AWBW Portal"). ---- %>
<% sender_users = User.where(super_user: true).includes(:person).to_a %>
<% sender_users |= [ current_user ] if current_user %>
<% sender_options = sender_users.sort_by { |u| u.full_name.to_s.downcase }.map { |u| [ u.full_name, u.id ] } %>
<% channel_default = Notification::MANUAL_CHANNELS.include?(f.object.channel) ? f.object.channel : "email" %>
<% record = local_assigns[:record] %>
<div class="nested-fields rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="nested-fields" data-paginated-fields-target="item">
<%# Record this communication against its parent (the "Record" column in the
notifications index) β€” e.g. an event registration or a person. %>
<% if record %>
<%= f.hidden_field :noticeable_type, value: record.class.name %>
<%= f.hidden_field :noticeable_id, value: record.id %>
<% end %>
<div class="grid grid-cols-1 gap-x-4 sm:grid-cols-6">
<%= f.input :sender_id,
collection: sender_options,
include_blank: "AWBW Portal",
selected: f.object.sender_id || current_user&.id,
label: "From",
wrapper_html: { class: "sm:col-span-2" } %>
<%= f.input :channel,
collection: Notification::MANUAL_CHANNELS.map { |c| [ c.titleize, c ] },
include_blank: false,
selected: channel_default,
label: "Channel",
wrapper_html: { class: "sm:col-span-1" } %>
<%= f.input :email_subject, label: "Subject", placeholder: "Topic or subject line",
input_html: { rows: 1 }, wrapper_html: { class: "sm:col-span-3" } %>
</div>

<%= f.input :email_body_text, as: :text, label: "Body", input_html: { rows: 3, placeholder: "What happened? (e.g. left voicemail, sent reminder)" } %>

<div class="text-right">
<%= link_to_remove_association "Remove", f,
class: "text-sm text-gray-400 underline hover:text-red-600" %>
</div>
<% if f.object.persisted? %>
<%= f.hidden_field :id %>
<div class="notification-view">
<%= render "notifications/notification_row", notification: f.object %>
</div>
<div class="notification-edit rounded-md border border-amber-200 bg-amber-50 p-3" style="display:none">
<%= render "notifications/notification_form", f: f, sender_options: sender_options,
sender_selected: f.object.sender_id, channel_default: channel_default %>
</div>
<% else %>
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<%= render "notifications/notification_form", f: f, sender_options: sender_options,
sender_selected: f.object.sender_id || current_user&.id, channel_default: channel_default %>
</div>
<% end %>
</div>
Loading