diff --git a/lib/dpul_collections/solr.ex b/lib/dpul_collections/solr.ex index 37e4e395..395ddb3d 100644 --- a/lib/dpul_collections/solr.ex +++ b/lib/dpul_collections/solr.ex @@ -10,18 +10,48 @@ defmodule DpulCollections.Solr do response.body["response"]["numFound"] end - def query(%{q: q}) do - params = [ - q: q + @spec query(map()) :: map() + def query(search_state) do + solr_params = [ + q: query_param(search_state), + "q.op": "AND", + sort: sort_param(search_state), + rows: search_state[:per_page], + start: pagination_offset(search_state) ] {:ok, response} = Req.get( select_url(), - params: params + params: solr_params ) - response.body["response"]["docs"] + response.body["response"] + end + + defp query_param(search_state) do + Enum.reject([search_state[:q], date_query(search_state)], &is_nil(&1)) + |> Enum.join(" ") + end + + defp date_query(%{date_from: nil, date_to: nil}), do: nil + + defp date_query(%{date_from: date_from, date_to: date_to}) do + from = date_from || "*" + to = date_to || "*" + "years_is:[#{from} TO #{to}]" + end + + defp sort_param(%{sort_by: sort_by}) do + case sort_by do + :relevance -> "score desc" + :date_desc -> "years_is desc" + :date_asc -> "years_is asc" + end + end + + defp pagination_offset(%{page: page, per_page: per_page}) do + max(page - 1, 0) * per_page end def latest_document() do diff --git a/lib/dpul_collections_web/components/layouts/app.html.heex b/lib/dpul_collections_web/components/layouts/app.html.heex index fec9a04b..dc8b62a8 100644 --- a/lib/dpul_collections_web/components/layouts/app.html.heex +++ b/lib/dpul_collections_web/components/layouts/app.html.heex @@ -1,6 +1,4 @@ -
-
- <.flash_group flash={@flash} /> - <%= @inner_content %> -
+
+ <.flash_group flash={@flash} /> + <%= @inner_content %>
diff --git a/lib/dpul_collections_web/live/helpers.ex b/lib/dpul_collections_web/live/helpers.ex new file mode 100644 index 00000000..518bfc0f --- /dev/null +++ b/lib/dpul_collections_web/live/helpers.ex @@ -0,0 +1,9 @@ +defmodule DpulCollectionsWeb.Live.Helpers do + # Remove KV pairs with nil or empty string values + def clean_params(params) do + params + |> Enum.reject(fn {_, v} -> v == "" end) + |> Enum.reject(fn {_, v} -> is_nil(v) end) + |> Enum.into(%{}) + end +end diff --git a/lib/dpul_collections_web/live/home_live.ex b/lib/dpul_collections_web/live/home_live.ex index 2261508b..7d94786e 100644 --- a/lib/dpul_collections_web/live/home_live.ex +++ b/lib/dpul_collections_web/live/home_live.ex @@ -1,6 +1,7 @@ defmodule DpulCollectionsWeb.HomeLive do use DpulCollectionsWeb, :live_view alias DpulCollections.Solr + alias DpulCollectionsWeb.Live.Helpers def mount(_params, _session, socket) do socket = @@ -32,7 +33,7 @@ defmodule DpulCollectionsWeb.HomeLive do end def handle_event("search", %{"q" => q}, socket) do - params = %{q: q} + params = %{q: q} |> Helpers.clean_params() socket = push_navigate(socket, to: ~p"/search?#{params}") {:noreply, socket} end diff --git a/lib/dpul_collections_web/live/search_live.ex b/lib/dpul_collections_web/live/search_live.ex index 9a292571..5cbb14e2 100644 --- a/lib/dpul_collections_web/live/search_live.ex +++ b/lib/dpul_collections_web/live/search_live.ex @@ -1,9 +1,30 @@ defmodule DpulCollectionsWeb.SearchLive do use DpulCollectionsWeb, :live_view alias DpulCollections.Solr + alias DpulCollectionsWeb.Live.Helpers defmodule Item do - defstruct [:id, :title] + defstruct [:id, :title, :date] + end + + defmodule SearchState do + def from_params(params) do + %{ + q: params["q"], + sort_by: valid_sort_by(params), + page: (params["page"] || "1") |> String.to_integer(), + per_page: (params["per_page"] || "10") |> String.to_integer(), + date_from: params["date_from"] || nil, + date_to: params["date_to"] || nil + } + end + + defp valid_sort_by(%{"sort_by" => sort_by}) + when sort_by in ["relevance", "date_desc", "date_asc"] do + String.to_existing_atom(sort_by) + end + + defp valid_sort_by(_), do: :relevance end def mount(_params, _session, socket) do @@ -11,20 +32,20 @@ defmodule DpulCollectionsWeb.SearchLive do end def handle_params(params, _uri, socket) do - filter = %{ - q: valid_query(params) - } + search_state = SearchState.from_params(params) + solr_response = Solr.query(search_state) items = - Solr.query(filter) + solr_response["docs"] |> Enum.map(fn item -> - %Item{id: item["id"], title: item["title_ss"]} + %Item{id: item["id"], title: item["title_ss"], date: item["display_date_s"]} end) socket = assign(socket, - filter: filter, - items: items + search_state: search_state, + items: items, + total_items: solr_response["numFound"] ) {:noreply, socket} @@ -32,18 +53,57 @@ defmodule DpulCollectionsWeb.SearchLive do def render(assigns) do ~H""" -
-
+
+
- -
-
- <.search_item :for={item <- @items} item={item} /> +
+ + +
+
+
+ + +
+
+
+
+ <.search_item :for={item <- @items} item={item} /> +
+
+ <.paginator + page={@search_state.page} + per_page={@search_state.per_page} + total_items={@total_items} + />
""" end @@ -53,19 +113,101 @@ defmodule DpulCollectionsWeb.SearchLive do def search_item(assigns) do ~H"""
-
<%= @item.title %>
+
<%= @item.title %>
<%= @item.id %>
+
<%= @item.date %>
""" end - def handle_event("search", %{"q" => q}, socket) do - params = %{q: q} + def paginator(assigns) do + ~H""" +
+ <.link :if={@page > 1} id="paginator-previous" phx-click="paginate" phx-value-page={@page - 1}> + Previous + + <.link + :for={{page_number, current_page?} <- pages(@page, @per_page, @total_items)} + class={if current_page?, do: "active"} + phx-click="paginate" + phx-value-page={page_number} + > + <%= page_number %> + + <.link + :if={more_pages?(@page, @per_page, @total_items)} + id="paginator-next" + phx-click="paginate" + phx-value-page={@page + 1} + > + Next + +
+ """ + end + + def handle_event("search", params, socket) do + params = + %{ + socket.assigns.search_state + | q: params["q"], + date_to: params["date-to"], + date_from: params["date-from"] + } + |> Helpers.clean_params() + socket = push_patch(socket, to: ~p"/search?#{params}") {:noreply, socket} end - defp valid_query(%{"q" => ""}), do: nil - defp valid_query(%{"q" => q}), do: q - defp valid_query(_), do: nil + def handle_event("sort", params, socket) do + params = + %{socket.assigns.search_state | sort_by: params["sort-by"]} + |> Helpers.clean_params() + + socket = push_patch(socket, to: ~p"/search?#{params}") + {:noreply, socket} + end + + def handle_event("paginate", %{"page" => page}, socket) when page != "..." do + params = %{socket.assigns.search_state | page: page} |> Helpers.clean_params() + socket = push_patch(socket, to: ~p"/search?#{params}") + {:noreply, socket} + end + + def handle_event("paginate", _, socket) do + {:noreply, socket} + end + + defp more_pages?(page, per_page, total_items) do + page * per_page < total_items + end + + defp pages(page, per_page, total_items) do + page_count = ceil(total_items / per_page) + page_range = (page - 2)..(page + 2) + + pages = + for page_number <- page_range, + page_number > 0 do + if page_number <= page_count do + current_page? = page_number == page + {page_number, current_page?} + end + end + + # Add the prefix (1...) and postfix (...last_page) + # tail element to the paginator. + paginator_tail(:pre, 1, page_range) ++ + pages ++ + paginator_tail(:post, page_count, page_range) + end + + defp paginator_tail(type, page, page_range) do + cond do + Enum.member?(page_range |> Enum.to_list(), page) -> [] + type == :pre -> [{page, false}, {"...", false}] + type == :post -> [{"...", false}, {page, false}] + end + end end diff --git a/test/dpul_collections_web/live/search_live_test.exs b/test/dpul_collections_web/live/search_live_test.exs index 3d2eabfc..3d779abe 100644 --- a/test/dpul_collections_web/live/search_live_test.exs +++ b/test/dpul_collections_web/live/search_live_test.exs @@ -4,23 +4,8 @@ defmodule DpulCollectionsWeb.SearchLiveTest do alias DpulCollections.Solr @endpoint DpulCollectionsWeb.Endpoint - setup do - doc1 = %{ - "id" => "ce6aa6c7-623f-4398-ba04-ba542a858e4f", - "title_txtm" => ["Mehrdad"] - } - - doc2 = %{ - "id" => "6c3367b1-344c-4dde-868e-c71192757c4a", - "title_txtm" => ["Masih"] - } - - doc3 = %{ - "id" => "097263fb-5beb-407b-ab36-b468e0489792", - "title_txtm" => ["Hamed Javadzadeh"] - } - - Solr.add([doc1, doc2, doc3]) + setup_all do + Solr.add(SolrTestSupport.mock_solr_documents()) Solr.commit() on_exit(fn -> Solr.delete_all() end) end @@ -28,25 +13,81 @@ defmodule DpulCollectionsWeb.SearchLiveTest do test "GET /search", %{conn: conn} do conn = get(conn, ~p"/search") response = html_response(conn, 200) - assert response =~ "
Masih
" - assert response =~ "
Mehrdad
" + assert response =~ "
Document-1
" + assert response =~ "
Document-2
" end test "GET /search with blank q parameter", %{conn: conn} do conn = get(conn, ~p"/search?q=") response = html_response(conn, 200) - assert response =~ "
Masih
" - assert response =~ "
Mehrdad
" + assert response =~ "
Document-1
" + assert response =~ "
Document-2
" end test "searching filters results", %{conn: conn} do + {:ok, view, _html} = live(conn, "/search?") + + response = + view + |> element("#search-form") + |> render_submit(%{"q" => "Document-2"}) + + assert response =~ "
Document-2
" + assert !(response =~ "
Document-1
") + end + + test "items can be sorted by date, ascending and descending", %{conn: conn} do + {:ok, view, _html} = live(conn, "/search") + + response = render_click(view, "sort", %{"sort-by" => "date_asc"}) + assert response =~ "
Document-100
" + assert !(response =~ "
Document-1
") + + response = render_click(view, "sort", %{"sort-by" => "date_desc"}) + assert response =~ "
Document-1
" + assert !(response =~ "
Document-100
") + end + + test "items can be filtered by date range", %{conn: conn} do {:ok, view, _html} = live(conn, "/search") response = view - |> element("form") - |> render_submit(%{"q" => "Hamed"}) + |> element("#search-form") + |> render_submit(%{"date-from" => "1925", "date-to" => "1926"}) + + assert !(response =~ "
Document-98
") + assert response =~ "
Document-99
" + assert response =~ "
Document-100
" + end + + test "paginator works as expected", %{conn: conn} do + # Check that the previous link is hidden on the first page + {:ok, view, _html} = live(conn, ~p"/search?page=1") + assert !(view |> element("#paginator-previous") |> has_element?()) + assert view |> element("#paginator-next") |> has_element?() + + # Check that the previous and next links are displayed and work as expected + {:ok, view, _html} = live(conn, ~p"/search?page=5") + assert(view |> element(".paginator > a.active", ~r(5)) |> has_element?()) + + assert view + |> element("#paginator-previous") + |> render_click() =~ "
Document-40
" + + assert view + |> element("#paginator-next") + |> render_click() =~ "
Document-50
" + + # Check that the next link is hidden on the last page + {:ok, view, _html} = live(conn, ~p"/search?page=10") + assert view |> element("#paginator-previous") |> has_element?() + assert !(view |> element("#paginator-next") |> has_element?()) - assert response =~ "
Hamed Javadzadeh
" + # Check that clicking the "..." paginator link + # does not change the rendered page + assert view + |> element("a", "...") + |> render_click() =~ "
Document-100
" end end diff --git a/test/support/solr_test_support.ex b/test/support/solr_test_support.ex new file mode 100644 index 00000000..fd8a9e54 --- /dev/null +++ b/test/support/solr_test_support.ex @@ -0,0 +1,14 @@ +defmodule SolrTestSupport do + def mock_solr_documents(count \\ 100) do + for n <- 1..count do + date = 2025 - n + + %{ + id: n, + title_txtm: "Document-#{n}", + display_date_s: date |> Integer.to_string(), + years_is: [date] + } + end + end +end