diff --git a/AGENTS.md b/AGENTS.md index b95413ced7..ad089c6f0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 692566995e..8ca1063862 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -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 diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index c7d058625f..1c51370125 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -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 diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb index dd52dd14bd..ebf71dc18a 100644 --- a/app/controllers/scholarships_controller.rb +++ b/app/controllers/scholarships_controller.rb @@ -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 diff --git a/app/frontend/javascript/controllers/comment_edit_toggle_controller.js b/app/frontend/javascript/controllers/comment_edit_toggle_controller.js deleted file mode 100644 index 108aa8abcd..0000000000 --- a/app/frontend/javascript/controllers/comment_edit_toggle_controller.js +++ /dev/null @@ -1,44 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="comment-edit-toggle" -// -// Toggles between view and edit modes for inline comment editing. -// When exiting edit mode, syncs textarea values back to the truncated display. -// -export default class extends Controller { - static targets = ["editLabel", "viewLabel"]; - - connect() { - this.editing = false; - } - - toggle() { - this.editing = !this.editing; - - // When leaving edit mode, sync textarea values to view display - if (!this.editing) { - this.element.querySelectorAll(".nested-fields").forEach((item) => { - const textarea = item.querySelector(".comment-edit textarea"); - const viewBody = item.querySelector(".comment-view .comment-body"); - if (textarea && viewBody) { - const text = textarea.value; - viewBody.textContent = - text.length > 135 ? text.substring(0, 132) + "..." : text; - viewBody.title = text; - } - }); - } - - this.element - .querySelectorAll(".comment-view") - .forEach((el) => (el.style.display = this.editing ? "none" : "")); - this.element - .querySelectorAll(".comment-edit") - .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"; - } - } -} diff --git a/app/frontend/javascript/controllers/edit_toggle_controller.js b/app/frontend/javascript/controllers/edit_toggle_controller.js new file mode 100644 index 0000000000..9fbe754fae --- /dev/null +++ b/app/frontend/javascript/controllers/edit_toggle_controller.js @@ -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: "" }, + 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. + if (!this.editing && this.bodyClassValue) { + this.element.querySelectorAll(".nested-fields").forEach((item) => { + 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"; + } + } +} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 13e2909844..834879a523 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -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) diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index d187d7165a..3a491eae5b 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -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 diff --git a/app/models/person.rb b/app/models/person.rb index 261d41482e..28719d5099 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -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? } # Search Cop include SearchCop diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index 91f7d4e6b8..f40d3b89a3 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -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 diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 4a7a474f28..3d05cb4a55 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -302,7 +302,7 @@ <% end %> <%# ---- Comments ---- %> -
+
@@ -337,11 +337,11 @@
diff --git a/app/views/event_registrations/_notifications.html.erb b/app/views/event_registrations/_notifications.html.erb index fa1aed0404..7db967589f 100644 --- a/app/views/event_registrations/_notifications.html.erb +++ b/app/views/event_registrations/_notifications.html.erb @@ -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 %> -
+<% 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 } %> +
@@ -23,40 +25,50 @@
- <% if notifications.any? %> -
+
+
<% notifications.each do |notification| %> - <% subject = notification.email_subject.presence || notification.kind.to_s.humanize %> - <% body = notification.email_body_text.to_s %> -
- <%= notification.created_at.strftime("%-m/%-d/%Y") %> - <%= notification.sender&.full_name.presence || "AWBW Portal" %> - "> - <%= subject %> - <% if body.present? %> - <%= body %> - <% end %> - -
+ <% if editable_notifications.include?(notification) %> + <%= f.simple_fields_for :notifications, notification do |nf| %> + <%= render "notifications/notification_fields", f: nf, record: registration %> + <% end %> + <% else %> +
+ <%= render "notifications/notification_row", notification: notification %> +
+ <% 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 %> -
- <% else %> +
+
+ + <% if notifications.empty? && new_notifications.empty? %>

<%= t("communications.none_for_registrant") %>

