Probuild Ex Part four

cover for article Probuild Ex Part four

In Part three we setup our base liveview and App context. In this fourth part, we are going to create:

Part four assumes that you have already gone through Part three and have the code at a point where we can jump right in. If you want to checkout the companion code and fast forward to this point, do the following:

git clone https://github.com/mrdotb/probuild_ex.git cd probuild_ex git checkout 0633b54a6465bf73273074267ea57cb75c0b3dca

Table of contents

App context search - commit

Edit lib/probuild_ex/app.ex

defmodule ProbuildEx.App do @moduledoc """ The context module who hold the queries. """ import Ecto.Query alias ProbuildEx.Repo alias ProbuildEx.Games.Participant defmodule Search do @moduledoc """ We represent our search input in a embedded_schema to use ecto validation helpers. """ use Ecto.Schema import Ecto.Changeset @primary_key false embedded_schema do field :search, :string field :platform_id, Ecto.Enum, values: [:euw1, :jp1, :kr, :na1, :br1] field :team_position, Ecto.Enum, values: [:UTILITY, :TOP, :JUNGLE, :MIDDLE, :BOTTOM] end def changeset(search \\ %__MODULE__{}, attrs \\ %{}) do cast(search, attrs, [:search, :platform_id, :team_position]) end def validate(changeset) do apply_action(changeset, :insert) end def to_map(search) do Map.from_struct(search) end def platform_options do Ecto.Enum.values(__MODULE__, :platform_id) end end defp pro_participant_base_query do from participant in Participant, left_join: game in assoc(participant, :game), as: :game, left_join: summoner in assoc(participant, :summoner), left_join: opponent_participant in assoc(participant, :opponent_participant), inner_join: pro in assoc(summoner, :pro), as: :pro, preload: [ game: game, opponent_participant: opponent_participant, summoner: {summoner, pro: pro} ], order_by: [desc: game.creation], limit: 20 end def list_pro_participant_summoner(search_opts) do query = Enum.reduce(search_opts, pro_participant_base_query(), &reduce_pro_participant_opts/2) Repo.all(query) end defp reduce_pro_participant_opts({:platform_id, nil}, query) do query end defp reduce_pro_participant_opts({:platform_id, platform_id}, query) do from [participant, game: game] in query, where: game.platform_id == ^platform_id end defp reduce_pro_participant_opts({:team_position, nil}, query) do query end defp reduce_pro_participant_opts({:team_position, team_position}, query) do from [participant] in query, where: participant.team_position == ^team_position end defp reduce_pro_participant_opts({:search, nil}, query) do query end defp reduce_pro_participant_opts({:search, search}, query) do search_str = search <> "%" from [participant, pro: pro] in query, where: ilike(pro.name, ^search_str) end defp reduce_pro_participant_opts({key, value}, _query), do: raise("not supported option #{inspect(key)} with value #{inspect(value)}") end

We did two things:

  • We created a submodule Search using embedded_schema and changeset to represent what are the valid search parameters search, platform_id and team_position
  • We added some ecto query composition method to our list_pro_participant_summoner query

Now let's ensure it works by adding some tests.

Edit test/support/fixtures/game_data_fixtures.ex

defmodule ProbuildEx.GameDataFixtures do ... @weiwei_ugg %{ "current_ign" => "2639439711897152", "current_team" => "Bilibili Gaming", "league" => "LPL", "main_role" => "jungle", "normalized_name" => "weiwei", "official_name" => "Weiwei", "region_id" => "kr" } @weiwei_summoner_riot %{ "accountId" => "_-m7Gyn4QupEILCjIt7KAMXBv5AhpPOzkWf9LuIehDILnvGy01qYgAKc", "id" => "NEXg9wj80c8ygbKTds2qVxdpMVIytZRpWuxLjPxJB3rJKx702B-BW0ZsMQ", "name" => "2639439711897152", "profileIconId" => 5212, "puuid" => "Kr4y3g-A2i3ygwfAfPAVhrNdwxP8S8EvzM4-Uzcpzf-hOLlaLWnVsjRjX_vsxGDo53k22fczemzjdQ", "revisionDate" => 1_642_137_289_000, "summonerLevel" => 177 } def get_weiwei do %{ ugg: @weiwei_ugg, summoner_riot: @weiwei_summoner_riot } end ... end

