diff --git a/app/controllers/staff_taggings_controller.rb b/app/controllers/staff_taggings_controller.rb index 764efd216c..17f179bc91 100644 --- a/app/controllers/staff_taggings_controller.rb +++ b/app/controllers/staff_taggings_controller.rb @@ -1,14 +1,14 @@ class StaffTaggingsController < ApplicationController - before_action :set_staff_tagging, only: [ :edit, :update, :destroy ] + before_action :set_staff_tagging, only: [ :edit, :update, :destroy, :toggle_marked, :save_note ] def index authorize! if turbo_frame_request? per_page = params[:number_of_items_per_page].presence || 25 - base_scope = authorized_scope(StaffTagging.includes(:staff_tag, :created_by, :staff_taggable)) + base_scope = authorized_scope(StaffTagging.includes(:staff_tag, :created_by, :staff_taggable, :comments)) filtered = base_scope.search_by_params(params.to_unsafe_h) - @sort = %w[person staff_tag created_at].include?(params[:sort]) ? params[:sort] : "created_at" + @sort = %w[person staff_tag marked created_at].include?(params[:sort]) ? params[:sort] : "created_at" @sort_direction = params[:direction] == "asc" ? "asc" : "desc" filtered = case @sort when "person" @@ -16,10 +16,13 @@ def index .reorder(Arel.sql("people.first_name #{@sort_direction}, people.last_name #{@sort_direction}")) when "staff_tag" filtered.left_joins(:staff_tag).reorder(Arel.sql("staff_tags.name #{@sort_direction}")) + when "marked" + filtered.reorder(marked: @sort_direction, created_at: :desc) else filtered.reorder(created_at: @sort_direction) end @count_display = filtered.count == base_scope.count ? base_scope.count : "#{filtered.count}/#{base_scope.count}" + @mark_column_label = mark_column_label @staff_taggings = filtered.paginate(page: params[:page], per_page: per_page) render :staff_taggings_results @@ -65,6 +68,21 @@ def destroy redirect_to staff_taggings_path, notice: "Staff tagging was successfully removed.", status: :see_other end + def toggle_marked + authorize! @staff_tagging, to: :update? + @staff_tagging.update!(marked: ActiveModel::Type::Boolean.new.cast(params[:value])) + respond_to do |format| + format.turbo_stream + format.html { redirect_to staff_taggings_path } + end + end + + def save_note + authorize! @staff_tagging, to: :update? + @staff_tagging.save_index_note(params[:note]) + head :ok + end + private def set_staff_tagging @@ -73,12 +91,22 @@ def set_staff_tagging def create_params person = Person.find_by(id: params.dig(:staff_tagging, :person_id)) - { staff_tag_id: params.dig(:staff_tagging, :staff_tag_id), staff_taggable: person } + { staff_tag_id: params.dig(:staff_tagging, :staff_tag_id), staff_taggable: person, marked: params.dig(:staff_tagging, :marked) == "1" } + end + + # When the list is filtered to a single tag, the Mark column header takes that + # tag's configured label; otherwise it stays the generic "Mark". + def mark_column_label + ids = Array(params[:staff_tag_ids]).reject(&:blank?) + return "Mark" unless ids.one? + + StaffTag.where(id: ids).pick(:mark_label).presence || "Mark" end def staff_tagging_params params.require(:staff_tagging).permit( :staff_tag_id, + :marked, comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :direction, :responded, :noticeable_type, :noticeable_id, :_destroy ] ) diff --git a/app/controllers/staff_tags_controller.rb b/app/controllers/staff_tags_controller.rb index 09e5149870..6c5d213c47 100644 --- a/app/controllers/staff_tags_controller.rb +++ b/app/controllers/staff_tags_controller.rb @@ -71,6 +71,6 @@ def set_staff_tag end def staff_tag_params - params.require(:staff_tag).permit(:name, :description, :published) + params.require(:staff_tag).permit(:name, :description, :published, :mark_label) end end diff --git a/app/controllers/topic_subscription_types_controller.rb b/app/controllers/topic_subscription_types_controller.rb index c59075a2a7..a2b5080fc0 100644 --- a/app/controllers/topic_subscription_types_controller.rb +++ b/app/controllers/topic_subscription_types_controller.rb @@ -71,6 +71,6 @@ def set_topic_subscription_type end def topic_subscription_type_params - params.require(:topic_subscription_type).permit(:name, :description, :event_selector) + params.require(:topic_subscription_type).permit(:name, :description, :event_selector, :mark_label) end end diff --git a/app/controllers/topic_subscriptions_controller.rb b/app/controllers/topic_subscriptions_controller.rb index 51cf8d5fc8..7379c67a22 100644 --- a/app/controllers/topic_subscriptions_controller.rb +++ b/app/controllers/topic_subscriptions_controller.rb @@ -1,6 +1,6 @@ class TopicSubscriptionsController < ApplicationController before_action :authenticate_user! - before_action :set_topic_subscription, only: [ :edit, :update, :destroy, :unsubscribe, :resubscribe ] + before_action :set_topic_subscription, only: [ :edit, :update, :destroy, :unsubscribe, :resubscribe, :toggle_marked, :save_note ] def index authorize! TopicSubscription @@ -8,14 +8,22 @@ def index # active), so exclude status from the shared filter and apply it here. base = TopicSubscription .search_by_params(params.except(:status)) - .includes(:topic_subscription_type, :interested_event, :organization, person: [ :user, { event_registrations: :event } ]) + .includes(:topic_subscription_type, :interested_event, :organization, :comments, person: [ :user, { event_registrations: :event } ]) @active_count = base.active.count @unsubscribed_count = base.unsubscribed.count @status_filter = params[:status].presence == "unsubscribed" ? "unsubscribed" : "active" scope = @status_filter == "unsubscribed" ? base.unsubscribed : base.active - @topic_subscriptions = scope.newest_first.paginate(page: params[:page], per_page: 25) + + # Only the marked column is click-to-sort; every other view defaults to + # newest-first. A marked sort keeps newest-first as its tiebreak. + @sort = params[:sort] == "marked" ? "marked" : "subscribed_at" + @sort_direction = params[:direction] == "asc" ? "asc" : "desc" + ordered = @sort == "marked" ? scope.reorder(marked: @sort_direction, subscribed_at: :desc) : scope.newest_first + + @topic_subscriptions = ordered.paginate(page: params[:page], per_page: 25) + @mark_column_label = mark_column_label render :topic_subscriptions_results if turbo_frame_request? end @@ -103,6 +111,21 @@ def destroy redirect_to save_return_path, notice: "Subscription removed." end + def toggle_marked + authorize! @topic_subscription, to: :update? + @topic_subscription.update!(marked: ActiveModel::Type::Boolean.new.cast(params[:value])) + respond_to do |format| + format.turbo_stream + format.html { redirect_to topic_subscriptions_path } + end + end + + def save_note + authorize! @topic_subscription, to: :update? + @topic_subscription.save_index_note(params[:note]) + head :ok + end + private # The distinct addresses reachable from these subscriptions under the two @@ -118,8 +141,16 @@ def set_topic_subscription @topic_subscription = TopicSubscription.find(params[:id]) end + # When the list is filtered to a single topic, the Mark column header takes that + # topic's configured label; otherwise it stays the generic "Mark". + def mark_column_label + return "Mark" if params[:topic_subscription_type_id].blank? + + TopicSubscriptionType.where(id: params[:topic_subscription_type_id]).pick(:mark_label).presence || "Mark" + end + def topic_subscription_params - permitted = params.require(:topic_subscription).permit(:person_id, :topic_subscription_type_id, :interested_event_id, :organization_id, :source, + permitted = params.require(:topic_subscription).permit(:person_id, :topic_subscription_type_id, :interested_event_id, :organization_id, :source, :marked, comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :direction, :responded, :noticeable_type, :noticeable_id, :_destroy ], person_attributes: [ :first_name, :last_name, :email ]) diff --git a/app/helpers/mark_helper.rb b/app/helpers/mark_helper.rb new file mode 100644 index 0000000000..5072db668c --- /dev/null +++ b/app/helpers/mark_helper.rb @@ -0,0 +1,11 @@ +module MarkHelper + # The "marked" filter dropdown options shared by the taggings & subscriptions + # indexes: All / Yes / No, filtering the boolean the tag/topic labels. + MARK_FILTER_OPTIONS = [ [ "All", "" ], [ "Yes", "true" ], [ "No", "false" ] ].freeze + + # The word a tag/topic gives its mark, falling back to a generic label when the + # admin hasn't named one. + def mark_label_or_default(label) + label.presence || "Marked" + end +end diff --git a/app/models/staff_tagging.rb b/app/models/staff_tagging.rb index f6ac3922d4..07de031d37 100644 --- a/app/models/staff_tagging.rb +++ b/app/models/staff_tagging.rb @@ -18,11 +18,27 @@ def communications_email staff_taggable.try(:preferred_email) end + # The index's inline note edits this tagging's latest comment (or starts one), + # so a quick jot on the list lands in the same comment log the edit page shows. + def save_index_note(body) + note = comments.newest_first.first + return note.update!(body: body.to_s) if note + + comments.create!(body: body) if body.present? + end + scope :for_staff_tag, ->(ids) { tag_ids = Array(ids).reject(&:blank?) return all if tag_ids.empty? where(staff_tag_id: tag_ids) } + scope :marked_status, ->(value) { + case value + when "true" then where(marked: true) + when "false" then where(marked: false) + else all + end } + # Free-text match on the tagged person: their own searchable fields (name, # email, phone, address — Person's SearchCop) plus their affiliated # organization's name. Taggings are Person-only today, so other taggable types @@ -55,6 +71,7 @@ def self.search_by_params(params) results = results.for_staff_tag(params[:staff_tag_ids]) if params[:staff_tag_ids].present? results = results.matching_text(params[:query]) if params[:query].present? results = results.matching_content(params[:content]) if params[:content].present? + results = results.marked_status(params[:marked]) if params[:marked].present? results end end diff --git a/app/models/topic_subscription.rb b/app/models/topic_subscription.rb index 914346f02d..70e94b64e6 100644 --- a/app/models/topic_subscription.rb +++ b/app/models/topic_subscription.rb @@ -16,6 +16,16 @@ def communications_email person&.preferred_email end + # The index's inline note edits this subscription's latest comment (or starts + # one), so a quick jot on the list lands in the same comment log the edit page + # shows. + def save_index_note(body) + note = comments.newest_first.first + return note.update!(body: body.to_s) if note + + comments.create!(body: body) if body.present? + end + accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } # Lets the new-subscription form create a brand-new person inline instead of # only picking an existing one. The person is saved (and validated) in the same @@ -45,17 +55,26 @@ def communications_email else all end } + scope :marked_status, ->(value) { + case value + when "true" then where(marked: true) + when "false" then where(marked: false) + else all + end + } - # Drives the subscriptions index filters: person, organization, topic type, and - # status ("active"/"unsubscribed" — the two the segmented toggle emits). The - # person filter is an exact id (picked through the remote-select search), while - # organization is a free-text name match against the linked org. + # Drives the subscriptions index filters: person, organization, topic type, + # marked status, and status ("active"/"unsubscribed" — the two the segmented + # toggle emits). The person filter is an exact id (picked through the + # remote-select search), while organization is a free-text name match against + # the linked org. def self.search_by_params(params) scope = all scope = scope.where(person_id: params[:person_id]) if params[:person_id].present? scope = scope.by_organization_name(params[:organization_name]) if params[:organization_name].present? scope = scope.for_topic_type(params[:topic_subscription_type_id]) if params[:topic_subscription_type_id].present? scope = scope.comment_status(params[:comment_status]) if params[:comment_status].present? + scope = scope.marked_status(params[:marked]) if params[:marked].present? case params[:status] when "active" then scope = scope.active diff --git a/app/views/shared/_index_note.html.erb b/app/views/shared/_index_note.html.erb new file mode 100644 index 0000000000..7a9df1ff83 --- /dev/null +++ b/app/views/shared/_index_note.html.erb @@ -0,0 +1,9 @@ +<%# Inline autosaving note for an index row — reuses the generic `autosave` + Stimulus controller (no new JS). Saves on blur to the row's `save_note` action, + which upserts the record's latest comment. Locals: record, url, body. %> +<%= text_area_tag "note", body, + rows: 2, + placeholder: "Add a comment…", + "aria-label": "Comment", + class: "w-full min-w-[180px] rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none", + data: { controller: "autosave", autosave_url_value: url, autosave_id_value: record.id, action: "change->autosave#save" } %> diff --git a/app/views/shared/_mark_switch.html.erb b/app/views/shared/_mark_switch.html.erb new file mode 100644 index 0000000000..1357f6d1e3 --- /dev/null +++ b/app/views/shared/_mark_switch.html.erb @@ -0,0 +1,17 @@ +<%# Inline mark slider for an index row — check a tagging/subscription off the list + in place. Same peer-checked switch + Turbo self-submit as the certificate-issued + toggle (no custom JS); the action replaces just this span. + Locals: record, url, label, checked. %> + + <%= form_with url: url, method: :patch, class: "inline" do %> + <%= hidden_field_tag :value, "0" %> + + <% end %> + diff --git a/app/views/staff_taggings/_mark_switch.html.erb b/app/views/staff_taggings/_mark_switch.html.erb new file mode 100644 index 0000000000..9fa4220f15 --- /dev/null +++ b/app/views/staff_taggings/_mark_switch.html.erb @@ -0,0 +1,5 @@ +<%= render "shared/mark_switch", + record: tagging, + url: toggle_marked_staff_tagging_path(tagging), + label: mark_label_or_default(tagging.staff_tag&.mark_label), + checked: tagging.marked %> diff --git a/app/views/staff_taggings/_mark_toggle.html.erb b/app/views/staff_taggings/_mark_toggle.html.erb new file mode 100644 index 0000000000..13f666ab6f --- /dev/null +++ b/app/views/staff_taggings/_mark_toggle.html.erb @@ -0,0 +1,13 @@ +<%# Mark slider — the shared peer-checked switch style (see the certificate-issued + toggle). Captioned with the tag's configured mark label; on the new form no tag + is chosen yet, so it reads the generic default until the tagging is saved. The + track is a direct sibling of the checkbox so peer-checked reaches it; the knob + is the track's ::after. %> +
+ Mark + +
diff --git a/app/views/staff_taggings/_search_boxes.html.erb b/app/views/staff_taggings/_search_boxes.html.erb index 82b0ffd923..09a917d446 100644 --- a/app/views/staff_taggings/_search_boxes.html.erb +++ b/app/views/staff_taggings/_search_boxes.html.erb @@ -1,7 +1,11 @@ <%= form_tag(staff_taggings_path, method: :get, data: { controller: "collection searchable-checkbox", turbo_frame: "staff_taggings_results" }, autocomplete: "off", - class: "space-y-4") do |f| %> + class: "mb-6 space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-4") do |f| %> +
+ + Filter +
@@ -39,6 +43,15 @@

