Skip to content

Commit

Permalink
Episode Search
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
stevepolitodesign and seanpdoyle committed Dec 3, 2022
1 parent 2099437 commit 0febcec
Show file tree
Hide file tree
Showing 19 changed files with 463 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
ruby-version: 3.1.2
bundler-cache: true

- name: Install libvips
run: sudo apt-get install -y libvips

- name: Build and run tests
env:
PGHOST: localhost
Expand Down
3 changes: 3 additions & 0 deletions app/assets/images/icons/search.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions app/controllers/search_results_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class SearchResultsController < ApplicationController
include PodcastScoped

def index
@search = Search.new(search_params.merge(podcast: @podcast))
@page, @search_results = @search.search_results
end

private

def search_params
params.permit!.slice(:page, :query)
end
end
4 changes: 4 additions & 0 deletions app/models/application_model.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class ApplicationModel
include ActiveModel::Model
include ActiveModel::Attributes
end
9 changes: 9 additions & 0 deletions app/models/episode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,13 @@ class Episode < ApplicationRecord
has_rich_text :transcript

scope :most_recent_first, -> { order published_at: :desc }
scope :containing, ->(value) {
if value.present?
query = "%" + sanitize_sql_like(value) + "%"

with_rich_text_transcript.left_joins(:rich_text_transcript).where(<<~SQL, query:)
title ILIKE :query OR subtitle ILIKE :query OR body ILIKE :query
SQL
end
}
end
27 changes: 27 additions & 0 deletions app/models/search.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class Search < ApplicationModel
include Pagy::Backend

attribute :podcast
attribute :page
attribute :query, :string

def search_results
page, paginated_episodes = pagy episodes

paginated_search_results = paginated_episodes.map { |episode| SearchResult.new(episode:, query:) }

[page, paginated_search_results]
end

def episodes
if query.present?
podcast.episodes.most_recent_first.containing(query)
else
podcast.episodes.none
end
end

private

def params = {page:}
end
6 changes: 6 additions & 0 deletions app/models/search_result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class SearchResult < ApplicationModel
attribute :query, :string
attribute :episode

delegate_missing_to :episode
end
13 changes: 13 additions & 0 deletions app/views/episodes/podcast/_frame.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@
<p class="mt-3 text-lg font-medium leading-8 text-slate-700"><%= podcast.subtitle %></p>
</div>

<section class="mt-10 lg:mt-12">
<div class="border border-gray-300 bg-white rounded-md shadow-sm text-slate-500">
<%= link_to podcast_search_results_path(podcast), class: "flex gap-5 px-3" do %>
<div class="flex items-center">
<%= inline_svg_tag "icons/search.svg", class: "h-2.5 w-2.5" %>
</div>
<div class="w-full py-1">
<span class="font-mono text-sm leading-7">Search</span>
</div>
<% end %>
</div>
</section>

<section class="mt-12 hidden lg:block">
<h2 class="flex items-center font-mono text-sm font-medium leading-7 text-slate-900">
<%= inline_svg_tag "icons/graph.svg", class: "h-2.5 w-2.5" %>
Expand Down
55 changes: 55 additions & 0 deletions app/views/search_results/_search_result.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<article aria-labelledby="<%= dom_id(search_result, :title) %>" class="py-10 sm:py-12">
<div class="lg:px-8">
<div class="lg:max-w-4xl">
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
<div class="flex flex-col items-start">
<h2 id="<%= dom_id(search_result, :title) %>" class="mt-2 text-lg font-bold text-slate-900">
<%= link_to search_result do %>
<%= highlight search_result.title, search_result.query %>
<% end %>
</h2>

<%= time_tag search_result.published_at.to_date, class: "order-first font-mono text-sm leading-7 text-slate-500" %>

<p class="mt-1 text-base leading-7 text-slate-700">
<%= highlight(
excerpt(search_result.subtitle, search_result.query),
search_result.query
) %>
</p>

<p class="mt-1 text-base leading-7 text-slate-700">
<%= highlight(
excerpt(search_result.transcript.to_plain_text, search_result.query),
search_result.query
) %>
</p>

<div class="mt-4 flex items-center gap-4">
<form action="<%= url_for(search_result) %>">
<button class="group flex items-center text-sm font-bold leading-6 text-pink-500 hover:text-pink-700 active:text-pink-900">
<% with_options class: "h-2.5 w-2.5 fill-current" do |styled| %>
<div class="block group-aria-pressed:hidden">
<span class="sr-only">Play episode <%= search_result.title %></span>
<%= styled.inline_svg_tag "icons/play.svg" %>
</div>

<div class="hidden group-aria-pressed:block">
<span class="sr-only">Pause episode <%= search_result.title %></span>
<%= styled.inline_svg_tag "icons/pause.svg" %>
</div>
<% end %>

<span class="ml-3" aria-hidden="true">Listen</span>
</button>
</form>

<span aria-hidden="true" class="text-sm font-bold text-slate-400">/</span>

<%= 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}"} %>
</div>
</div>
</div>
</div>
</div>
</article>
97 changes: 97 additions & 0 deletions app/views/search_results/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<%= render "episodes/podcast/frame", podcast: @podcast do %>
<div>
<div class="pt-16 pb-12 sm:pb-4 lg:pt-12">
<div class="lg:px-8">
<div class="lg:max-w-4xl">
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
<h1 id="main_title" class="text-2xl font-bold leading-7 text-slate-900">
<% if @search.query.present? %>
Search Results for "<%= @search.query %>"
<% else %>
Search
<% end %>
</h1>
</div>
</div>
</div>
</div>