<% end %> - <%# Inline add — manual log entries, saved with the registration form. %> -
- <%= f.simple_fields_for :notifications, registration.notifications.select(&:new_record?) do |nf| %> - <%= render "notifications/notification_fields", f: nf, record: registration %> - <% end %> +
<%= 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 %> <%= t("communications.add") %> <% end %> + + <% if editable_notifications.any? %> + + <% end %>
diff --git a/app/views/notifications/_notification_fields.html.erb b/app/views/notifications/_notification_fields.html.erb index 100f88189e..0eef703885 100644 --- a/app/views/notifications/_notification_fields.html.erb +++ b/app/views/notifications/_notification_fields.html.erb @@ -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] %> -
+
<%# 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 %> -
- <%= 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" } %> -
- <%= f.input :email_body_text, as: :text, label: "Body", input_html: { rows: 3, placeholder: "What happened? (e.g. left voicemail, sent reminder)" } %> - -
- <%= link_to_remove_association "Remove", f, - class: "text-sm text-gray-400 underline hover:text-red-600" %> -
+ <% if f.object.persisted? %> + <%= f.hidden_field :id %> +
+ <%= render "notifications/notification_row", notification: f.object %> +
+ + <% else %> +
+ <%= render "notifications/notification_form", f: f, sender_options: sender_options, + sender_selected: f.object.sender_id || current_user&.id, channel_default: channel_default %> +
+ <% end %>
diff --git a/app/views/notifications/_notification_form.html.erb b/app/views/notifications/_notification_form.html.erb new file mode 100644 index 0000000000..caea737538 --- /dev/null +++ b/app/views/notifications/_notification_form.html.erb @@ -0,0 +1,26 @@ +<%# ---- The editable fields for a manual-log communication (From / Channel / + Subject / Body + Remove). Shared by a new entry and the edit mode of a + persisted one in _notification_fields. ---- %> +
+ <%= f.input :sender_id, + collection: sender_options, + include_blank: "AWBW Portal", + selected: sender_selected, + 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" } %> +
+ +<%= f.input :email_body_text, as: :text, label: "Body", input_html: { rows: 3, placeholder: "What happened? (e.g. left voicemail, sent reminder)" } %> + +
+ <%= link_to_remove_association "Remove", f, + class: "text-sm text-gray-400 underline hover:text-red-600" %> +
diff --git a/app/views/notifications/_notification_row.html.erb b/app/views/notifications/_notification_row.html.erb new file mode 100644 index 0000000000..1ed64c83b5 --- /dev/null +++ b/app/views/notifications/_notification_row.html.erb @@ -0,0 +1,15 @@ +<%# ---- Read-only display of one communication (date, sender, subject, body). + Shared by the notifications box (automated entries) and the view mode of an + editable manual-log entry in _notification_fields. ---- %> +<% subject = notification.email_subject.presence || notification.kind.to_s.humanize %> +<% body = notification.email_body_text.to_s %> +
+ <%= notification.created_at.strftime("%-m/%-d/%Y") %> + <%= notification.sender&.full_name.presence || "AWBW Portal" %> + "> + <%= subject %> + <% if body.present? %> + <%= body %> + <% end %> + +
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 855477e164..28903d6467 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -402,7 +402,7 @@
Comments
-
+
<%= f.simple_fields_for :comments do |cf| %> <%= render "comments/comment_fields", f: cf %> <% end %> @@ -413,10 +413,10 @@ type="button" class="text-sm text-gray-400 hover:text-blue-600 underline cursor-pointer whitespace-nowrap" style="float:right" - data-action="click->comment-edit-toggle#toggle" + data-action="click->edit-toggle#toggle" > - Edit Comments - Done Editing + Edit Comments + Done Editing <%= link_to_add_association "➕ Add Comment", diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index a277210426..a0a31291e4 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -524,7 +524,7 @@
Comments
<%= f.simple_fields_for :comments do |cf| %> <%= render "comments/comment_fields", f: cf %> @@ -535,9 +535,9 @@ <%= link_to_add_association "➕ Add Comment", f, diff --git a/app/views/people/_notifications.html.erb b/app/views/people/_notifications.html.erb index b68c0f0663..132a0c4c02 100644 --- a/app/views/people/_notifications.html.erb +++ b/app/views/people/_notifications.html.erb @@ -1,12 +1,15 @@ <%# ---- Notifications — communications addressed to this person, 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 person form. Links open in a new tab so unsaved edits aren't - lost. Mirrors the registration edit notifications section. ---- %> + Automated entries are read-only; manually logged entries addressed to this + person can be edited inline (toggle "Edit communications", like the comments + box) and new ones added, both saved with the person form. Links open in a + new tab so unsaved edits aren't lost. Mirrors the registration edit + notifications section. ---- %> <% person = f.object %> <% email = person.preferred_email %> <% notifications = email.present? ? Notification.email(email).order(created_at: :desc) : Notification.none %> -
+<% new_notifications = person.notifications.select(&:new_record?) %> +<% editable_notifications = notifications.select { |n| n.manual_log? && n.noticeable_type == Person.name && n.noticeable_id == person.id } %> +
@@ -23,40 +26,50 @@
- <% if notifications.any? %> -
+
+
<% notifications.each do |notification| %> - <% subject = notification.email_subject.presence || notification.kind.to_s.humanize %> - <% body = notification.email_body_text.to_s %> -
- <%= notification.created_at.strftime("%-m/%-d/%Y") %> - <%= notification.sender&.full_name.presence || "AWBW Portal" %> - "> - <%= subject %> - <% if body.present? %> - <%= body %> - <% end %> - -
+ <% if editable_notifications.include?(notification) %> + <%= f.simple_fields_for :notifications, notification do |nf| %> + <%= render "notifications/notification_fields", f: nf, record: person %> + <% end %> + <% else %> +
+ <%= render "notifications/notification_row", notification: notification %> +
+ <% 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: person %> <% end %> -
- <% else %> +
+
+ + <% if notifications.empty? && new_notifications.empty? %>

