Skip to content

Commit c599e52

Browse files
maebealeclaude
andauthored
Surface hidden (unpublished) sector & category tags to admins (#1708)
Admins browse the same tags pages as the public, but had no way to tell at a glance which tags the public can't see. Render unpublished sector and category chips as a muted grey chip with a closed-eye icon (the same "hidden" cue used by the password-visibility toggle) so the hidden-from-public state reads instantly. The chips stay admin-only via the existing :taggable policy scope; the public never sees them. Also adds admin "Edit sector/category" shortcuts on the filtered taggings header, and seeds a few unpublished sectors and categories with taggings in the dev sample data so the hidden state is exercised locally. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 28a125c commit c599e52

10 files changed

Lines changed: 188 additions & 19 deletions

File tree

app/models/sector.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ class Sector < ApplicationRecord
6262
scope :sector_name, ->(sector_name) {
6363
sector_name.present? ? where("sectors.name LIKE ?", "%#{sector_name}%") : all }
6464
scope :sector_ids, ->(ids) { where(id: ids.to_s.split("-").map(&:to_i)) }
65+
scope :sector_names_all, ->(names) do
66+
return all if names.blank?
67+
parsed = Array(names).flat_map { |n| n.to_s.split("--") }.map(&:strip).reject(&:blank?).map(&:downcase)
68+
return all if parsed.empty?
69+
where("LOWER(sectors.name) IN (?)", parsed)
70+
end
6571
scope :excluding_other, -> { where.not(name: OTHER_SECTOR_NAME) }
6672
scope :has_taggings, -> { joins(:sectorable_items).distinct }
6773
scope :has_published_taggings, -> {

app/views/categories/_tagging_label.html.erb

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,42 @@
22

33
<% name_only ||= false %>
44

5+
<%# Tags hidden from the public (unpublished) are only ever shown to admins, so
6+
they read as a muted grey chip with an eye-slash icon — the same "hidden" cue
7+
used on password fields — to flag at a glance what the public can't see. %>
8+
<% is_hidden = category.respond_to?(:published?) && !category.published? %>
9+
510
<% bg_color ||= DomainTheme.bg_class_for(:sectors) %>
611

712
<%# bg_hover_color ||= DomainTheme.bg_class_for(:sectors, hover: true) %>
813

914
<% bg_hover_color ||= "bg-lime-200" %>
1015

16+
<% text_color = "text-gray-500" %>
17+
18+
<% border_class = DomainTheme.border_class_for(:categories) %>
19+
20+
<% border_hover_class = DomainTheme.border_class_for(:categories, intensity: 500) %>
21+
22+
<% if is_hidden
23+
bg_color = "bg-gray-100"
24+
bg_hover_color = "bg-gray-200"
25+
border_class = "border-gray-200"
26+
border_hover_class = "border-gray-300"
27+
end %>
28+
1129
<% if category %>
1230
<%= link_to taggings_path(category_names_all: category.name),
1331
data: { turbo_frame: "_top", category_name: category.name, controller: "tag-link-loading", action: "click->tag-link-loading#showSpinner" },
1432
class: "inline-flex items-center
1533
rounded-md
16-
border #{ DomainTheme.border_class_for(:categories) } hover:#{ DomainTheme.border_class_for(:categories, intensity: 500) }
34+
border #{ border_class } hover:#{ border_hover_class }
1735
#{ bg_color } hover:#{ bg_hover_color }
18-
text-gray-500
36+
#{ text_color }
1937
px-3 py-1
2038
text-sm font-medium
2139
transition" do %>
22-
<span data-tag-link-loading-target="text"><%= "#{category.category_type&.name&.titleize}: " if !name_only && category.category_type %><%= category.name %></span>
40+
<span data-tag-link-loading-target="text"><% if is_hidden %><%= render "shared/hidden_tag_icon" %><% end %><%= "#{category.category_type&.name&.titleize}: " if !name_only && category.category_type %><%= category.name %></span>
2341
<span data-tag-link-loading-target="spinner" class="hidden"><i class="fa-solid fa-spinner animate-spin" aria-hidden="true"></i></span>
2442
<% end %>
2543
<% end %>

app/views/sectors/_tagging_label.html.erb

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66

77
<% is_primary ||= false %>
88

9+
<%# Tags hidden from the public (unpublished) are only ever shown to admins, so
10+
they read as a muted grey chip with an eye-slash icon — the same "hidden" cue
11+
used on password fields — to flag at a glance what the public can't see. %>
12+
<% is_hidden = sector.respond_to?(:published?) && !sector.published? %>
13+
914
<%# Primary service areas read as a darker-green chip with a star so they stand
1015
out from the additional sectors, matching the star used in the chip editor;
1116
everyone else keeps the light-lime default. %>
@@ -17,18 +22,30 @@
1722

1823
<% text_color ||= is_primary ? "text-lime-800" : "text-gray-500" %>
1924

25+
<% border_class = DomainTheme.border_class_for(:sectors) %>
26+
27+
<% border_hover_class = DomainTheme.border_class_for(:sectors, intensity: 500) %>
28+
29+
<% if is_hidden
30+
bg_color = "bg-gray-100"
31+
bg_hover_color = "bg-gray-200"
32+
text_color = "text-gray-500"
33+
border_class = "border-gray-200"
34+
border_hover_class = "border-gray-300"
35+
end %>
36+
2037
<% if sector %>
2138
<%= link_to taggings_path(sector_names_all: sector.name),
2239
data: { turbo_frame: "_top", sector_name: sector.name, controller: "tag-link-loading", action: "click->tag-link-loading#showSpinner" },
2340
class: "inline-flex items-center
2441
rounded-md
25-
border #{ DomainTheme.border_class_for(:sectors) } hover:#{ DomainTheme.border_class_for(:sectors, intensity: 500) }
42+
border #{ border_class } hover:#{ border_hover_class }
2643
#{ bg_color } hover:#{ bg_hover_color }
2744
#{ text_color }
2845
px-3 py-1
2946
text-sm font-medium
3047
transition" do %>
31-
<span data-tag-link-loading-target="text"><% if is_primary %><i class="fa-solid fa-star mr-1 text-xs text-amber-400" aria-hidden="true" title="Primary service area"></i><% end %><%= sector.name %><% if display_leader && is_leader %><span class="ml-1 text-xs text-indigo-600">(Leader)</span><% end %></span>
48+
<span data-tag-link-loading-target="text"><% if is_hidden %><%= render "shared/hidden_tag_icon" %><% end %><% if is_primary %><i class="fa-solid fa-star mr-1 text-xs text-amber-400" aria-hidden="true" title="Primary service area"></i><% end %><%= sector.name %><% if display_leader && is_leader %><span class="ml-1 text-xs text-indigo-600">(Leader)</span><% end %></span>
3249
<span data-tag-link-loading-target="spinner" class="hidden"><i class="fa-solid fa-spinner animate-spin" aria-hidden="true"></i></span>
3350
<% end %>
3451
<% end %>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<%# Closed-eye icon — the same "hidden" glyph used by the password-visibility
2+
toggle on the login pages — flagging a tag that's hidden from the public and
3+
visible only to admins. %>
4+
<svg class="mr-1 inline-block size-3 align-[-0.125em]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><title>Hidden from the public</title><path d="M2 10c3 4 6.5 6 10 6s7-2 10-6"/><path d="M7.5 14l-1.5 3"/><path d="M12 16v3"/><path d="M16.5 14l1.5 3"/></svg>

app/views/taggings/index.html.erb

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
<% content_for(:page_bg_class, "public") %>
22
<% sector_names = @sector_names_all.to_s.split("--").join(", ") %>
3+
<%# Resolve the active filters to records so admins can jump straight to editing
4+
the sector/category they're browsing. The header still falls back to the raw
5+
param names below for any value that doesn't match a record. %>
6+
<% filtered_sectors = @sector_names_all.present? ? Sector.sector_names_all(@sector_names_all).order(:name).to_a : [] %>
7+
<% filtered_categories = @category_names_all.present? ? Category.category_names_all(@category_names_all).includes(:category_type).order(:name).to_a : [] %>
38
<% category_full_names =
4-
if @category_names_all.present?
5-
Category.category_names_all(@category_names_all)
6-
.includes(:category_type)
7-
.map { |c| "#{c.category_type&.name&.titleize}: #{c.name}" }
8-
.join(", ")
9-
else
10-
nil
11-
end
9+
filtered_categories.map { |c| "#{c.category_type&.name&.titleize}: #{c.name}" }.join(", ").presence
1210
%>
1311

1412
<div class="taggings-index max-w-7xl mx-auto <%= DomainTheme.bg_class_for(:tags) %> border border-gray-200 rounded-xl shadow p-6">
@@ -29,14 +27,34 @@
2927
<% if sector_names.present? || category_full_names.present? || @category_names_all.present? %>
3028
<div class="mt-4 text-center">
3129
<% if sector_names.present? %>
32-
<div class="text-3xl md:text-4xl font-semibold text-gray-900 leading-tight">
33-
<%= sector_names %>
30+
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-2">
31+
<span class="text-3xl md:text-4xl font-semibold text-gray-900 leading-tight">
32+
<%= sector_names %>
33+
</span>
34+
<% if filtered_sectors.any? && allowed_to?(:edit?, filtered_sectors.first) %>
35+
<% filtered_sectors.each do |sector| %>
36+
<%= link_to edit_sector_path(sector),
37+
class: "admin-only bg-blue-100 btn btn-secondary-outline whitespace-nowrap" do %>
38+
Edit sector<%= " (#{sector.name})" if filtered_sectors.size > 1 %>
39+
<% end %>
40+
<% end %>
41+
<% end %>
3442
</div>
3543
<% end %>
3644

3745
<% if category_full_names.present? || @category_names_all.present? %>
38-
<div class="mt-2 text-base md:text-lg text-gray-600 font-semibold">
39-
<%= category_full_names || @category_names_all %>
46+
<div class="mt-2 flex flex-wrap items-center justify-center gap-x-3 gap-y-2">
47+
<span class="text-base md:text-lg text-gray-600 font-semibold">
48+
<%= category_full_names || @category_names_all %>
49+
</span>
50+
<% if filtered_categories.any? && allowed_to?(:edit?, filtered_categories.first) %>
51+
<% filtered_categories.each do |category| %>
52+
<%= link_to edit_category_path(category),
53+
class: "admin-only bg-blue-100 btn btn-secondary-outline whitespace-nowrap" do %>
54+
Edit category<%= " (#{category.name})" if filtered_categories.size > 1 %>
55+
<% end %>
56+
<% end %>
57+
<% end %>
4058
</div>
4159
<% end %>
4260
</div>

db/seeds.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def find_or_create_by_name!(klass, name, **attrs, &block)
175175
# taggings rather than destroying them. SECTOR_TYPES already includes the "Other"
176176
# catch-all, so it stays published.
177177
canonical_names = Sector::SECTOR_TYPES.map(&:downcase)
178-
Sector.reject { |sector| canonical_names.include?(sector.name.downcase) }
178+
Sector.all.reject { |sector| canonical_names.include?(sector.name.downcase) }
179179
.each { |sector| sector.update!(published: false) }
180180

181181
puts "Creating CategoryTypes/Categories…"

db/seeds/dev/workshops.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,48 @@
367367
end
368368
puts "Done assigning categories and sectors."
369369

370+
# A few unpublished (non-canonical) sectors that still carry taggings, so the
371+
# taggings admin has data exercising the "tagged but hidden" state. The base
372+
# db/seeds.rb only creates the canonical SECTOR_TYPES; these are dev-only and
373+
# stay unpublished (they're not on the canonical list).
374+
puts "Creating unpublished sectors with taggings…"
375+
workshop_pool = Workshop.all.to_a
376+
[ "Animal-Assisted Therapy", "School Counseling", "Youth Mentorship" ].each do |sector_name|
377+
sector = Sector.where("LOWER(name) = LOWER(?)", sector_name).first_or_create!(name: sector_name)
378+
sector.update!(published: false)
379+
380+
workshop_pool.sample(rand(2..4)).each do |workshop|
381+
SectorableItem.find_or_create_by!(
382+
sector_id: sector.id,
383+
sectorable_type: "Workshop",
384+
sectorable_id: workshop.id
385+
)
386+
end
387+
end
388+
puts "Done creating unpublished sectors with taggings."
389+
390+
# A few unpublished (non-canonical) categories that still carry taggings, so the
391+
# taggings admin has data exercising the "tagged but hidden" state for categories
392+
# too. The base db/seeds.rb only creates canonical categories; these are dev-only,
393+
# attached to an existing type so they group correctly on the tags page, and stay
394+
# unpublished (they're not on any canonical list).
395+
puts "Creating unpublished categories with taggings…"
396+
art_type = CategoryType.where("LOWER(name) = LOWER(?)", "ArtType").first_or_create!(name: "ArtType", published: true)
397+
[ "Sand Art", "Found-Object Sculpture", "Shadow Puppetry" ].each do |category_name|
398+
category = Category.where("LOWER(name) = LOWER(?)", category_name)
399+
.first_or_create!(name: category_name, category_type: art_type)
400+
category.update!(published: false)
401+
402+
workshop_pool.sample(rand(2..4)).each do |workshop|
403+
CategorizableItem.find_or_create_by!(
404+
category_id: category.id,
405+
categorizable_type: "Workshop",
406+
categorizable_id: workshop.id
407+
)
408+
end
409+
end
410+
puts "Done creating unpublished categories with taggings."
411+
370412
puts "Creating Workshop Variations…"
371413
# rubocop:disable Style/PercentLiteralDelimiters
372414
variations = [

spec/requests/taggings_spec.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,38 @@
5959
end
6060
end
6161

62+
describe "admin edit buttons" do
63+
let!(:admin) { create(:user, :admin) }
64+
65+
before { sign_in admin }
66+
67+
it "shows a single Edit sector button when filtering by one sector" do
68+
get taggings_path(sector_names_all: sector_2.name)
69+
expect(response.body).to include(edit_sector_path(sector_2))
70+
expect(response.body).to include("Edit sector")
71+
expect(response.body).not_to include("Edit sector (")
72+
end
73+
74+
it "names each sector when filtering by more than one" do
75+
get taggings_path(sector_names_all: "#{sector_1.name}--#{sector_2.name}")
76+
expect(response.body).to include("Edit sector (#{sector_1.name})")
77+
expect(response.body).to include("Edit sector (#{sector_2.name})")
78+
end
79+
80+
it "shows an Edit category button when filtering by a category" do
81+
get taggings_path(category_names_all: category.name)
82+
expect(response.body).to include(edit_category_path(category))
83+
expect(response.body).to include("Edit category")
84+
end
85+
end
86+
87+
describe "edit buttons for non-admins" do
88+
it "does not show edit buttons to a regular signed-in user" do
89+
get taggings_path(sector_names_all: sector_2.name)
90+
expect(response.body).not_to include("Edit sector")
91+
end
92+
end
93+
6294
describe "when no matching tags exist" do
6395
it "does not blow up and renders empty sections" do
6496
get taggings_path(sector_names_all: "Nonexistent")

spec/views/categories/_tagging_label.html.erb_spec.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,17 @@
1717

1818
expect(rendered).not_to include("Theme:")
1919
end
20+
21+
context "when the category is hidden from the public (unpublished)" do
22+
let(:category) { create(:category, :unpublished, name: "Resilience", category_type: type) }
23+
24+
it "renders a muted grey chip with the closed-eye hidden icon" do
25+
render partial: "categories/tagging_label",
26+
locals: { category: category, name_only: true }
27+
28+
expect(rendered).to include("Hidden from the public")
29+
expect(rendered).to include("bg-gray-100")
30+
expect(rendered).not_to include(DomainTheme.bg_class_for(:sectors))
31+
end
32+
end
2033
end

spec/views/sectors/_tagging_label.html.erb_spec.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
require 'rails_helper'
22

33
RSpec.describe "sectors/_tagging_label", type: :view do
4-
let(:sector) { create(:sector, name: "Survivors") }
4+
let(:sector) { create(:sector, :published, name: "Survivors") }
55

66
it "links to taggings with sector filter" do
77
render partial: "sectors/tagging_label", locals: { sector: sector }
@@ -23,4 +23,23 @@
2323
expect(rendered).to include("fa-star")
2424
expect(rendered).to include("bg-lime-200")
2525
end
26+
27+
context "when the sector is hidden from the public (unpublished)" do
28+
let(:sector) { create(:sector, :unpublished, name: "Survivors") }
29+
30+
it "renders a muted grey chip with the closed-eye hidden icon" do
31+
render partial: "sectors/tagging_label", locals: { sector: sector }
32+
33+
expect(rendered).to include("Hidden from the public")
34+
expect(rendered).to include("bg-gray-100")
35+
expect(rendered).not_to include(DomainTheme.bg_class_for(:sectors))
36+
end
37+
38+
it "keeps the grey treatment even when passed an explicit background colour" do
39+
render partial: "sectors/tagging_label", locals: { sector: sector, bg_color: "bg-white" }
40+
41+
expect(rendered).to include("bg-gray-100")
42+
expect(rendered).not_to include("bg-white")
43+
end
44+
end
2645
end

0 commit comments

Comments
 (0)