Skip to content

Commit 2598f5d

Browse files
Episode Search
First, we'll implement the `Episode.containing` scope to transform a search term into an SQL [ILIKE][] query. Next, create the `Search` class to serve as a model for a term-based `Episode` query. We'll construct instances based on the `query` and `page` parameters submitted as `URLSearchParams`. Then, we'll declare the `Search#episodes` method to construct an appropriate `Episode.containing` query, along with a `Search#search_results` method to paginate the collection of `Episode` records and transform them into `SearchResult` instances. Both `Search` and `SearchResult` inherit from the new `ApplicationModel` (not to be confused with `ApplicationRecord`) base class. Finally, we'll introduce the `/podcasts/:podcast_id/search_results` route along with the `SearchResultsController#index` action to fetch results and render them as a list of `search_results/search_result` partials. The `search_results/search_result` partial composes calls to the [highlight][] and [excerpt][] view helpers to find and highlight portions of the result that match the search term. For now, we'll render a [mirage][] search field in the main navigation to link to the `search_results#index` route and template. In the future, we'll be able to progressively enhance search to be globally accessible throughout the application. [ILIKE]: https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-LIKE [highlight]: https://edgeapi.rubyonrails.org/classes/ActionView/Helpers/TextHelper.html#method-i-highlight [excerpt]: https://edgeapi.rubyonrails.org/classes/ActionView/Helpers/TextHelper.html#method-i-excerpt [mirage]: https://en.wikipedia.org/wiki/Mirage Co-authored-by: Steve Polito <[email protected]> Co-authored-by: Sean Doyle <[email protected]>
1 parent 880aa6c commit 2598f5d

22 files changed

+500
-0
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ jobs:
2323
ruby-version: 3.1.2
2424
bundler-cache: true
2525

26+
- name: Install libvips
27+
run: sudo apt-get install -y libvips
28+
2629
- name: Build and run tests
2730
env:
2831
PGHOST: localhost

app/assets/images/icons/search.svg

Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class SearchResultsController < ApplicationController
2+
include PodcastScoped
3+
4+
def index
5+
@search = Search.new(search_params.merge(podcast: @podcast))
6+
@page, @search_results = @search.search_results
7+
end
8+
9+
private
10+
11+
def search_params
12+
params.permit!.slice(:page, :query)
13+
end
14+
end

app/models/application_model.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class ApplicationModel
2+
include ActiveModel::Model
3+
include ActiveModel::Attributes
4+
end

app/models/episode.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,16 @@ class Episode < ApplicationRecord
66
has_rich_text :transcript
77