We add a pro player data to GameDataFixtures

Edit test/probuild_ex/app_test.exs

defmodule ProbuildEx.AppTest do use ExUnit.Case, async: true use ProbuildEx.DataCase import ProbuildEx.GamesFixtures alias ProbuildEx.{App, Games} alias ProbuildEx.GameDataFixtures describe "search" do test "validate/1 should validate query" do query = %{"search" => "faker", "platform_id" => "euw1", "team_position" => "MIDDLE"} changeset = App.Search.changeset(%App.Search{}, query) assert {:ok, _search} = App.Search.validate(changeset) end test "validate/1 should ignore extra params" do query = %{"bob" => "bob"} changeset = App.Search.changeset(%App.Search{}, query) assert {:ok, _search} = App.Search.validate(changeset) end test "validate/1 should error when value not in enum" do query = %{"search" => "faker", "platform_id" => "bob", "team_position" => "MIDDLE"} changeset = App.Search.changeset(%App.Search{}, query) assert {:error, _changeset} = App.Search.validate(changeset) end end describe "list" do defp create_weiwei_game do data = GameDataFixtures.get() weiwei_data = GameDataFixtures.get_weiwei() # create weiwei {:ok, result} = Games.create_pro_complete(weiwei_data.ugg, weiwei_data.summoner_riot) # put weiwei summoner in summoners_list summoners_list = Enum.map(data.summoners_list, fn summoner -> if(summoner["id"] == weiwei_data.summoner_riot["id"], do: result.summoner, else: summoner ) end) Games.create_game_complete( data.platform_id, data.game_data, summoners_list ) end test "list_pro_participant_summoner/1 should return participant matching the query" do # This game off weiwei is on :kr and his position is :TOP create_weiwei_game() [_] = App.list_pro_participant_summoner(%{search: "weiwei"}) [_] = App.list_pro_participant_summoner(%{platform_id: :kr}) [_] = App.list_pro_participant_summoner(%{team_position: :TOP}) [] = App.list_pro_participant_summoner(%{search: "faker"}) [] = App.list_pro_participant_summoner(%{platform_id: :euw1}) [] = App.list_pro_participant_summoner(%{team_position: :MIDDLE}) end end end

We test our Search.validate/1 function and list_pro_participant_summoner/1 using real data from weiwei player. We ensure that we got one result when we look for the weiwei game.

Liveview search - commit

Now it's time to integrate the search to our liveview.

Edit game_live/index.ex

defmodule ProbuildExWeb.GameLive.Index do use ProbuildExWeb, :live_view alias ProbuildEx.App alias ProbuildEx.Ddragon @defaults %{ page_title: "Listing games", changeset: App.Search.changeset(), search: %App.Search{}, participants: [], loading?: true } @impl true def mount(_params, _session, socket) do {:ok, assign(socket, @defaults)} end @impl true def handle_params(params, _url, socket) do # Avoid double request learn more on the article below # https://kobrakai.de/kolumne/liveview-double-mount/ socket = if connected?(socket) do apply_action(socket, socket.assigns.live_action, params) else socket end {:noreply, socket} end defp apply_action(socket, :index, params) do changeset = App.Search.changeset(socket.assigns.search, params) case App.Search.validate(changeset) do {:ok, search} -> opts = App.Search.to_map(search) # Don't block the apply_action, execute the slow request in handle_info send(self(), {:query_pro_participants, opts}) assign( socket, changeset: changeset, search: search, loading?: true ) {:error, _changest} -> socket end end @impl true def handle_event( "filter", %{"search" => %{"platform_id" => platform_id, "search" => search}}, socket ) do changeset = App.Search.changeset(socket.assigns.search, %{ "platform_id" => platform_id, "search" => search }) socket = case App.Search.validate(changeset) do {:ok, search} -> socket |> assign(changeset: changeset, search: search) |> push_patch_index() {:error, _changest} -> socket end {:noreply, socket} end def handle_event("team_position", %{"position" => position}, socket) do changeset = App.Search.changeset(socket.assigns.search, %{"team_position" => position}) socket = case App.Search.validate(changeset) do {:ok, search} -> socket |> assign(changeset: changeset, search: search) |> push_patch_index() {:error, _changest} -> socket end {:noreply, socket} end @impl true def handle_info({:query_pro_participants, opts}, socket) do participants = App.list_pro_participant_summoner(opts) socket = assign( socket, participants: participants, loading?: false ) {:noreply, socket} end defp push_patch_index(socket) do params = App.Search.to_map(socket.assigns.search) push_patch(socket, to: Routes.game_index_path(socket, :index, params)) end end