<%= t("communications.none_for_person") %>

<% end %> - <%# Inline add — manual log entries, saved with the person form. %> -
- <%= f.simple_fields_for :notifications, person.notifications.select(&:new_record?) do |nf| %> - <%= render "notifications/notification_fields", f: nf, record: person %> - <% end %> +
<%= link_to_add_association f, :notifications, partial: "notifications/notification_fields", render_options: { locals: { record: person } }, + data: { association_insertion_node: "#person-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 %> <%= t("communications.add") %> <% end %> + + <% if editable_notifications.any? %> + + <% end %>
diff --git a/app/views/scholarships/_form.html.erb b/app/views/scholarships/_form.html.erb index 4b9a615f0a..794de9b8b6 100644 --- a/app/views/scholarships/_form.html.erb +++ b/app/views/scholarships/_form.html.erb @@ -199,7 +199,7 @@ <% end %> <%# ---- Comments ---- %> -
+
@@ -228,11 +228,11 @@
diff --git a/app/views/scholarships/_notifications.html.erb b/app/views/scholarships/_notifications.html.erb index 51b89894a6..9a3e22df68 100644 --- a/app/views/scholarships/_notifications.html.erb +++ b/app/views/scholarships/_notifications.html.erb @@ -1,11 +1,14 @@ <%# ---- Notifications — communications for the scholarship recipient, newest - first. Persisted entries are read-only and paginated; new manual log entries - (email/phone/text/video) can be added inline and saved with the scholarship - form. Mirrors the registration notifications box. ---- %> + first. Automated entries are read-only; manually logged entries addressed to + this scholarship can be edited inline (toggle "Edit communications", like the + comments box) and new ones added, both saved with the scholarship form. + Mirrors the registration notifications box. ---- %> <% scholarship = f.object %> <% email = scholarship.recipient.preferred_email %> <% notifications = email.present? ? Notification.email(email).order(created_at: :desc) : Notification.none %> -
+<% new_notifications = scholarship.notifications.select(&:new_record?) %> +<% editable_notifications = notifications.select { |n| n.manual_log? && n.noticeable_type == Scholarship.name && n.noticeable_id == scholarship.id } %> +
@@ -22,38 +25,50 @@
- <% if notifications.any? %> -
+
+
<% notifications.each do |notification| %> - <% subject = notification.email_subject.presence || notification.kind.to_s.humanize %> - <% body = notification.email_body_text.to_s %> -
- <%= notification.created_at.strftime("%-m/%-d/%Y") %> - <%= notification.sender&.full_name.presence || "AWBW Portal" %> - "> - <%= subject %> - <% if body.present? %> - <%= body %> - <% end %> - -
+ <% if editable_notifications.include?(notification) %> + <%= f.simple_fields_for :notifications, notification do |nf| %> + <%= render "notifications/notification_fields", f: nf, record: scholarship %> + <% end %> + <% else %> +
+ <%= render "notifications/notification_row", notification: notification %> +
+ <% 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: scholarship %> <% end %> -
+
+
+ + <% if notifications.empty? && new_notifications.empty? %> +

<%= t("communications.none_for_scholarship") %>

<% end %> - <%# Inline add — manual log entries, saved with the scholarship form. %> -
- <%= f.simple_fields_for :notifications, scholarship.notifications.select(&:new_record?) do |nf| %> - <%= render "notifications/notification_fields", f: nf, event_registration: scholarship %> - <% end %> +
<%= link_to_add_association f, :notifications, partial: "notifications/notification_fields", - render_options: { locals: { event_registration: scholarship } }, + render_options: { locals: { record: scholarship } }, + data: { association_insertion_node: "#scholarship-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 %> <%= t("communications.add") %> <% end %> + + <% if editable_notifications.any? %> + + <% end %>
diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index 07c9665ff0..ac9ca3d048 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -135,7 +135,7 @@

Comments

<%= f.simple_fields_for :comments do |cf| %> <%= render "comments/comment_fields", f: cf %> @@ -151,9 +151,9 @@ class: "btn btn-secondary-outline" %>
diff --git a/app/views/workshops/_form.html.erb b/app/views/workshops/_form.html.erb index 1b5d53594d..3feb183dbe 100644 --- a/app/views/workshops/_form.html.erb +++ b/app/views/workshops/_form.html.erb @@ -483,7 +483,7 @@
Comments
<%= f.simple_fields_for :comments do |cf| %> <%= render "comments/comment_fields", f: cf %> @@ -494,9 +494,9 @@ <%= link_to_add_association "➕ Add Comment", f, diff --git a/config/locales/en.yml b/config/locales/en.yml index 70aa4971cc..4d3ce39a59 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -39,11 +39,14 @@ en: back_link: "← Back to communications" empty: "No communications found." add: "Add communication" + edit: "Edit communications" + done_editing: "Done editing" resent: "Communication has been resent successfully." registration_title: "Registration communications" scholarship_title: "Scholarship communications" none_for_person: "No communications for this person yet." none_for_registrant: "No communications for this registrant yet." + none_for_scholarship: "No communications for this scholarship yet." activerecord: attributes: workshop_variation: diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 0c9df0eff0..4c815a3ba1 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -416,6 +416,23 @@ def unrequest(registration) } }.not_to change(Notification, :count) end + + it "edits an existing logged notification in place" do + note = create(:notification, noticeable: existing_registration, + recipient_email: existing_registration.registrant.preferred_email, + channel: "phone", email_subject: "Left a voicemail", + kind: "manual_log", recipient_role: "person", notification_type: 0) + + patch event_registration_path(existing_registration), params: { + event_registration: { + notifications_attributes: { "0" => { id: note.id, channel: "email", email_subject: "Sent a reminder" } } + } + } + + note.reload + expect(note.channel).to eq("email") + expect(note.email_subject).to eq("Sent a reminder") + end end describe "DELETE /event_registrations/:id" do diff --git a/spec/requests/people_notifications_spec.rb b/spec/requests/people_notifications_spec.rb index 083e1a8381..f3c09a2639 100644 --- a/spec/requests/people_notifications_spec.rb +++ b/spec/requests/people_notifications_spec.rb @@ -35,6 +35,36 @@ end end + describe "PATCH /people/:id editing a logged notification" do + let!(:log) do + create(:notification, noticeable: person, recipient_email: person.preferred_email, + channel: "phone", email_subject: "Left a voicemail", + kind: "manual_log", recipient_role: "person", notification_type: 0) + end + + it "updates an existing manual notification log in place" do + patch person_path(person), params: { + person: { + notifications_attributes: { "0" => { id: log.id, channel: "email", email_subject: "Sent a reminder" } } + } + } + + log.reload + expect(log.channel).to eq("email") + expect(log.email_subject).to eq("Sent a reminder") + end + + it "removes a logged notification when marked for destruction" do + expect { + patch person_path(person), params: { + person: { + notifications_attributes: { "0" => { id: log.id, _destroy: "1" } } + } + } + }.to change { person.notifications.count }.by(-1) + end + end + describe "GET /people/:id/edit" do it "lists past communications addressed to the person" do person = create(:person, user: nil, email: "comms@example.com") diff --git a/spec/requests/scholarships_spec.rb b/spec/requests/scholarships_spec.rb index e9797fb449..6315c6feb4 100644 --- a/spec/requests/scholarships_spec.rb +++ b/spec/requests/scholarships_spec.rb @@ -233,6 +233,18 @@ expect(note.email_subject).to eq("Called recipient") expect(note.recipient_email).to eq(scholarship.recipient.preferred_email.presence || "n/a") end + + it "edits an existing logged notification in place" do + note = create(:notification, noticeable: scholarship, + recipient_email: scholarship.recipient.preferred_email.presence || "n/a", + email_subject: "Called recipient", kind: "manual_log", + recipient_role: "person", notification_type: 0) + + patch scholarship_path(scholarship), + params: { scholarship: { notifications_attributes: { "0" => { id: note.id, email_subject: "Emailed recipient" } } } } + + expect(note.reload.email_subject).to eq("Emailed recipient") + end end describe "DELETE /scholarships/:id" do