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. %> +