+ +
+ <%= label_tag :marked, "Marked", class: search_label_class %> + <%= select_tag :marked, + options_for_select(MarkHelper::MARK_FILTER_OPTIONS, params[:marked]), + class: "#{search_field_class} search-select-placeholder", + onchange: "this.form.requestSubmit()" %> +
+
" aria-hidden="true">Clear diff --git a/app/views/staff_taggings/edit.html.erb b/app/views/staff_taggings/edit.html.erb index 0541652759..e8548faed0 100644 --- a/app/views/staff_taggings/edit.html.erb +++ b/app/views/staff_taggings/edit.html.erb @@ -44,6 +44,9 @@ include_blank: false, input_html: { class: "form-control" } %>
+
+ <%= render "mark_toggle", f: f %> +
diff --git a/app/views/staff_taggings/index.html.erb b/app/views/staff_taggings/index.html.erb index 9d60f9d0b0..757f4679a1 100644 --- a/app/views/staff_taggings/index.html.erb +++ b/app/views/staff_taggings/index.html.erb @@ -13,6 +13,10 @@ <%= link_to "People", people_path, class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1" %> <% end %> + <% if allowed_to?(:index?, StaffTag) %> + <%= link_to "Staff tags", staff_tags_path, + class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1" %> + <% end %> <% if allowed_to?(:index?, TopicSubscription) %> <%= link_to "Topic subscriptions", topic_subscriptions_path, class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1" %> diff --git a/app/views/staff_taggings/new.html.erb b/app/views/staff_taggings/new.html.erb index 7dddfe24dd..44f60727aa 100644 --- a/app/views/staff_taggings/new.html.erb +++ b/app/views/staff_taggings/new.html.erb @@ -27,6 +27,8 @@ collection: StaffTag.published.ordered.map { |t| [ t.name, t.id ] }, include_blank: "Select a tag", input_html: { class: "form-control" } %> + + <%= render "mark_toggle", f: f %>
diff --git a/app/views/staff_taggings/staff_taggings_results.html.erb b/app/views/staff_taggings/staff_taggings_results.html.erb index 64e1f59450..176ef75018 100644 --- a/app/views/staff_taggings/staff_taggings_results.html.erb +++ b/app/views/staff_taggings/staff_taggings_results.html.erb @@ -1,4 +1,4 @@ -<% sort_base = params.permit(:query, :content, :number_of_items_per_page, staff_tag_ids: []).to_h.symbolize_keys %> +<% sort_base = params.permit(:query, :content, :number_of_items_per_page, :marked, staff_tag_ids: []).to_h.symbolize_keys %> <% sort_link = sort_header_link(frame: "staff_taggings_results", path: ->(p) { staff_taggings_path(p) }, base: sort_base) %> <%= turbo_frame_tag :staff_taggings_results do %> <%= turbo_stream.replace("staff_taggings_count", partial: "staff_taggings_count") %> @@ -9,9 +9,11 @@ - - - + + + + + @@ -41,6 +43,12 @@ data: { turbo_frame: "_top" }, class: "font-medium text-gray-900 hover:underline" %> + + + + @@ -115,6 +119,14 @@ <% end %> + + + +
<%= sort_link.call("person", "Person") %><%= sort_link.call("staff_tag", "Staff tag") %><%= sort_link.call("created_at", "Tagged") %><%= sort_link.call("person", "Person") %><%= sort_link.call("staff_tag", "Staff tag") %><%= sort_link.call("marked", @mark_column_label) %>Comments<%= sort_link.call("created_at", "Tagged") %>
+ <%= render "mark_switch", tagging: tagging %> + + <%= render "shared/index_note", record: tagging, url: save_note_staff_tagging_path(tagging), body: tagging.comments.first&.body %> + <% if tagging.created_by&.person %>by <%= tagging.created_by.full_name %>
<% end %> on <%= tagging.created_at.strftime("%b %-d, %Y") %> diff --git a/app/views/staff_taggings/toggle_marked.turbo_stream.erb b/app/views/staff_taggings/toggle_marked.turbo_stream.erb new file mode 100644 index 0000000000..21d203ae9c --- /dev/null +++ b/app/views/staff_taggings/toggle_marked.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.replace dom_id(@staff_tagging, :marked) do %> + <%= render "mark_switch", tagging: @staff_tagging %> +<% end %> diff --git a/app/views/staff_tags/_form.html.erb b/app/views/staff_tags/_form.html.erb index 005e052e96..2657ed7887 100644 --- a/app/views/staff_tags/_form.html.erb +++ b/app/views/staff_tags/_form.html.erb @@ -15,6 +15,10 @@ hint: "Optional note on what this tag means and how it's used.", input_html: { class: "form-control", rows: 3 }, as: :text %> + <%= f.input :mark_label, + label: "Mark label", + hint: "Optional word for the checkbox on each tagging (e.g. \"Confirmed\", \"Completed\"). Defaults to \"Marked\".", + input_html: { class: "form-control" } %>
diff --git a/app/views/staff_tags/show.html.erb b/app/views/staff_tags/show.html.erb index c7391c701b..ca4e570905 100644 --- a/app/views/staff_tags/show.html.erb +++ b/app/views/staff_tags/show.html.erb @@ -27,8 +27,12 @@

