Skip to content

Commit e74ed99

Browse files
zalepaclaude
andcommitted
Support AND combinations of multiple tags in search
The topic filter is now a multi-select. Selecting multiple tags finds items tagged with ALL of them (AND logic) using GROUP BY / HAVING COUNT. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 363731c commit e74ed99

3 files changed

Lines changed: 31 additions & 13 deletions

File tree

app/controllers/search_controller.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ def index
1818
scope = apply_date_filters(scope)
1919
scope = apply_result_filter(scope)
2020

21-
@total_count = scope.count
21+
count_result = scope.count
22+
@total_count = count_result.is_a?(Hash) ? count_result.size : count_result
2223
@page = [ params[:page].to_i, 1 ].max
2324
@total_pages = (@total_count.to_f / PER_PAGE).ceil
2425
@agenda_items = scope.order("meetings.date DESC, agenda_items.position ASC")
@@ -47,7 +48,7 @@ def any_filters?
4748
params[:q].present? ||
4849
params[:file_number].present? ||
4950
params[:council_member_id].present? ||
50-
params[:tag_id].present? ||
51+
Array(params[:tag_ids]).reject(&:blank?).any? ||
5152
params[:date_from].present? ||
5253
params[:date_to].present? ||
5354
(params[:result].present? && AgendaItem::VALID_RESULTS.include?(params[:result]))
@@ -74,9 +75,13 @@ def apply_council_member_filter(scope)
7475
end
7576

7677
def apply_tag_filter(scope)
77-
return scope unless params[:tag_id].present?
78+
tag_ids = Array(params[:tag_ids]).reject(&:blank?).map(&:to_i)
79+
return scope unless tag_ids.any?
7880

79-
scope.joins(:agenda_item_tags).where(agenda_item_tags: { tag_id: params[:tag_id] })
81+
scope.joins(:agenda_item_tags)
82+
.where(agenda_item_tags: { tag_id: tag_ids })
83+
.group("agenda_items.id")
84+
.having("COUNT(DISTINCT agenda_item_tags.tag_id) = ?", tag_ids.size)
8085
end
8186

8287
def apply_date_filters(scope)

app/views/search/index.html.erb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@
5454
<%# Row 3: Topic + Result %>
5555
<div class="grid sm:grid-cols-2 gap-4">
5656
<div>
57-
<label for="tag_id" class="block text-sm font-medium text-gray-700">Topic</label>
58-
<select name="tag_id" id="tag_id"
59-
class="mt-1 block w-full h-[42px] rounded-md border border-gray-300 px-3 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500">
60-
<option value="">All topics</option>
57+
<label for="tag_ids" class="block text-sm font-medium text-gray-700">Topics <span class="font-normal text-gray-400">(select multiple for AND)</span></label>
58+
<select name="tag_ids[]" id="tag_ids" multiple
59+
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 h-[84px]">
60+
<% selected_tag_ids = Array(params[:tag_ids]).map(&:to_s) %>
6161
<% @tags.each do |tag| %>
62-
<option value="<%= tag.id %>" <%= "selected" if params[:tag_id] == tag.id.to_s %>><%= tag.name %></option>
62+
<option value="<%= tag.id %>" <%= "selected" if selected_tag_ids.include?(tag.id.to_s) %>><%= tag.name %></option>
6363
<% end %>
6464
</select>
6565
</div>

test/controllers/search_controller_test.rb

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,26 @@ class SearchControllerTest < ActionDispatch::IntegrationTest
7979
end
8080

8181
test "tag filter finds tagged items" do
82-
get search_path, params: { tag_id: tags(:budget).id }
82+
get search_path, params: { tag_ids: [ tags(:budget).id ] }
8383
assert_response :success
8484
assert_select "td", text: /Fees and Charges/
8585
end
8686

8787
test "tag filter excludes untagged items" do
88-
get search_path, params: { tag_id: tags(:housing).id }
88+
get search_path, params: { tag_ids: [ tags(:housing).id ] }
89+
assert_response :success
90+
assert_select "p", text: /No agenda items match/
91+
end
92+
93+
test "multiple tags filter with AND logic" do
94+
item = agenda_items(:ordinance_with_details)
95+
item.tags << tags(:infrastructure) unless item.tags.include?(tags(:infrastructure))
96+
97+
get search_path, params: { tag_ids: [ tags(:budget).id, tags(:infrastructure).id ] }
98+
assert_response :success
99+
assert_select "td", text: /Fees and Charges/
100+
101+
get search_path, params: { tag_ids: [ tags(:budget).id, tags(:housing).id ] }
89102
assert_response :success
90103
assert_select "p", text: /No agenda items match/
91104
end
@@ -120,11 +133,11 @@ class SearchControllerTest < ActionDispatch::IntegrationTest
120133
end
121134

122135
test "combined filters compose together" do
123-
get search_path, params: { q: "Fees", tag_id: tags(:budget).id, result: "approved" }
136+
get search_path, params: { q: "Fees", tag_ids: [ tags(:budget).id ], result: "approved" }
124137
assert_response :success
125138
assert_select "td", text: /Fees and Charges/
126139

127-
get search_path, params: { q: "Fees", tag_id: tags(:housing).id }
140+
get search_path, params: { q: "Fees", tag_ids: [ tags(:housing).id ] }
128141
assert_response :success
129142
assert_select "p", text: /No agenda items match/
130143
end

0 commit comments

Comments
 (0)