Edit game_live/index.html.heex

<div class="flex flex-col"> <.form let={f} for={@changeset} phx-change="filter" phx-submit="filter"> <div class="flex justify-center"> <div class="md:max-w-3xl w-full"> <div class="px-2 md:px-0"> <div class="w-full mt-1 relative rounded-full shadow-sm"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <!-- Heroicon name: magnifying-glass --> <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> </svg> </div> <%= search_input(f, :search, phx_debounce: 300, class: "py-4 px-5 focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-full", placeholder: "Seach for a Champion or Pro Player") %> </div> </div> </div> </div> <div class="mt-3 flex flex-wrap justify-center"> <span class="relative z-0 inline-flex shadow-sm rounded-md"> <button phx-click="team_position" phx-value-position="" type="button" class="relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 rounded-l-md border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">All Roles</button> <button phx-click="team_position" phx-value-position="TOP" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Top</button> <button phx-click="team_position" phx-value-position="JUNGLE" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Jungle</button> <button phx-click="team_position" phx-value-position="MIDDLE" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Middle</button> <button phx-click="team_position" phx-value-position="UTILITY" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Utility</button> <button phx-click="team_position" phx-value-position="BOTTOM" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 rounded-r-md border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Bottom</button> </span> <div> <%= select(f, :platform_id, App.Search.platform_options(), prompt: "All regions", class: "mt-1 md:mt-0 ml-2 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 text-xs md:text-sm rounded-md") %> </div> </div> </.form> <div class="mt-4 flex flex-col items-center space-y-1"> <%= cond do %> <% @loading? -> %> <div class="w-full max-w-3xl py-2 flex justify-center"> <img src="https://developer.riotgames.com/static/img/katarina.55a01cf0560a.gif" /> </div> <% length(@participants) == 0 -> %> <div class="w-full max-w-3xl py-2 flex justify-center"> <div>No results...</div> </div> <% true -> %> <div class="w-full max-w-3xl grid-participants-header px-1 py-2 text-xs"> <div></div> <div>Pro player</div> <div class="flex justify-center">Matchup</div> <div class="flex justify-center">KDA</div> <div class="flex justify-center">Summoners</div> <div class="flex justify-center">Build</div> </div> <%= for participant <- @participants do %> <div id={"participant-#{participant.id}"} class={[if(participant.win, do: "border-blue-500", else: "border-red-500"), "hover:bg-gray-100 hover:cursor-pointer border-l-8 w-full max-w-3xl grid-participants px-1 py-2 bg-white rounded-lg overflow-hidden shadow"]}> <div class="grid-area-creation flex md:justify-center items-center"> <time id={"time-ago-#{participant.id}"} phx-hook="TimeAgo" datetime={participant.game.creation}></time> </div> <div class="grid-area-player flex items-center"> <!-- Heroicon name: user-circle --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8"> <path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" /> </svg> <span class="flex-1 ml-1 text-ellipsis overflow-hidden"> <%= participant.summoner.pro.name %> </span> </div> <div class="grid-area-versus flex justify-center items-center space-x-1"> <%= img_tag(Ddragon.get_champion_image(participant.game.version, participant.champion_id), class: "w-8 h-8 rounded-full") %> <span>vs</span> <%= img_tag(Ddragon.get_champion_image(participant.game.version, participant.opponent_participant.champion_id), class: "w-8 h-8 rounded-full") %> </div> <div class="grid-area-kda flex justify-center items-center"> <span class="font-medium"> <%= participant.kills %> </span> / <span class="font-medium text-red-500"> <%= participant.deaths %> </span> / <span class="font-medium"> <%= participant.assists %> </span> </div> <div class="grid-area-summoners flex justify-center items-center space-x-1"> <%= for summoner_key <- participant.summoners do %> <%= img_tag(Ddragon.get_summoner_image(participant.game.version, summoner_key), class: "w-8 h-8 border border-gray-400") %> <% end %> </div> <div class="grid-area-build flex justify-center items-center space-x-1"> <%= for item_key <- participant.items do %> <%= if src = Ddragon.get_item_image(participant.game.version, item_key) do %> <img src={src} class="w-8 h-8" /> <% else %> <div class="bg-gray-900 w-8 h-8 border border-gray-400"></div> <% end %> <% end %> </div> <div class="grid-area-ellipsis hidden md:flex flex-1 justify-center items-center"> <!-- Heroicon name: ellipsis-vertical --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6"> <path fill-rule="evenodd" d="M4.5 12a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zm6 0a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zm6 0a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z" clip-rule="evenodd" /> </svg> </div> </div> <% end %> <% end %> </div> </div>