<div class="pt-16 pb-12 sm:pb-4 lg:pt-12">
<div class="lg:px-8">
<div class="lg:max-w-4xl">
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
<%= form_with model: @search, scope: "", url: false, method: :get, class: "flex items-center gap-4" do |form| %>
<div class="relative mt-1 rounded-md shadow-sm">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<%= inline_svg_tag "icons/search.svg", class: "h-2.5 w-2.5" %>
</div>
<%= form.label :query, class: "sr-only" %>
<%= 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",
placeholder: "Search", autofocus: true,
aria: {describedby: dom_id(@search, :prompt)} %>
</div>

<button class="text-sm font-bold leading-6 text-pink-500 hover:text-pink-700 active:text-pink-900">
Search
</button>
<% end %>
</div>
</div>
</div>
</div>

<div class="lg:px-8">
<div class="lg:max-w-4xl">
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
<p id="<%= dom_id(@search, :prompt) %>" class="font-mono text-sm font-medium leading-7 text-slate-900">
Search for episodes by their title, subtitle, or transcript.
</p>
</div>
</div>
</div>

<div class="divide-y divide-slate-100 sm:mt-4 lg:mt-8 lg:border-t lg:border-slate-100">
<div>
<% if @page.prev %>
<div class="py-10 sm:py-12">
<div class="lg:px-8">
<div class="lg:max-w-4xl">
<div class="flex justify-center mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
<%= link_to pagy_url_for(@page, @page.prev), rel: "prev",
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 %>
<span class="sr-only">Load newer episodes</span>
<%= inline_svg_tag "icons/up_arrow.svg", class: "h-6 w-6" %>
<% end %>
</div>
</div>
</div>
</div>
<% end %>

<% if @search.query.present? %>
<%= render(@search_results) || render("searches/search/empty", search: @search) %>
<% end %>

<% if @page.next %>
<div>
<div class="py-10 sm:py-12">
<div class="lg:px-8">
<div class="lg:max-w-4xl">
<div class="flex justify-center mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
<%= link_to pagy_url_for(@page, @page.next), rel: "next",
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 %>
<span class="sr-only">Load older episodes</span>
<%= inline_svg_tag "icons/down_arrow.svg", class: "h-6 w-6" %>
<% end %>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>

<%= render "episodes/player" %>
<% end %>
11 changes: 11 additions & 0 deletions app/views/searches/search/_empty.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="py-10 sm:py-12">
<div class="lg:px-8">
<div class="lg:max-w-4xl">
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
<p class="font-mono text-sm font-medium leading-7 text-slate-900">
We couldn't find any episodes matching "<%= search.query %>".
</p>
</div>
</div>
</div>
</div>
5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
resources :podcasts, only: :index do
resources :episodes, only: [:index, :show]
resources :search_results, only: :index
end

# Defines the root path route ("/")
# root "articles#index"
root to: "podcasts#index"

resolve "SearchResult" do |search_result|
[search_result.podcast, search_result.episode]
end
end
10 changes: 10 additions & 0 deletions test/application_system_test_case.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,14 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
include Devise::Test::IntegrationHelpers

driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]

def tab_until_focused(*arguments, times: 1000.times, focused: true, wait: 0, **options, &block)
times.each do
if page.has_selector?(*arguments, **options, focused:, wait:, &block)
break
else
send_keys(:tab)
end
end
end
end
10 changes: 10 additions & 0 deletions test/integration/episodes_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ class IndexTest < ActionDispatch::IntegrationTest
assert_selector :element, id: "audio"
end
end

test "provides navigation to the Search page" do
episode = create(:episode)

get podcast_episodes_path(episode.podcast)

within :banner do
assert_link "Search", href: podcast_search_results_path(episode.podcast)
end
end
end

class ShowTest < ActionDispatch::IntegrationTest
Expand Down
72 changes: 72 additions & 0 deletions test/integration/search_results_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
require "test_helper"

module SearchResults
class IndexTest < ActionDispatch::IntegrationTest
test "renders a prompt when the query is blank" do
podcast = create(:podcast)

get podcast_search_results_path(podcast)

within :main, "Search" do
within :element, "form", method: "get", action: false do
assert_field "Query", type: "text", described_by: "Search for episodes by their title, subtitle, or transcript."
assert_button "Search"
end
end
end

test "renders a prompt when there are no results" do
podcast = create(:podcast)
query = "term"

get podcast_search_results_path(podcast), params: {query:}

within :main, %(Search Results for "#{query}") do
within :element, "form", method: "get", action: false do
assert_field "Query", type: "text", with: query
assert_button "Search"
end

assert_text %(We couldn't find any episodes matching "#{query}".)
end
end

test "renders a list of the Podcast's Episodes that match the query" do
podcast = create(:podcast)
included = create(:episode, podcast:, title: "A title match", subtitle: "A subtitle match", transcript: "A matching transcript")
excluded = create(:episode, podcast:, title: "ignored")
query = "match"

get podcast_search_results_path(podcast), params: {query:}

within :main, %(Search Results for "match") do
assert_no_selector :article, excluded.title

within :article, included.title do
within :link, text: included.title do
assert_selector "mark", text: query
end
within "p", text: included.subtitle do
assert_selector "mark", text: query
end
within "p", text: included.transcript.to_plain_text do
assert_selector "mark", text: query
end
assert_link included.title, href: podcast_episode_path(podcast, included)
assert_form action: podcast_episode_path(podcast, included), method: false, buttons: [["Play episode #{included.title}", text: "Listen"]]
assert_link "Show notes for episode #{included.title}", text: "Show notes", href: podcast_episode_path(podcast, included)
end
end
end

test "renders an empty placeholder for the player" do
podcast = create(:podcast)

get podcast_search_results_path(podcast)

within :element, id: "player" do
assert_selector :element, id: "audio"
end
end
end
end
Loading

0 comments on commit 0febcec

Please sign in to comment.