Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1af98f2
Add StaffTag: internal admin-only tagging for people
maebeale Aug 22, 2026
b51027a
Add StaffTag specs, docs, and feature-catalog entry
maebeale Aug 22, 2026
5418be4
StaffTags UI polish: reorder person form, green Active pill, precompu…
maebeale Aug 22, 2026
9e05f26
StaffTags: cocoon chip picker on person form + index cleanup
maebeale Aug 22, 2026
b8d8c9e
StaffTags: use admin (blue) styling instead of the rose domain tint
maebeale Aug 22, 2026
c8df958
StaffTags: surface created_by/updated_by and track updated_by on tagg…
maebeale Aug 22, 2026
011272f
Seed the canonical StaffTags in all environments
maebeale Aug 22, 2026
431b961
StaffTags: published/Publishable language instead of archived; trim c…
maebeale Aug 22, 2026
8eca8ca
Show StaffTags in admin-only sections on the Tags and Taggings pages
maebeale Aug 22, 2026
bbac1f7
Add adaptive eyebrow back links to the people roster from staff-tag e…
maebeale Aug 22, 2026
6700825
Staff tag form: move the Visibility box to the right, matching the qu…
maebeale Aug 22, 2026
8801419
Staff tag form: staff-tag-specific published hint
maebeale Aug 22, 2026
1b41f99
Strip explanatory comments from the staff-tag code
maebeale Aug 22, 2026
e4b5d37
Move StaffTag seed data out of the model into db/seeds.rb
maebeale Aug 22, 2026
50db31c
Staff tag pages: left ← Staff tags back-arrow eyebrow
maebeale Aug 22, 2026
12180b7
Regenerate schema.rb after rebasing onto the quotes migrations (#2314)
maebeale Aug 22, 2026
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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ This codebase (Rails 8.1)
| `app/models/` | ActiveRecord models | ~90 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~69 files |
| `app/jobs/` | SolidQueue background jobs | 6 files |
| `app/models/concerns/` | Shared model modules | 17 concerns |
| `app/models/concerns/` | Shared model modules | 18 concerns |

### Presentation

Expand Down Expand Up @@ -103,6 +103,8 @@ This codebase (Rails 8.1)
| `Story` | Editorial content with facilitators, primary/gallery assets |
| `Resource` | Handouts, toolkits, templates with downloadable assets |
| `Person` | Organization affiliates with contacts, addresses, sectors |
| `StaffTag` | Internal, admin-only label for people (talent pipeline / roster / outreach β€” "Potential future trainer", "DV Leadership Cohort"). Admin-CRUD'd; `Publishable` (`published` flag) retires a tag from the pickers without deleting; never shown publicly (`StaffTagPolicy` gates every action + relation scope). Applied via the polymorphic `StaffTagging` join (`StaffTaggable` concern, Person today). Starter set seeded in db/seeds.rb |
| `StaffTagging` | Polymorphic join linking a `StaffTag` to the record it tags (`staff_taggable`); `created_by`/`updated_by` (stamped from `Current.user`) record which admin applied and last touched it |
| `OtherResponse` | A free-text "Other" typed on a form question, captured at submission time (registration, scholarship, bulk payment). Polymorphic `owner`: a **sector** "Other" is owned by the `Person` (promotable into a `Sector`, shown on their profile/edit chip); an **organization_type** "Other" is owned by the `Organization` (stored now, not promotable until `OrganizationType` is a model). `generic` questions aren't captured β€” that stays searchable in the form answers. `field_identifier` records the question; `kind` is derived. Curated at `/other_responses` (grouped by kind/question): `promote` (sectors only), `keep`, `dismiss`. `dismissed` hides the chip from the profile but stays in the review queue (still promotable later); only `promoted` leaves the queue. Admins deep-link there from a person's chip. |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Grant` | Funds (polymorphic `funder`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
Expand Down Expand Up @@ -147,6 +149,7 @@ This codebase (Rails 8.1)
| `RemoteSearchable` | AJAX remote search by column |
| `RichTextSearchable` | Full-text search on ActionText rich_text fields |
| `SectorsTaggable` | Enforces a single primary sector for sector-tagged owners |
| `StaffTaggable` | Adds the polymorphic `staff_taggings`/`staff_tags` associations for internal admin StaffTags (Person today) |
| `TagFilterable` | Scope-based filtering by tag names |
| `Trendable` | Trending metrics tracking |
| `WindowsTypeFilterable` | Filter by WindowsType association |
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,9 @@ def set_form_variables
# of any other type (age ranges included, handled by nested attributes), so
# saving the form can't drop a person's other category connections.
@managed_category_type_ids = @person_categories_grouped.map { |type, _| type.id }

@staff_tags_collection = StaffTag.published.ordered.pluck(:name, :id)
@current_staff_tag_ids = @person.staff_tag_ids
end

def find_duplicate_people(first_name, last_name, email, legal_first_name: nil, email_2: nil)
Expand Down Expand Up @@ -575,6 +578,7 @@ def person_params
:twitter_url,
:created_by_id, :updated_by_id,
sectorable_items_attributes: [ :id, :sector_id, :is_leader, :is_primary, :_destroy ],
staff_taggings_attributes: [ :id, :staff_tag_id, :_destroy ],
age_range_categorizable_items_attributes: [ :id, :category_id, :is_primary, :_destroy ],
addresses_attributes: [
:id,
Expand Down
79 changes: 79 additions & 0 deletions app/controllers/staff_tags_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
class StaffTagsController < ApplicationController
before_action :set_staff_tag, only: [ :show, :edit, :update, :destroy ]

def index
authorize!
per_page = params[:number_of_items_per_page].presence || 25
base_scope = authorized_scope(StaffTag.all)
@count_display = base_scope.count
@staff_tags = base_scope.ordered.paginate(page: params[:page], per_page: per_page).decorate
@tagged_people_counts = StaffTagging
.where(staff_tag_id: @staff_tags.map(&:id), staff_taggable_type: "Person")
.group(:staff_tag_id)
.count
end

def show
authorize! @staff_tag
@taggings = @staff_tag.staff_taggings
.includes(:created_by, :updated_by, :staff_taggable)
.order(created_at: :desc)
@staff_tag = @staff_tag.decorate
end

def new
@staff_tag = StaffTag.new.decorate
authorize! @staff_tag
end

def edit
@staff_tag = @staff_tag.decorate
authorize! @staff_tag
end

def create
@staff_tag = StaffTag.new(staff_tag_params)
@staff_tag.created_by = current_user
@staff_tag.updated_by = current_user
authorize! @staff_tag

if @staff_tag.save
redirect_to @staff_tag, notice: "Staff tag was successfully created."
else
@staff_tag = @staff_tag.decorate
render :new, status: :unprocessable_content
end
end

def update
authorize! @staff_tag
@staff_tag.updated_by = current_user

if @staff_tag.update(staff_tag_params)
redirect_to @staff_tag, notice: "Staff tag was successfully updated.", status: :see_other
else
@staff_tag = @staff_tag.decorate
render :edit, status: :unprocessable_content
end
end

def destroy
authorize! @staff_tag

if @staff_tag.destroy
redirect_to staff_tags_path, notice: "Staff tag was successfully deleted.", status: :see_other
else
redirect_to staff_tags_path, alert: "Can't delete a staff tag that's still in use β€” unpublish it instead.", status: :see_other
end
end

private

def set_staff_tag
@staff_tag = StaffTag.find(params[:id])
end

def staff_tag_params
params.require(:staff_tag).permit(:name, :description, :published)
end
end
1 change: 1 addition & 0 deletions app/controllers/taggings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def index
.select("categories.*, category_types.name AS category_type_name")
.distinct
.order("category_type_name ASC, categories.name ASC")
@staff_tags = authorized_scope(StaffTag.all).published.ordered

track_view("taggings")
track_tagging_browse(@grouped_tagged_items) if browsing_intentionally?
Expand Down
1 change: 1 addition & 0 deletions app/controllers/tags_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def index
.distinct
.order("category_type_name ASC, categories.name ASC")
@categories_by_type = @categories.to_a.group_by(&:category_type_name)
@staff_tags = authorized_scope(StaffTag.all).published.ordered
track_view("tags", { page: "index" })
end

Expand Down
13 changes: 13 additions & 0 deletions app/decorators/staff_tag_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class StaffTagDecorator < ApplicationDecorator
def title
name
end

def detail(length: nil)
description
end

def status_label
published? ? "Published" : "Unpublished"
end
end
7 changes: 7 additions & 0 deletions app/helpers/visibility_flags_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ module VisibilityFlagsHelper
hint: "Hides all child categories",
description: "When off, this type and all of its child categories are hidden."
},
# Staff tags are admin-only regardless; `published` just controls whether the
# tag is offered in the pickers, so it uses this definition via definition_key.
staff_tag_published: {
label: "Published",
hint: "Offered in the tag pickers",
description: "When off, this tag is retired from the pickers but stays on anyone already carrying it. Staff tags are admin-only either way β€” never shown publicly."
},
story_specific: {
label: "Story specific",
hint: "Needed for story share subsite",
Expand Down
10 changes: 10 additions & 0 deletions app/models/concerns/staff_taggable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module StaffTaggable
extend ActiveSupport::Concern

included do
has_many :staff_taggings, as: :staff_taggable, dependent: :destroy
has_many :staff_tags, through: :staff_taggings
accepts_nested_attributes_for :staff_taggings, allow_destroy: true,
reject_if: ->(attrs) { attrs[:staff_tag_id].blank? }
end
end
7 changes: 6 additions & 1 deletion app/models/person.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class Person < ApplicationRecord
include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable
include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable, StaffTaggable

pay_customer default_payment_processor: :stripe

Expand Down Expand Up @@ -188,6 +188,10 @@ class Person < ApplicationRecord
.distinct }
scope :sector_leaders, -> {
joins(:sectorable_items).where(sectorable_items: { is_leader: true }).distinct }
scope :staff_tagged_with, ->(ids) {
tag_ids = Array(ids).reject(&:blank?)
return all if tag_ids.empty?
joins(:staff_taggings).where(staff_taggings: { staff_tag_id: tag_ids }).distinct }

def self.search_by_params(params)
results = is_a?(ActiveRecord::Relation) ? self : all
Expand All @@ -197,6 +201,7 @@ def self.search_by_params(params)
results = results.category_names_all(params[:category_names_all]) if params[:category_names_all].present?
results = results.organization_name(params[:organization_name]) if params[:organization_name].present?
results = results.organization_id(params[:organization_id]) if params[:organization_id].present?
results = results.staff_tagged_with(params[:staff_tag_ids]) if params[:staff_tag_ids].present?
results = results.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present?
results
end
Expand Down
16 changes: 16 additions & 0 deletions app/models/staff_tag.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class StaffTag < ApplicationRecord
include Publishable

belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true
has_many :staff_taggings, dependent: :restrict_with_error
has_many :people, through: :staff_taggings, source: :staff_taggable, source_type: "Person"

validates :name, presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 }

scope :ordered, -> { order(:name) }

def to_s
name
end
end
22 changes: 22 additions & 0 deletions app/models/staff_tagging.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
class StaffTagging < ApplicationRecord
belongs_to :staff_tag
belongs_to :staff_taggable, polymorphic: true, touch: true
belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true

validates :staff_tag_id,
uniqueness: { scope: [ :staff_taggable_type, :staff_taggable_id ], message: "has already been added" }

before_create :stamp_created_by
before_save :stamp_updated_by

private

def stamp_created_by
self.created_by ||= Current.user
end

def stamp_updated_by
self.updated_by = Current.user if Current.user
end
end
14 changes: 14 additions & 0 deletions app/policies/staff_tag_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class StaffTagPolicy < ApplicationPolicy
# Staff tags are internal, admin-only. Every action is gated to admins, and the
# relation scope hides them entirely from everyone else.
def index? = admin?
def show? = admin?
def create? = admin?
def update? = admin?
def destroy? = record.persisted? && admin?

relation_scope do |relation|
next relation if admin?
relation.none
end
end
28 changes: 27 additions & 1 deletion app/views/people/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,32 @@
<% end %>
</div>

<!-- Staff tags (internal, admin-only) -->
<% if allowed_to?(:manage?, StaffTag) %>
<% staff_owner = f.object.respond_to?(:object) ? f.object.object : f.object %>
<div class="form-group admin-only space-y-2 rounded-lg bg-blue-100 p-4">
<div class="flex items-baseline justify-between">
<h3 class="font-semibold text-gray-700">Staff tags</h3>
<%= link_to "Manage tags", staff_tags_path, class: "text-sm #{eyebrow_link_class}", target: "_blank", rel: "noopener" %>
</div>
<p class="text-xs text-gray-500">Internal only β€” pipelines, rosters, and outreach lists. Never shown publicly.</p>
<div class="flex flex-wrap content-start items-center gap-2 pt-1">
<%= f.simple_fields_for :staff_taggings, staff_owner.staff_taggings do |sfi| %>
<%= render "people/staff_tag_item_fields", f: sfi %>
<% end %>

<%= link_to_add_association "βž• Add staff tag",
f,
:staff_taggings,
partial: "people/staff_tag_item_fields",
render_options: {
locals: { collection: (@staff_tags_collection || [])
.reject { |_, id| (@current_staff_tag_ids || []).include?(id) } } },
class: "btn btn-secondary-outline" %>
</div>
</div>
<% end %>

<!-- Profile-specific Categories (other than age ranges) -->
<% other_category_types = (@person_categories_grouped || {}).reject { |type, _| type.name == "AgeRange" } %>
<% if other_category_types.present? %>
Expand Down Expand Up @@ -287,7 +313,7 @@
<% end %>
</p>
<div class="mt-1 <%= "hidden" unless decorated.affiliated_since_note %>" data-affiliation-dates-target="affiliatedNote">
<span class="group/aff relative inline-flex items-center gap-1 cursor-help">
<span class="group/aff relative inline-flex cursor-help items-center gap-1">
<span class="text-xs text-gray-500" data-affiliation-dates-target="affiliatedNoteText"><%= decorated.affiliated_since_note %></span>
<i class="fa-solid fa-circle-info text-xs text-gray-400"></i>
<div class="absolute bottom-full left-0 z-50 mb-1 hidden w-64 rounded-lg bg-blue-100 p-3 text-xs whitespace-normal text-gray-700 shadow-lg ring-1 ring-blue-200 group-hover/aff:block">
Expand Down
22 changes: 18 additions & 4 deletions app/views/people/_search_boxes.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
autocomplete: "off",
class: "space-y-4") do |f| %>
<%= hidden_field_tag "organization_id", params[:organization_id] if params[:organization_id].present? %>
<div class="flex flex-col md:flex-row md:flex-wrap md:items-end gap-4 mb-6">
<div class="mb-6 flex flex-col gap-4 md:flex-row md:flex-wrap md:items-end">
<!-- Contact info -->
<div class="w-full md:flex-1">
<%= label_tag :contact_info, "Name, email, or phone", class: search_label_class %>
Expand Down Expand Up @@ -41,16 +41,30 @@
</div>

<!-- Sector leaders only -->
<div class="w-full md:w-auto flex items-center">
<label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer py-2">
<div class="flex w-full items-center md:w-auto">
<label class="inline-flex cursor-pointer items-center gap-2 py-2 text-sm font-medium text-gray-700">
<%= check_box_tag :sector_leaders_only, "1", params[:sector_leaders_only].present?,
class: "rounded border-gray-300 text-blue-600 focus:ring focus:ring-blue-200" %>
<span>Sector leaders only</span>
</label>
</div>

<!-- Staff tag (internal, admin-only) -->
<% if allowed_to?(:index?, StaffTag) %>
<% staff_tags = StaffTag.published.ordered %>
<% if staff_tags.any? %>
<div class="w-full md:flex-1">
<%= label_tag :staff_tag_ids, "Staff tag", class: search_label_class %>
<%= select_tag :staff_tag_ids,
options_from_collection_for_select(staff_tags, :id, :name, params[:staff_tag_ids]),
include_blank: "Any",
class: search_field_class %>
</div>
<% end %>
<% end %>

<!-- Clear filters -->
<div class="w-full md:w-auto flex justify-end">
<div class="flex w-full justify-end md:w-auto">
<%= render "shared/search_clear", url: people_path %>
</div>
</div>
Expand Down
23 changes: 23 additions & 0 deletions app/views/people/_staff_tag_item_fields.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<% collection ||= @staff_tags_collection || StaffTag.published.ordered.pluck(:name, :id) %>
<div class="nested-fields inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 transition">
<% if f.object&.staff_tag_id.present? %>
<%= f.hidden_field :staff_tag_id %>
<span>
<%= f.object.staff_tag&.name %><% unless f.object.staff_tag.nil? || f.object.staff_tag.published? %> <span class="text-xs text-gray-400">(unpublished)</span><% end %>
</span>
<% else %>
<%= f.input :staff_tag_id,
as: :select,
label: false,
collection: collection,
selected: f.object&.staff_tag_id,
include_blank: "Select staff tag…",
wrapper: false,
input_html: {
class: "bg-transparent border-none focus:ring-0 text-sm text-gray-500 cursor-pointer py-0"
} %>
<% end %>
<%= link_to_remove_association "βœ–",
f,
class: "ml-0.5 text-gray-400 hover:text-gray-600 font-bold text-xs transition" %>
</div>
Loading