Many changes in our GameLive liveview let's break it up:

  • I like to set a @defaults at the top of my liveview like this I am able to see the shape of the state easily
  • The mount assign the @defaults
  • The handle_params call apply_action/3 but only if the liveview is connected? to avoid double mount
  • On apply_action we validate the search, if it's valid we send a message to ourself to query_pro_participants in an asynchronous fashion while showing a loading animation.
  • On handle_event we get the form action or the clicks on the team position then we call a push_patch_index that will call handle_params then apply_action
  • We used Form bindings to use our search changeset
  • We binded our buttons click to their team position
  • The rows render use a cond to check in order if we are loading?, if there is no results or display the results

Visit http://localhost:4000 and try the search and filters

Search by champion name - commit

Because we store champion_id and we want to search champion name we need to create a champions_search_map. We will add a function to our Ddragon.Cache module.

Edit lib/probuild_ex/ddragon/cache.ex

defmodule ProbuildEx.Ddragon.Cache do ... def fetch_champions_search_map do GenServer.call(__MODULE__, :fetch_champions_search_map) end ... defp request_and_cache_champions do with {:ok, %{body: versions}} <- Api.fetch_versions(), last_game_version <- List.first(versions), {:ok, %{body: champions_response}} <- Api.fetch_champions(last_game_version) do champions_search_map = create_champions_search_map(champions_response) champions_img_map = create_champions_img_map(champions_response) :ets.insert(:champions, {:search_map, champions_search_map}) Enum.each(champions_img_map, fn {key, img} -> :ets.insert(:champions, {{:img, key}, img}) end) end end ... def handle_call(:fetch_champions_search_map, _from, state) do response = case :ets.lookup(:champions, :search_map) do [{_, champions_map}] -> {:ok, champions_map} [] -> {:error, :not_found} end {:reply, response, state} end ... defp create_champions_search_map(champions_response) do champions_response |> Map.get("data") |> Enum.map(fn {_champion_id, data} -> key = String.downcase(data["name"]) value = String.to_integer(data["key"]) {key, value} end) |> Map.new() end end

Edit lib/probuild_ex/ddragon.ex

defmodule ProbuildEx.Ddragon do ... @doc """ Get a champion search map ## Example iex> Ddragon.get_champions_search_map() %{ "lux" => 99, "evelynn" => 28, "heimerdinger" => 74, ... } """ def get_champions_search_map do case Cache.fetch_champions_search_map() do {:ok, search_map} -> search_map {:error, _} -> %{} end end end