Tagged people (<%= @taggings.size %>)

- <%= link_to "View as filtered roster", people_path(staff_tag_ids: @staff_tag.id, return_to: "staff_tag"), - class: button_classes(:secondary_outline) %> +
+ <%= link_to "View as filtered roster", people_path(staff_tag_ids: @staff_tag.id, return_to: "staff_tag"), + class: button_classes(:secondary_outline) %> + <%= link_to "View as filtered taggings", staff_taggings_path(staff_tag_ids: [ @staff_tag.id ]), + class: button_classes(:secondary_outline) %> +
<% if @taggings.any? %> diff --git a/app/views/topic_subscription_types/_form.html.erb b/app/views/topic_subscription_types/_form.html.erb index 3dcc1d200b..996a8f29f4 100644 --- a/app/views/topic_subscription_types/_form.html.erb +++ b/app/views/topic_subscription_types/_form.html.erb @@ -25,6 +25,12 @@ <%= f.text_area :description, rows: 3, class: field_class %> +
+ <%= f.label :mark_label, "Mark label (optional)", class: label_class %> + <%= f.text_field :mark_label, class: field_class %> +

Word for the checkbox on each subscription (e.g. “Confirmed”, “Completed”). Defaults to “Marked”.

+
+
<%= f.check_box :event_selector, class: "mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
diff --git a/app/views/topic_subscriptions/_form.html.erb b/app/views/topic_subscriptions/_form.html.erb index 830638837c..1ad7d6cb9b 100644 --- a/app/views/topic_subscriptions/_form.html.erb +++ b/app/views/topic_subscriptions/_form.html.erb @@ -183,6 +183,14 @@ <%= f.text_field :source, class: field_class, placeholder: "e.g. Facilitator Training registration, admin" %>
+
+ <%= f.check_box :marked, class: "mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> +
+ <%= f.label :marked, mark_label_or_default(current_type&.mark_label), class: "block text-sm font-medium text-gray-700" %> +

