Skip to content
Merged
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
36 changes: 32 additions & 4 deletions app/controllers/staff_taggings_controller.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
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"
filtered.joins("LEFT JOIN people ON people.id = staff_taggings.staff_taggable_id AND staff_taggings.staff_taggable_type = 'Person'")
.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
Expand Down Expand Up @@ -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
Expand All @@ -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 ]
)
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/staff_tags_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/controllers/topic_subscription_types_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 35 additions & 4 deletions app/controllers/topic_subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
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
# The active/unsubscribed segmented toggle owns the status axis (default
# 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

Expand Down Expand Up @@ -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
Expand All @@ -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 ])
Expand Down
11 changes: 11 additions & 0 deletions app/helpers/mark_helper.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions app/models/staff_tagging.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
27 changes: 23 additions & 4 deletions app/models/topic_subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/views/shared/_index_note.html.erb
Original file line number Diff line number Diff line change
@@ -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" } %>
17 changes: 17 additions & 0 deletions app/views/shared/_mark_switch.html.erb
Original file line number Diff line number Diff line change
@@ -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. %>
<span id="<%= dom_id(record, :marked) %>" class="inline-block">
<%= form_with url: url, method: :patch, class: "inline" do %>
<%= hidden_field_tag :value, "0" %>
<label class="relative inline-flex cursor-pointer items-center gap-2 select-none" title="<%= label %>">
<%= check_box_tag :value, "1", checked,
onchange: "this.form.requestSubmit()",
"aria-label": label,
class: "peer sr-only" %>
<span class="relative inline-block h-5 w-9 rounded-full bg-gray-300 transition-colors peer-checked:bg-blue-600 after:absolute after:top-0.5 after:left-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform after:content-[''] peer-checked:after:translate-x-4"></span>
<span class="text-sm text-gray-500 peer-checked:font-medium peer-checked:text-green-700"><%= label %></span>
</label>
<% end %>
</span>
5 changes: 5 additions & 0 deletions app/views/staff_taggings/_mark_switch.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>
13 changes: 13 additions & 0 deletions app/views/staff_taggings/_mark_toggle.html.erb
Original file line number Diff line number Diff line change
@@ -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. %>
<div>
<span class="<%= search_label_class %>">Mark</span>
<label class="relative mt-1 flex w-fit cursor-pointer items-center gap-3 select-none">
<%= f.check_box :marked, class: "peer sr-only" %>
<span class="relative inline-block h-5 w-9 rounded-full bg-gray-300 transition-colors peer-checked:bg-blue-600 after:absolute after:top-0.5 after:left-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform after:content-[''] peer-checked:after:translate-x-4"></span>
<span class="text-sm font-medium text-gray-700"><%= mark_label_or_default(f.object.staff_tag&.mark_label) %></span>
</label>
</div>
15 changes: 14 additions & 1 deletion app/views/staff_taggings/_search_boxes.html.erb
Original file line number Diff line number Diff line change
@@ -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| %>
<div class="flex items-center gap-2 text-gray-600">
<i class="fa-solid fa-filter text-xs"></i>
<span class="text-xs font-semibold tracking-wide uppercase">Filter</span>
</div>
<div class="flex flex-col gap-4 md:flex-row md:flex-wrap md:items-start">
<!-- Person or organization name -->
<div class="w-full md:flex-1">
Expand Down Expand Up @@ -39,6 +43,15 @@
</p>
</div>

<!-- Marked -->
<div class="w-full md:w-40">
<%= 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()" %>
</div>

<!-- Clear filters -->
<div class="w-full md:ml-auto md:w-auto">
<span class="<%= search_label_class(extra: "invisible") %>" aria-hidden="true">Clear</span>
Expand Down
3 changes: 3 additions & 0 deletions app/views/staff_taggings/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
include_blank: false,
input_html: { class: "form-control" } %>
</div>
<div class="md:w-72">
<%= render "mark_toggle", f: f %>
</div>
</div>
</div>

Expand Down
4 changes: 4 additions & 0 deletions app/views/staff_taggings/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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" %>
Expand Down
2 changes: 2 additions & 0 deletions app/views/staff_taggings/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
</div>

<div class="action-buttons mt-8 flex flex-wrap justify-center gap-3">
Expand Down
Loading