We got the function to get our champions search map from Ddragon. Now we wil add it to our search close in the query.

Edit lib/probuild_ex/app.ex

defmodule ProbuildEx.App do ... alias ProbuildEx.Ddragon ... defp reduce_pro_participant_opts({:search, search}, query) do champions_ids = Enum.reduce(Ddragon.get_champions_search_map(), [], fn {champion_name, champion_id}, acc -> if String.starts_with?(champion_name, search) do [champion_id | acc] else acc end end) search_str = search <> "%" from [participant, pro: pro] in query, where: ilike(pro.name, ^search_str) or participant.champion_id in ^champions_ids end ... end

We added some logic in our search close. We use String.start_with?/2 to reduce our champion search map. If it match the champion name we add the champion_id to the list and use it to filter.

Hook infinite scroll

Install scrivener for easy pagination - commit

We will use scrivener_ecto to handle the pagination.

Edit mix.exs

defmodule ProbuildEx.MixProject do ... def application do [ mod: {ProbuildEx.Application, []}, extra_applications: [:logger, :runtime_tools, :scrivener] ] end ... defp deps do [ ... {:scrivener_ecto, "~> 2.7"}, ] end ... end

Edit lib/probuild_ex/repo.ex

defmodule ProbuildEx.Repo do use Ecto.Repo, otp_app: :probuild_ex, adapter: Ecto.Adapters.Postgres use Scrivener, page_size: 20 end

Nothing special we follow the scrivener installation guideline.

Paginate query and liveview infiniteScroll commit

Edit lib/probuild_ex/app.ex

defmodule ProbuildEx.App do ... defp pro_participant_base_query do from participant in Participant, left_join: game in assoc(participant, :game), as: :game, left_join: summoner in assoc(participant, :summoner), left_join: opponent_participant in assoc(participant, :opponent_participant), inner_join: pro in assoc(summoner, :pro), as: :pro, preload: [ game: game, opponent_participant: opponent_participant, summoner: {summoner, pro: pro} ], order_by: [desc: game.creation] end @doc """ Query pro participant paginated based on search_opts. """ def paginate_pro_participants(search_opts, page_number \\ 1) do query = Enum.reduce(search_opts, pro_participant_base_query(), &reduce_pro_participant_opts/2) Repo.paginate(query, page: page_number) end ... end

We need to remove the limit from the base_query() we rename the function list_* to paginate_* and add an extra parameter page_number.

Edit test/probuild_ex/app_test.exs

defmodule ProbuildEx.AppTest do ... describe "list" do ... test "paginate_pro_participants/1 should return participant matching the query" do # This game off weiwei is on :kr and his position is :TOP and play yone create_weiwei_game() %{total_entries: 1} = App.paginate_pro_participants(%{search: "weiwei"}) %{total_entries: 1} = App.paginate_pro_participants(%{search: "yone"}) %{total_entries: 1} = App.paginate_pro_participants(%{platform_id: :kr}) %{total_entries: 1} = App.paginate_pro_participants(%{team_position: :TOP}) %{total_entries: 0} = App.paginate_pro_participants(%{search: "faker"}) %{total_entries: 0} = App.paginate_pro_participants(%{platform_id: :euw1}) %{total_entries: 0} = App.paginate_pro_participants(%{team_position: :MIDDLE}) end end end

We edited the tests because now it returns a %Scrivener.Page{} instead of a list of Participants

Edit assets/js/app.js

// https://elixirforum.com/t/how-can-i-implement-an-infinite-scroll-in-liveview/30457 // https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver Hooks.InfiniteScroll = { page() { return parseInt(this.el.dataset.page, 10) }, loadMore(entries) { const target = entries[0] if (this.pending && target.isIntersecting && this.pending == this.page()) { this.pending = this.page() + 1 this.pushEvent('load-more', {}) } }, mounted() { this.pending = this.page() const options = { root: null, rootMargin: '-90% 0px 10% 0px', threshold: 1.0, } this.observer = new IntersectionObserver(this.loadMore.bind(this), options) this.observer.observe(this.el) }, reconnected() { this.pending = this.page() }, updated() { this.pending = this.page() }, beforeDestroy() { this.observer.unobserve(this.el) }, }