Set by the topic; means whatever “<%= mark_label_or_default(current_type&.mark_label) %>” is configured to mean for this topic.

+
+
+ <%= render "shared/comments_and_communications", f: f, view_all_person: f.object.person %>
diff --git a/app/views/topic_subscriptions/_mark_switch.html.erb b/app/views/topic_subscriptions/_mark_switch.html.erb new file mode 100644 index 0000000000..4493989c6a --- /dev/null +++ b/app/views/topic_subscriptions/_mark_switch.html.erb @@ -0,0 +1,5 @@ +<%= render "shared/mark_switch", + record: subscription, + url: toggle_marked_topic_subscription_path(subscription), + label: mark_label_or_default(subscription.topic_subscription_type&.mark_label), + checked: subscription.marked %> diff --git a/app/views/topic_subscriptions/_search_boxes.html.erb b/app/views/topic_subscriptions/_search_boxes.html.erb index 425c2a2a01..866b5f67ea 100644 --- a/app/views/topic_subscriptions/_search_boxes.html.erb +++ b/app/views/topic_subscriptions/_search_boxes.html.erb @@ -3,7 +3,11 @@
<%= form_with url: topic_subscriptions_path, method: :get, data: { controller: "collection", turbo_frame: "topic_subscriptions_results" }, - autocomplete: "off", class: "space-y-2" do |f| %> + autocomplete: "off", class: "space-y-3" do |f| %> +
+ + Filter +
<%# Remote person picker rather than a free-text box: it lives inside the filter form, so a person scope arrived at from the person page (and any @@ -41,6 +45,12 @@ options_for_select([ [ "All", "" ], [ "Has comments", "present" ], [ "Flagged", "flagged" ], [ "None", "none" ] ], params[:comment_status]), {}, class: "#{field_class} search-select-placeholder", onchange: "this.form.requestSubmit()" %>
+
+ <%= f.label :marked, "Marked", class: label_class %> + <%= f.select :marked, + options_for_select(MarkHelper::MARK_FILTER_OPTIONS, params[:marked]), + {}, class: "#{field_class} search-select-placeholder", onchange: "this.form.requestSubmit()" %> +
<%# Active/unsubscribed lives in the segmented toggle above the results; carry the current selection so filtering here doesn't reset it. %> <%= hidden_field_tag :status, params[:status], id: nil if params[:status].present? %> diff --git a/app/views/topic_subscriptions/toggle_marked.turbo_stream.erb b/app/views/topic_subscriptions/toggle_marked.turbo_stream.erb new file mode 100644 index 0000000000..26674e6f7e --- /dev/null +++ b/app/views/topic_subscriptions/toggle_marked.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.replace dom_id(@topic_subscription, :marked) do %> + <%= render "mark_switch", subscription: @topic_subscription %> +<% end %> diff --git a/app/views/topic_subscriptions/topic_subscriptions_results.html.erb b/app/views/topic_subscriptions/topic_subscriptions_results.html.erb index e7856ad4af..eb59315835 100644 --- a/app/views/topic_subscriptions/topic_subscriptions_results.html.erb +++ b/app/views/topic_subscriptions/topic_subscriptions_results.html.erb @@ -1,3 +1,5 @@ +<% sort_base = params.permit(:person_id, :organization_name, :topic_subscription_type_id, :comment_status, :marked, :status).to_h.symbolize_keys %> +<% sort_link = sort_header_link(frame: "topic_subscriptions_results", path: ->(p) { topic_subscriptions_path(p) }, base: sort_base) %> <%= turbo_frame_tag :topic_subscriptions_results do %>
<%# Outside the empty check: with nothing to list, the toggle is the only way @@ -30,6 +32,8 @@
Person Topic Training registrations<%= sort_link.call("marked", @mark_column_label) %>Comments Subscribed
+ <%= render "mark_switch", subscription: subscription.object %> + + <%= render "shared/index_note", record: subscription, url: save_note_topic_subscription_path(subscription), body: subscription.comments.first&.body %> +
<%= subscription.subscribed_at.strftime("%B %d, %Y") %>
<%= subscription.source.presence || "—" %>
diff --git a/config/features.yml b/config/features.yml index b94a49b0d8..719c35596c 100644 --- a/config/features.yml +++ b/config/features.yml @@ -3048,3 +3048,17 @@ count question like "individuals served" reads as a running total. pro_tips: - "This covers any question set to a number input type. Text and choice questions are unaffected." + +- name: "Mark subscriptions and staff taggings" + area: communications + display_status: admin_facing + released_on: 2026-09-01 + summary: >- + Tag people, then check them off the list. Each topic and staff tag names its + own mark ("Confirmed", "Completed", …), and you can tick each row right on the + taggings or subscriptions index. Both also gain a sortable mark column, an + All / Yes / No filter, and an autosaving notes box. + pro_tips: + - "Set the word on the topic (Manage topics) or the staff tag (Manage staff tags); it defaults to \"Marked\" until you name it." + - "Flip the mark slider or filter to Yes/No to work through the list." + - "The notes box saves when you click away, and shows up in that row's comment log." diff --git a/config/routes.rb b/config/routes.rb index 10c0b3b5a8..2408a1d642 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -71,7 +71,12 @@ end resources :category_types resources :staff_tags - resources :staff_taggings, only: [ :index, :new, :create, :edit, :update, :destroy ] + resources :staff_taggings, only: [ :index, :new, :create, :edit, :update, :destroy ] do + member do + patch :toggle_marked + patch :save_note + end + end resources :categories do collection do get :dedupe_index @@ -149,6 +154,8 @@ member do patch :unsubscribe patch :resubscribe + patch :toggle_marked + patch :save_note end resources :comments, only: [ :create, :update ] end diff --git a/db/migrate/20260901135946_add_mark_to_taggings_and_subscriptions.rb b/db/migrate/20260901135946_add_mark_to_taggings_and_subscriptions.rb new file mode 100644 index 0000000000..927d128d18 --- /dev/null +++ b/db/migrate/20260901135946_add_mark_to_taggings_and_subscriptions.rb @@ -0,0 +1,21 @@ +class AddMarkToTaggingsAndSubscriptions < ActiveRecord::Migration[8.1] + def up + # The boolean lives on the tagging/subscription; the human label for what it + # means is configured per tag/topic. + add_column :staff_taggings, :marked, :boolean, default: false, null: false + add_column :topic_subscriptions, :marked, :boolean, default: false, null: false + add_column :staff_tags, :mark_label, :string + add_column :topic_subscription_types, :mark_label, :string + add_index :staff_taggings, :marked + add_index :topic_subscriptions, :marked + end + + def down + remove_index :staff_taggings, :marked, if_exists: true + remove_index :topic_subscriptions, :marked, if_exists: true + remove_column :staff_taggings, :marked, if_exists: true + remove_column :topic_subscriptions, :marked, if_exists: true + remove_column :staff_tags, :mark_label, if_exists: true + remove_column :topic_subscription_types, :mark_label, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 7ec3f1ebba..d4cdeda908 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_31_220947) do +ActiveRecord::Schema[8.1].define(version: 2026_09_01_135946) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -1722,12 +1722,14 @@ create_table "staff_taggings", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "created_by_id" + t.boolean "marked", default: false, null: false t.bigint "staff_tag_id", null: false t.bigint "staff_taggable_id", null: false t.string "staff_taggable_type", null: false t.datetime "updated_at", null: false t.bigint "updated_by_id" t.index ["created_by_id"], name: "index_staff_taggings_on_created_by_id" + t.index ["marked"], name: "index_staff_taggings_on_marked" t.index ["staff_tag_id", "staff_taggable_type", "staff_taggable_id"], name: "index_staff_taggings_uniqueness", unique: true t.index ["staff_tag_id"], name: "index_staff_taggings_on_staff_tag_id" t.index ["staff_taggable_type", "staff_taggable_id"], name: "index_staff_taggings_on_staff_taggable" @@ -1738,6 +1740,7 @@ t.datetime "created_at", null: false t.bigint "created_by_id" t.text "description" + t.string "mark_label" t.string "name", null: false t.boolean "published", default: true, null: false t.datetime "updated_at", null: false @@ -1811,6 +1814,7 @@ t.text "description" t.boolean "event_selector", default: false, null: false t.string "key", null: false + t.string "mark_label" t.string "name", null: false t.datetime "updated_at", null: false t.bigint "updated_by_id" @@ -1823,6 +1827,7 @@ t.datetime "created_at", null: false t.bigint "created_by_id" t.bigint "interested_event_id" + t.boolean "marked", default: false, null: false t.integer "organization_id" t.bigint "person_id", null: false t.string "source" @@ -1833,6 +1838,7 @@ t.bigint "updated_by_id" t.index ["created_by_id"], name: "index_topic_subscriptions_on_created_by_id" t.index ["interested_event_id"], name: "index_topic_subscriptions_on_interested_event_id" + t.index ["marked"], name: "index_topic_subscriptions_on_marked" t.index ["organization_id"], name: "index_topic_subscriptions_on_organization_id" t.index ["person_id"], name: "index_topic_subscriptions_on_person_id" t.index ["topic_subscription_type_id"], name: "index_topic_subscriptions_on_topic_subscription_type_id" diff --git a/spec/factories/staff_tags.rb b/spec/factories/staff_tags.rb index 60d1bf8499..176c65f9d1 100644 --- a/spec/factories/staff_tags.rb +++ b/spec/factories/staff_tags.rb @@ -11,5 +11,9 @@ factory :staff_tagging do association :staff_tag association :staff_taggable, factory: :person + + trait :marked do + marked { true } + end end end diff --git a/spec/factories/topic_subscriptions.rb b/spec/factories/topic_subscriptions.rb index 2d76d2989c..54158c4dc5 100644 --- a/spec/factories/topic_subscriptions.rb +++ b/spec/factories/topic_subscriptions.rb @@ -14,5 +14,9 @@ trait :unsubscribed do unsubscribed_at { Time.current } end + + trait :marked do + marked { true } + end end end diff --git a/spec/models/staff_tagging_spec.rb b/spec/models/staff_tagging_spec.rb index 5cf050db08..60cc8d31c6 100644 --- a/spec/models/staff_tagging_spec.rb +++ b/spec/models/staff_tagging_spec.rb @@ -88,5 +88,13 @@ expect(results).to include(hit) expect(results).not_to include(miss) end + + it "filters by marked status" do + marked = create(:staff_tagging, :marked) + unmarked = create(:staff_tagging) + + expect(described_class.search_by_params(marked: "true")).to contain_exactly(marked) + expect(described_class.search_by_params(marked: "false")).to contain_exactly(unmarked) + end end end diff --git a/spec/models/topic_subscription_spec.rb b/spec/models/topic_subscription_spec.rb index 3ab2d4f0a7..0777f79e41 100644 --- a/spec/models/topic_subscription_spec.rb +++ b/spec/models/topic_subscription_spec.rb @@ -171,5 +171,23 @@ expect(described_class.unsubscribed).to contain_exactly(gone) expect(described_class.for_topic_type(news_type)).to contain_exactly(news) end + + it "filters by marked status" do + marked = create(:topic_subscription, :marked, topic_subscription_type: trainings) + unmarked = create(:topic_subscription, person: create(:person), topic_subscription_type: trainings) + + expect(described_class.marked_status("true")).to contain_exactly(marked) + expect(described_class.marked_status("false")).to contain_exactly(unmarked) + expect(described_class.marked_status("")).to include(marked, unmarked) + end + end + + describe ".search_by_params" do + it "narrows to marked subscriptions when marked=true" do + marked = create(:topic_subscription, :marked, topic_subscription_type: trainings) + create(:topic_subscription, person: create(:person), topic_subscription_type: trainings) + + expect(described_class.search_by_params(marked: "true")).to contain_exactly(marked) + end end end diff --git a/spec/requests/staff_taggings_spec.rb b/spec/requests/staff_taggings_spec.rb index c70c99c8a5..0efcd95e99 100644 --- a/spec/requests/staff_taggings_spec.rb +++ b/spec/requests/staff_taggings_spec.rb @@ -32,6 +32,25 @@ expect(response.body).not_to include("Drop Me") end + it "filters by marked status" do + create(:staff_tagging, :marked, staff_taggable: create(:person, first_name: "Marked", last_name: "One")) + create(:staff_tagging, staff_taggable: create(:person, first_name: "Plain", last_name: "One")) + + get staff_taggings_path, params: { marked: "true" }, headers: turbo_headers + + expect(response.body).to include("Marked One") + expect(response.body).not_to include("Plain One") + end + + it "labels the mark column with the tag's mark label when filtered to one tag" do + tag = create(:staff_tag, name: "Cohort", mark_label: "Confirmed") + create(:staff_tagging, staff_tag: tag) + + get staff_taggings_path, params: { staff_tag_ids: [ tag.id ] }, headers: turbo_headers + + expect(response.body).to include("Confirmed") + end + it "searches by person name" do create(:staff_tagging, staff_taggable: create(:person, first_name: "Alice", last_name: "Xylophone")) create(:staff_tagging, staff_taggable: create(:person, first_name: "Bob", last_name: "Quartz")) @@ -117,6 +136,15 @@ }.not_to change(StaffTagging, :count) expect(response).to have_http_status(:unprocessable_content) end + + it "creates the tagging already marked when the slider is on" do + person = create(:person) + tag = create(:staff_tag) + + post staff_taggings_path, params: { staff_tagging: { person_id: person.id, staff_tag_id: tag.id, marked: "1" } } + + expect(StaffTagging.last).to be_marked + end end describe "GET /staff_taggings/:id/edit" do @@ -206,6 +234,60 @@ expect(response).to have_http_status(:unprocessable_content) expect(tagging.reload.staff_tag).to eq(tag_a) end + + it "marks the tagging" do + patch staff_tagging_path(staff_tagging), params: { + return_to: "staff_taggings", staff_tagging: { staff_tag_id: staff_tag.id, marked: "1" } + } + + expect(staff_tagging.reload).to be_marked + end + end + + describe "PATCH /staff_taggings/:id/toggle_marked" do + before { sign_in admin } + + it "checks the tagging off from the index and answers a turbo stream" do + tagging = create(:staff_tagging) + + patch toggle_marked_staff_tagging_path(tagging), params: { value: "1" }, as: :turbo_stream + + expect(tagging.reload).to be_marked + expect(response.media_type).to eq(Mime[:turbo_stream]) + end + + it "unchecks it when value is 0" do + tagging = create(:staff_tagging, :marked) + + patch toggle_marked_staff_tagging_path(tagging), params: { value: "0" }, as: :turbo_stream + + expect(tagging.reload).not_to be_marked + end + end + + describe "PATCH /staff_taggings/:id/save_note" do + before { sign_in admin } + + it "creates a comment from the inline note" do + tagging = create(:staff_tagging) + + expect { + patch save_note_staff_tagging_path(tagging), params: { note: "Called them" } + }.to change { tagging.comments.count }.by(1) + + expect(tagging.comments.first.body).to eq("Called them") + end + + it "edits the latest comment instead of piling up new ones" do + tagging = create(:staff_tagging) + create(:comment, commentable: tagging, body: "first") + + expect { + patch save_note_staff_tagging_path(tagging), params: { note: "edited" } + }.not_to change { tagging.comments.count } + + expect(tagging.comments.first.body).to eq("edited") + end end describe "DELETE /staff_taggings/:id" do diff --git a/spec/requests/staff_tags_spec.rb b/spec/requests/staff_tags_spec.rb index 907878bfbe..3cc7b25c0a 100644 --- a/spec/requests/staff_tags_spec.rb +++ b/spec/requests/staff_tags_spec.rb @@ -63,6 +63,13 @@ expect(tag.reload).to be_published end + it "sets the mark label via the edit form" do + tag = create(:staff_tag) + + patch staff_tag_path(tag), params: { staff_tag: { mark_label: "Confirmed" } } + expect(tag.reload.mark_label).to eq("Confirmed") + end + it "won't delete a tag that is still applied" do tag = create(:staff_tag) create(:staff_tagging, staff_tag: tag) diff --git a/spec/requests/topic_subscription_types_spec.rb b/spec/requests/topic_subscription_types_spec.rb index c7c50d25fd..6bb222b37a 100644 --- a/spec/requests/topic_subscription_types_spec.rb +++ b/spec/requests/topic_subscription_types_spec.rb @@ -73,6 +73,13 @@ } expect(TopicSubscriptionType.last.event_selector?).to be(true) end + + it "sets the mark label" do + post topic_subscription_types_path, params: { + topic_subscription_type: { name: "Regional trainings", mark_label: "Completed" } + } + expect(TopicSubscriptionType.last.mark_label).to eq("Completed") + end end describe "PATCH archive / unarchive" do diff --git a/spec/requests/topic_subscriptions_spec.rb b/spec/requests/topic_subscriptions_spec.rb index f5fc76a4c5..6c793882e7 100644 --- a/spec/requests/topic_subscriptions_spec.rb +++ b/spec/requests/topic_subscriptions_spec.rb @@ -73,6 +73,16 @@ expect(response.body).not_to include("Tara Trainings") end + it "filters by marked status via the frame request" do + create(:topic_subscription, person: create(:person, first_name: "Mona", last_name: "Marked"), topic_subscription_type: trainings, marked: true) + create(:topic_subscription, person: create(:person, first_name: "Percy", last_name: "Plain"), topic_subscription_type: trainings, marked: false) + + get topic_subscriptions_path(marked: "true"), headers: { "Turbo-Frame" => "topic_subscriptions_results" } + + expect(response.body).to include("Mona Marked") + expect(response.body).not_to include("Percy Plain") + end + it "filters by organization name via the frame request" do acme = create(:organization, name: "Acme Shelter") create(:topic_subscription, person: create(:person, first_name: "Orla", last_name: "Acme"), topic_subscription_type: trainings, organization: acme) @@ -149,8 +159,11 @@ get topic_subscriptions_path, headers: { "Turbo-Frame" => "topic_subscriptions_results" } # A frame-scoped toggle would leave the out-of-frame filter form holding a - # stale status, so the next filter change would silently reset it. - expect(response.body).not_to include('data-turbo-frame="topic_subscriptions_results"') + # stale status, so the next filter change would silently reset it. (Sort + # links inside the frame legitimately target the frame; only the toggle must + # break out, so assert on the toggle anchor specifically.) + toggle_anchor = response.body[/]*status=active[^>]*>\s*Active/m] + expect(toggle_anchor).to include('data-turbo-frame="_top"') end it "carries the selected status through the filter form" do @@ -489,6 +502,12 @@ expect(subscription.comments.last.body).to eq("Reached out via email") end + it "marks the subscription" do + patch topic_subscription_path(subscription), params: { topic_subscription: { marked: "1" } } + + expect(subscription.reload).to be_marked + end + it "filters the index to subscriptions that have comments" do commented = create(:topic_subscription, topic_subscription_type: trainings, person: create(:person, first_name: "Comm", last_name: "Ented")) create(:comment, commentable: commented) @@ -499,6 +518,25 @@ expect(response.body).to include("Comm Ented") expect(response.body).not_to include("No Comments") end + + it "saves the inline note as a comment" do + expect { + patch save_note_topic_subscription_path(subscription), params: { note: "Left a voicemail" } + }.to change { subscription.comments.count }.by(1) + + expect(subscription.comments.first.body).to eq("Left a voicemail") + end + end + + describe "PATCH /topic_subscriptions/:id/toggle_marked" do + let(:subscription) { create(:topic_subscription, topic_subscription_type: trainings) } + + it "checks the subscription off from the index and answers a turbo stream" do + patch toggle_marked_topic_subscription_path(subscription), params: { value: "1" }, as: :turbo_stream + + expect(subscription.reload).to be_marked + expect(response.media_type).to eq(Mime[:turbo_stream]) + end end describe "returning to the filtered index" do