88
scope :most_recent_first, -> { order published_at: :desc }
9+
scope :websearch_transcript, ->(value) {
10+
joins(:rich_text_transcript).merge ActionText::RichText.websearch_body(value)
11+
}
12+
scope :containing, ->(value) {
13+
if value.present?
14+
fulltext_search = websearch_transcript(value).select(:id)
15+
16+
where <<~SQL, query: "%" + sanitize_sql_like(value) + "%"
17+
title ILIKE :query OR subtitle ILIKE :query OR id IN (#{fulltext_search.to_sql})
18+
SQL
19+
end
20+
}
921
end

app/models/search.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
class Search < ApplicationModel
2+
include Pagy::Backend
3+
4+
attribute :podcast
5+
attribute :page
6+
attribute :query, :string
7+
8+
def search_results
9+
page, paginated_episodes = pagy episodes
10+
11+
[page, paginated_episodes.map { SearchResult.new(episode: _1, search: self) }]
12+
end
13+
14+
def episodes
15+
if query.present?
16+
podcast.episodes.most_recent_first.containing(query)
17+
else
18+
podcast.episodes.none
19+
end
20+
end
21+
22+
private
23+
24+
def params = {page:}
25+
end

app/models/search_result.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class SearchResult < ApplicationModel
2+
attribute :episode
3+
attribute :search
4+
5+
delegate :query, to: :search
6+
delegate_missing_to :episode
7+
end

app/views/episodes/podcast/_frame.html.erb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@
1414
<p class="mt-3 text-lg font-medium leading-8 text-slate-700"><%= podcast.subtitle %></p>
1515
</div>
1616

17+
<section class="mt-10 lg:mt-12">
18+
<div class="border border-gray-300 bg-white rounded-md shadow-sm text-slate-500 focus-within:ring">
19+
<%= link_to podcast_search_results_path(podcast), class: "outline-none" do %>
20+
<div class="flex items-center gap-5 px-3 text-start">
21+
<%= inline_svg_tag "icons/search.svg", class: "h-2.5 w-2.5" %>
22+
23+
<div class="flex-1 py-1">
24+
<span class="font-mono text-sm leading-7">Search</span>
25+
</div>
26+
</div>
27+
<% end %>
28+
</div>
29+
</section>
30+
1731
<section class="mt-12 hidden lg:block">
1832
<h2 class="flex items-center font-mono text-sm font-medium leading-7 text-slate-900">
1933
<%= inline_svg_tag "icons/graph.svg", class: "h-2.5 w-2.5" %>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<article aria-labelledby="<%= dom_id(search_result, :title) %>" class="py-10 sm:py-12">
2+
<div class="lg:px-8">
3+
<div class="lg:max-w-4xl">
4+
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
5+
<div class="flex flex-col items-start">
6+
<h2 id="<%= dom_id(search_result, :title) %>" class="mt-2 text-lg font-bold text-slate-900">
7+
<%= link_to search_result do %>
8+
<%= highlight search_result.title, search_result.query %>
9+
<% end %>
10+
</h2>
11+
12+
<%= time_tag search_result.published_at.to_date, class: "order-first font-mono text-sm leading-7 text-slate-500" %>
13+
14+
<p class="mt-1 text-base leading-7 text-slate-700">
15+
<%= highlight(
16+
excerpt(search_result.subtitle, search_result.query),
17+
search_result.query
18+
) %>
19+
</p>
20+
21+
<p class="mt-1 text-base leading-7 text-slate-700">
22+
<%= highlight(
23+
excerpt(search_result.transcript.to_plain_text, search_result.query),
24+
search_result.query
25+
) %>
26+
</p>
27+
28+
<div class="mt-4 flex items-center gap-4">
29+
<form action="<%= url_for(search_result) %>">
30+
<button class="group flex items-center text-sm font-bold leading-6 text-pink-500 hover:text-pink-700 active:text-pink-900">
31+
<% with_options class: "h-2.5 w-2.5 fill-current" do |styled| %>
32+
<div class="block group-aria-pressed:hidden">
33+
<span class="sr-only">Play episode <%= search_result.title %></span>
34+
<%= styled.inline_svg_tag "icons/play.svg" %>
35+
</div>
36+
37+
<div class="hidden group-aria-pressed:block">
38+
<span class="sr-only">Pause episode <%= search_result.title %></span>
39+
<%= styled.inline_svg_tag "icons/pause.svg" %>
40+
</div>
41+
<% end %>
42+
43+
<span class="ml-3" aria-hidden="true">Listen</span>
44+
</button>
45+
</form>
46+
47+
<span aria-hidden="true" class="text-sm font-bold text-slate-400">/</span>
48+
49+
<%= link_to "Show notes", search_result, class: "flex items-center text-sm font-bold leading-6 text-pink-500 hover:text-pink-700 active:text-pink-900", aria: {label: "Show notes for episode #{search_result.title}"} %>
50+
</div>
51+
</div>
52+
</div>
53+
</div>
54+
</div>
55+
</article>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<%= render "episodes/podcast/frame", podcast: @podcast do %>
2+
<div>
3+
<div class="pt-16 pb-12 sm:pb-4 lg:pt-12">
4+
<div class="lg:px-8">
5+
<div class="lg:max-w-4xl">
6+
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
7+
<h1 id="main_title" class="text-2xl font-bold leading-7 text-slate-900">
8+
<% if @search.query.present? %>
9+
Search Results for "<%= @search.query %>"
10+
<% else %>
11+
Search
12+
<% end %>
13+
</h1>
14+
</div>
15+
</div>
16+
</div>
17+
</div>
18+
19+
<div class="pt-16 pb-12 sm:pb-4 lg:pt-12">
20+
<div class="lg:px-8">
21+
<div class="lg:max-w-4xl">
22+
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
23+
<%= form_with model: @search, scope: "", url: false, method: :get, class: "flex items-center gap-4" do |form| %>
24+
<div class="relative mt-1 rounded-md shadow-sm">
25+
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
26+
<%= inline_svg_tag "icons/search.svg", class: "h-2.5 w-2.5" %>
27+
</div>
28+
<%= form.label :query, class: "sr-only" %>
29+
<%= form.text_field :query, class: "w-full rounded-md border-gray-300 pl-10 text-sm placeholder:font-mono placeholder:text-sm placeholder:leading-7 placeholder:text-slate-500",
30+
placeholder: "Search", autofocus: true,
31+
aria: {describedby: dom_id(@search, :prompt)} %>
32+
</div>
33+
34+
<button class="text-sm font-bold leading-6 text-pink-500 hover:text-pink-700 active:text-pink-900">
35+
Search
36+
</button>
37+
<% end %>
38+
</div>
39+
</div>
40+
</div>
41+
</div>
42+
43+
<div class="lg:px-8">
44+
<div class="lg:max-w-4xl">
45+
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
46+
<p id="<%= dom_id(@search, :prompt) %>" class="font-mono text-sm font-medium leading-7 text-slate-900">
47+
Search for episodes by their title, subtitle, or transcript.
48+
</p>
49+
</div>
50+
</div>
51+
</div>
52+
53+
<div id="search_results_list" class="divide-y divide-slate-100 sm:mt-4 lg:mt-8 lg:border-t lg:border-slate-100">
54+
<div>
55+
<% if @page.prev %>
56+
<div class="py-10 sm:py-12">
57+
<div class="lg:px-8">
58+
<div class="lg:max-w-4xl">
59+
<div class="flex justify-center mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
60+
<%= link_to pagy_url_for(@page, @page.prev), rel: "prev",
61+
class: "flex animate-bounce items-center justify-center rounded-full bg-white p-2 shadow-lg text-pink-500 ring-1 ring-slate-900/5 focus:ring hover:text-pink-700 active:text-pink-900" do %>
62+
<span class="sr-only">Load newer episodes</span>
63+
<%= inline_svg_tag "icons/up_arrow.svg", class: "h-6 w-6" %>
64+
<% end %>
65+
</div>
66+
</div>
67+
</div>
68+
</div>
69+
<% end %>
70+
71+
<% if @search.query.present? %>
72+
<%= render(@search_results) || render("searches/search/empty", search: @search) %>
73+
<% end %>
74+
75+
<% if @page.next %>
76+
<div>
77+
<div class="py-10 sm:py-12">
78+
<div class="lg:px-8">
79+
<div class="lg:max-w-4xl">
80+
<div class="flex justify-center mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
81+
<%= link_to pagy_url_for(@page, @page.next), rel: "next",
82+
class: "flex animate-bounce items-center justify-center rounded-full bg-white p-2 shadow-lg text-pink-500 ring-1 ring-slate-900/5 focus:ring hover:text-pink-700 active:text-pink-900" do %>
83+
<span class="sr-only">Load older episodes</span>
84+
<%= inline_svg_tag "icons/down_arrow.svg", class: "h-6 w-6" %>
85+
<% end %>
86+
</div>
87+
</div>
88+
</div>
89+
</div>
90+
</div>
91+
<% end %>
92+
</div>
93+
</div>
94+
</div>
95+
<% end %>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<div class="py-10 sm:py-12">
2+
<div class="lg:px-8">
3+
<div class="lg:max-w-4xl">
4+
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
5+
<p class="font-mono text-sm font-medium leading-7 text-slate-900">
6+
We couldn't find any episodes matching "<%= search.query %>".
7+
</p>
8+
</div>
9+
</div>
10+
</div>
11+
</div>

config/initializers/action_text.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
ActiveSupport.on_load :action_text_rich_text do
2+
attr_readonly :body_plain_text
3+
4+
scope :websearch_body, ->(websearch) { where <<~SQL, websearch: }
5+
to_tsvector('english', body_plain_text) @@ websearch_to_tsquery(:websearch)
6+
SQL
7+
8+
def body=(...)
9+
super
10+
11+
write_attribute :body_plain_text, body.try(:to_plain_text)
12+
end
13+
14+
def to_plain_text
15+
body_plain_text
16+
end
17+
end

config/routes.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
44
resources :podcasts, only: :index do
55
resources :episodes, only: [:index, :show]
6+
resources :search_results, only: :index
67
end
78

89
# Defines the root path route ("/")
910
# root "articles#index"
1011
root to: "podcasts#index"
12+
13+
resolve "SearchResult" do |search_result|
14+
[search_result.podcast, search_result.episode]
15+
end
1116
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class AddSearchableIndicesToActionTextRichTextsAndEpisodes < ActiveRecord::Migration[7.1]
2+
def change
3+
change_table :action_text_rich_texts do |t|
4+
t.text :body_plain_text
5+
t.index "to_tsvector('english', body_plain_text)", using: :gin, name: "body_tsvector_idx"
6+
end
7+
8+
change_table :episodes do |t|
9+
t.index :title
10+
t.index :subtitle, where: "NOT NULL"
11+
end
12+
end
13+
end

db/schema.rb

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/application_system_test_case.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,14 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
44
include Devise::Test::IntegrationHelpers
55

66
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
7+
8+
def tab_until_focused(*arguments, times: 1000.times, focused: true, wait: 0, **options, &block)
9+
times.each do
10+
if page.has_selector?(*arguments, **options, focused:, wait:, &block)
11+
break
12+
else
13+
send_keys(:tab)
14+
end
15+
end
16+
end
717
end

test/integration/episodes_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ class IndexTest < ActionDispatch::IntegrationTest
4848
assert_selector :element, id: "audio"
4949
end
5050
end
51+
52+
test "provides navigation to the Search page" do
53+
episode = create(:episode)
54+
55+
get podcast_episodes_path(episode.podcast)
56+
57+
within :banner do
58+
assert_link "Search", href: podcast_search_results_path(episode.podcast)
59+
end
60+
end
5161
end
5262

5363
class ShowTest < ActionDispatch::IntegrationTest

0 commit comments

Comments
 (0)