We used the IntersectionObserver to trigger the load of more data. I use a modified rootMargin to put the root on the bottom of the page. Below a schema representing the boxes model. The loadMore function will trigger when the #load-more div and the root overlap.

Edit game_live/index.html.heex

defmodule ProbuildExWeb.GameLive.Index do use ProbuildExWeb, :live_view alias ProbuildEx.App alias ProbuildEx.Ddragon @defaults %{ page_title: "Listing games", update: "append", changeset: App.Search.changeset(), search: %App.Search{}, page: %Scrivener.Page{}, participants: [], loading?: true, load_more?: false } def mount(_params, _session, socket) do {:ok, assign(socket, @defaults), temporary_assigns: [participants: []]} end ... def handle_event("load-more", _params, socket) do page = socket.assigns.page socket = if page.page_number < page.total_pages do opts = App.Search.to_map(socket.assigns.search) # Don't block the load-more event, execute the slow request in handle_info send(self(), {:query_pro_participants, opts, page.page_number + 1}) assign(socket, load_more?: true) else socket end {:noreply, socket} end @impl true def handle_info({:query_pro_participants, opts}, socket) do page = App.paginate_pro_participants(opts) socket = assign( socket, update: "replace", page: page, participants: page.entries, loading?: false ) {:noreply, socket} end def handle_info({:query_pro_participants, opts, page_number}, socket) do page = App.paginate_pro_participants(opts, page_number) socket = assign( socket, update: "append", page: page, participants: page.entries, load_more?: false ) {:noreply, socket} end ... end

Edit game_live/index.html.heex

<div class="flex flex-col"> <.form let={f} for={@changeset} phx-change="filter" phx-submit="filter"> <div class="flex justify-center"> <div class="md:max-w-3xl w-full"> <div class="px-2 md:px-0"> <div class="w-full mt-1 relative rounded-full shadow-sm"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <!-- Heroicon name: magnifying-glass --> <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> </svg> </div> <%= search_input(f, :search, phx_debounce: 300, class: "py-4 px-5 focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-full", placeholder: "Seach for a Champion or Pro Player") %> </div> </div> </div> </div> <div class="mt-3 flex flex-wrap justify-center"> <span class="relative z-0 inline-flex shadow-sm rounded-md"> <button phx-click="team_position" phx-value-position="" type="button" class="relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 rounded-l-md border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">All Roles</button> <button phx-click="team_position" phx-value-position="TOP" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Top</button> <button phx-click="team_position" phx-value-position="JUNGLE" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Jungle</button> <button phx-click="team_position" phx-value-position="MIDDLE" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Middle</button> <button phx-click="team_position" phx-value-position="UTILITY" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Utility</button> <button phx-click="team_position" phx-value-position="BOTTOM" type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 rounded-r-md border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Bottom</button> </span> <div> <%= select(f, :platform_id, App.Search.platform_options(), prompt: "All regions", class: "mt-1 md:mt-0 ml-2 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 text-xs md:text-sm rounded-md") %> </div> </div> </.form> <div class="mt-4 flex flex-col items-center space-y-1"> <%= cond do %> <% @loading? -> %> <div class="w-full max-w-3xl py-2 flex justify-center"> <img src="https://developer.riotgames.com/static/img/katarina.55a01cf0560a.gif" /> </div> <% length(@participants) == 0 -> %> <div class="w-full max-w-3xl py-2 flex justify-center"> <div>No results...</div> </div> <% true -> %> <div class="w-full max-w-3xl grid-participants-header px-1 py-2 text-xs"> <div></div> <div>Pro player</div> <div class="flex justify-center">Matchup</div> <div class="flex justify-center">KDA</div> <div class="flex justify-center">Summoners</div> <div class="flex justify-center">Build</div> </div> <div id="participants" phx-update={@update} class="w-full max-w-3xl flex-1 flex flex-col items-center space-y-1"> <%= for participant <- @participants do %> <div id={"participant-#{participant.id}"} class={[if(participant.win, do: "border-blue-500", else: "border-red-500"), "hover:bg-gray-100 hover:cursor-pointer border-l-8 w-full max-w-3xl grid-participants px-1 py-2 bg-white rounded-lg overflow-hidden shadow"]}> <div class="grid-area-creation flex md:justify-center items-center"> <time id={"time-ago-#{participant.id}"} phx-hook="TimeAgo" datetime={participant.game.creation}></time> </div> <div class="grid-area-player flex items-center"> <!-- Heroicon name: user-circle --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8"> <path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" /> </svg> <span class="flex-1 ml-1 text-ellipsis overflow-hidden"> <%= participant.summoner.pro.name %> </span> </div> <div class="grid-area-versus flex justify-center items-center space-x-1"> <%= img_tag(Ddragon.get_champion_image(participant.game.version, participant.champion_id), class: "w-8 h-8 rounded-full") %> <span>vs</span> <%= img_tag(Ddragon.get_champion_image(participant.game.version, participant.opponent_participant.champion_id), class: "w-8 h-8 rounded-full") %> </div> <div class="grid-area-kda flex justify-center items-center"> <span class="font-medium"> <%= participant.kills %> </span> / <span class="font-medium text-red-500"> <%= participant.deaths %> </span> / <span class="font-medium"> <%= participant.assists %> </span> </div> <div class="grid-area-summoners flex justify-center items-center space-x-1"> <%= for summoner_key <- participant.summoners do %> <%= img_tag(Ddragon.get_summoner_image(participant.game.version, summoner_key), class: "w-8 h-8 border border-gray-400") %> <% end %> </div> <div class="grid-area-build flex justify-center items-center space-x-1"> <%= for item_key <- participant.items do %> <%= if src = Ddragon.get_item_image(participant.game.version, item_key) do %> <img src={src} class="w-8 h-8" /> <% else %> <div class="bg-gray-900 w-8 h-8 border border-gray-400"></div> <% end %> <% end %> </div> <div class="grid-area-ellipsis hidden md:flex flex-1 justify-center items-center"> <!-- Heroicon name: ellipsis-vertical --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6"> <path fill-rule="evenodd" d="M4.5 12a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zm6 0a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zm6 0a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z" clip-rule="evenodd" /> </svg> </div> </div> <% end %> </div> <% end %> <div id="infinite-scroll" phx-hook="InfiniteScroll" data-page={@page.page_number} class="w-full max-w-3xl py-2 flex justify-center"> <img class={if not @load_more?, do: "invisible"} src="https://developer.riotgames.com/static/img/katarina.55a01cf0560a.gif" /> </div> </div> </div>

Changes:

  • We use a dynamic phx-update since we need to append extra data when the infinite scroll trigger or replace all the data when a new search query is typed.
  • We use temporary assigns for our participants in the mount since we will render a large collection of data it's better to not keep it in memory.
  • We handle the event load-more it will asynchronous call query_pro_participants while we put a loading animation
  • On the view we added phx-update and the InfiniteScroll hook

Visit http://localhost:4000 and scroll to trigger a load more

Closing thoughts

Well done and thanks for sticking with me to the end! We did the first part of our liveview application.

In the next part which is the final one we will:

  • refactor our current views in live components
  • query a game for all the details and add it to our view on click
  • add game in realtime using PubSub
  • deploy on fly.io

Be sure to sign up to the newsletter so that you won't miss the next Part. Feel free to leave comments or feedback. I also appreciate if you can star ⭐ the companion code repo.

See you soon !

Stay up to date

Sign up for the mailing list and get notified via email when new blog posts come out.