Skip to content

Instantly share code, notes, and snippets.

@keatz55
Last active April 12, 2021 17:49
Show Gist options
  • Save keatz55/fd015ab6136c059c56fa1243550d11b4 to your computer and use it in GitHub Desktop.
Save keatz55/fd015ab6136c059c56fa1243550d11b4 to your computer and use it in GitHub Desktop.
LiveView Query Helper Module
defmodule ExampleWeb.ArticleLive.Index do
alias Example.{Articles, Tags}
alias ExampleWeb.{ArticleLive, ComponentLive, Query}
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~L"""
<div class="container max-w-screen-md mx-auto pt-6">
<!-- Title -->
<h1 class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl mb-6 text-center">
Articles
</h1>
<!-- Text Filter -->
<div class="mb-6 shadow sm:rounded-lg">
<%= live_component(@socket, ComponentLive.TextFilter,
id: "article-search",
param: "s",
query: @article_suquery
) %>
</div>
</div>
<div class="container max-w-screen-lg mx-auto">
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="px-4 sm:px-0">
<!-- Tag Filter -->
<%= live_component(@socket, ComponentLive.MultiselectFilter,
id: "tags-filter",
options: @tag_opts,
param: "tags",
query: @article_suquery,
text_filter: [param: "tag_s"],
title: "Tags"
) %>
</div>
</div>
<div class="mt-5 md:mt-0 md:col-span-3">
<!-- Article List -->
<%= for article <- @page.entries do %>
<div class="bg-white border border-gray-100 shadow overflow-hidden sm:rounded-lg mb-6">
<div class="px-6 py-4">
<h2 class="font-bold text-xl mb-2"><%= article.title %></h2>
<p class="text-gray-700 text-base"><%= article.description %></p>
</div>
<div class="px-6 pt-4 pb-2">
<%= for tag <- @tags do %>
<%= live_component(@socket, ComponentLive.Chip, text: tag) %>
<% end %>
</div>
</div>
<% end %>
<!-- Pagination -->
<%= live_component(@socket, ComponentLive.Pagination,
page: @page,
query: @article_suquery
) %>
<span><%= live_patch "New", to: Routes.live_path(@socket, ArticleLive.New) %></span>
</div>
</div>
</div>
"""
end
@impl true
def mount(_params, session, socket), do: {:ok, assign_defaults(socket, session)}
@impl true
def handle_params(query, uri, socket) do
article_subquery = Query.init(query, uri, "articles")
{:ok, page} = article_subquery |> Query.to_map() |> Articles.list()
{:ok, tags} = article_subquery |> Query.to_map() |> Tags.list()
{:noreply,
assign(socket,
article_suquery: article_subquery,
page: page,
tags: tags
)}
end
end
defmodule ExampleWeb.ComponentLive.MultiselectFilter do
@moduledoc """
Enables multi select filtering via URL query params.
"""
alias ExampleWeb.{ComponentLive, Query}
use ExampleWeb, :live_component
@default_text_filter_class "bg-gray-50 border-t border-b border-gray-100"
@impl true
def render(assigns) do
~L"""
<div
x-data={show:<%= @default_expanded? %>}
class="bg-white border border-gray-100 shadow overflow-hidden sm:rounded-lg mb-6"
>
<!-- Header -->
<div class="flex py-2 px-3 items-center text-gray-700 font-semibold">
<!-- Title -->
<%= @title %>
<div class="flex-1"></div>
<!-- Clear Btn -->
<%= if @clear_btn.show? do %>
<%= live_patch(
to: @clear_btn.link,
class: "text-xs py-1 px-2 border border-gray-400 text-gray-600 flex items-center rounded bg-gray-100"
) do %>
clear <i class="fas fa-times ml-1"></i>
<% end %>
<% end %>
<!-- Collapse Btn -->
<div @click="show=!show" class="py-1 pl-2 cursor-pointer">
<i x-show="!show" class="fas fa-chevron-down"></i>
<i x-show="show" class="fas fa-chevron-up"></i>
</div>
</div>
<!-- Content -->
<div x-show="show">
<!-- Text Filter -->
<%= if assigns[:text_filter] do %>
<%= live_component(@socket, ComponentLive.TextFilter, @text_filter) %>
<% end %>
<!-- Options -->
<div
class="overflow-y-auto py-1"
style="max-height:150px;"
>
<%= if !Enum.any?(@options) do %>
<div class="py-2 px-2 text-gray-700 text-sm flex items-center">
No results to display
</div>
<% end %>
<%= for opt <- @options do %>
<%= live_patch(
to: opt.link,
class: "py-1 px-2 text-gray-700 text-sm flex items-center"
) do %>
<input
class="cursor-pointer mr-3"
type="checkbox"
<%= if opt.checked?, do: "checked" %>
>
<%= opt.label %>
<% end %>
<% end %>
</div>
</div>
</div>
"""
end
@impl true
def update(%{options: options, param: param, query: query} = assigns, socket) do
selected = query |> Query.get(param, []) |> MapSet.new()
{:ok,
socket
|> assign(assigns)
|> assign(
clear_btn: get_clear_btn(assigns),
default_expanded?: Map.get(assigns, :default_expanded?, true),
options: Enum.map(options, &normalize_option(&1, assigns, selected)),
text_filter: normalize_text_filter_assigns(assigns)
)}
end
defp get_clear_btn(%{param: param, query: query}) do
link = query |> Query.delete([param, "pg"]) |> Query.to_link()
%{link: link, show?: Query.has_param?(query, param)}
end
defp normalize_option(option, assigns, selected) do
option |> with_checked?(selected) |> with_link(assigns)
end
defp with_checked?(option, selected) do
checked? = MapSet.member?(selected, option.value)
Map.put(option, :checked?, checked?)
end
defp with_link(option, %{param: param, query: query}) do
link = query |> build_option_query(param, option) |> Query.delete("pg") |> Query.to_link()
Map.put(option, :link, link)
end
defp build_option_query(query, param, %{checked?: false, value: value}) do
Query.update(query, param, [value], &[value | &1])
end
defp build_option_query(query, param, %{checked?: true, value: value}) do
Query.update(query, param, [], &List.delete(&1, value))
end
defp normalize_text_filter_assigns(%{text_filter: child_assigns} = assigns) do
child_assigns |> Map.new() |> with_class() |> with_id(assigns) |> with_query(assigns)
end
defp with_class(%{class: _} = child_assigns), do: child_assigns
defp with_class(child_assigns), do: Map.put(child_assigns, :class, @default_text_filter_class)
defp with_id(%{id: _} = child_assigns, _parent_assigns), do: child_assigns
defp with_id(child_assigns, %{id: id}), do: Map.put(child_assigns, :id, "#{id}-text-filter")
defp with_query(%{query: _} = child_assigns, _parent_assigns), do: child_assigns
defp with_query(child_assigns, %{query: query}), do: Map.put(child_assigns, :query, query)
end
defmodule ExampleWeb.Query do
@moduledoc """
Query helper library.
"""
defstruct data: nil, path: nil, subquery_param: nil
@doc """
Initializes a query struct.
## Examples
iex> init(query, uri)
%Query{}
iex> init(query, uri, subquery_param)
%Query{}
"""
def init(query, uri), do: %__MODULE__{data: query, path: URI.parse(uri).path}
def init(query, uri, subquery_param) do
%__MODULE__{data: query, path: URI.parse(uri).path, subquery_param: subquery_param}
end
@doc """
Returns param from query or subquery data.
## Examples
iex> get(query, param)
value
"""
def get(%__MODULE__{} = query, param, default \\ nil) do
query |> get_data() |> Map.get(param, default)
end
@doc """
Puts param value in query or subquery data.
## Examples
iex> put(query, param, value)
%Query{}
"""
def put(%__MODULE__{} = query, param, value) do
query |> get_data() |> Map.put(param, value) |> put_data(query)
end
@doc """
Applies update function on query or subquery data param.
## Examples
iex> update(query, param, default, fun)
%Query{}
"""
def update(%__MODULE__{} = query, param, default, fun) do
query |> get_data() |> Map.update(param, default, fun) |> put_data(query)
end
@doc """
Deletes param from query or subquery data if exists.
## Examples
iex> delete(query, param(s))
%Query{}
"""
def delete(%__MODULE__{} = query, param) when is_bitstring(param), do: delete(query, [param])
def delete(%__MODULE__{} = query, params) do
query |> get_data() |> Map.drop(params) |> put_data(query)
end
@doc """
Checks if param exists in query or subquery data.
## Examples
iex> has_param?(query, param)
true
"""
def has_param?(%__MODULE__{} = query, param), do: query |> get_data() |> Map.has_key?(param)
@doc """
Returns a query-encoded link.
## Examples
iex> to_link(query)
"http://localhost:4000?param=value"
"""
def to_link(%__MODULE__{data: data, path: path}), do: path <> encode_query(data)
defp encode_query(data) do
with qs when qs != "" <- Plug.Conn.Query.encode(data) do
"?" <> qs
end
end
@doc """
Returns a query or subquery data as a map.
## Examples
iex> to_map(query)
%{}
"""
def to_map(%__MODULE__{} = query), do: get_data(query)
defp get_data(%__MODULE__{data: data, subquery_param: nil}), do: data
defp get_data(%__MODULE__{data: data, subquery_param: param}), do: Map.get(data, param, %{})
defp put_data(data, %__MODULE__{subquery_param: nil} = query) do
%__MODULE__{query | data: data}
end
defp put_data(subquery, %__MODULE__{data: data, subquery_param: subquery_param} = query) do
%__MODULE__{query | data: Map.put(data, subquery_param, subquery)}
end
end
defmodule ExampleWeb.ComponentLive.TextFilter do
@moduledoc """
Enables text based filtering via URL query params.
"""
alias ExampleWeb.Query
alias Phoenix.LiveView.Socket
use ExampleWeb, :live_component
@default_class "focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm bg-white border border-gray-300 rounded-md"
@default_icon "fas fa-search"
@default_placeholder "Search"
@impl true
def render(assigns) do
~L"""
<div class="<%= @class %>">
<div class="relative text-gray-600 text-sm ">
<!-- Icon -->
<%= if assigns[:icon] do %>
<div class="absolute top-0 bottom-0 left-0 py-0 px-3 flex items-center">
<i class="<%= @icon %>"></i>
</div>
<% end %>
<!-- Form -->
<%= f = form_for(:filter, "#",
class: "flex-1",
phx_change: "filter",
phx_submit: "filter",
phx_target: @myself
) %>
<%= text_input(f, :text,
phx_debounce: 350,
placeholder: @placeholder,
class: "w-full py-2 px-10 bg-transparent border-none focus:outline-none focus:shadow-none",
value: @value
) %>
</form>
<!-- Clear Btn -->
<%= if @clear_btn.show? do %>
<%= live_patch(
to: @clear_btn.link,
class: "absolute top-0 right-0 bottom-0 py-0 px-3 flex items-center"
) do %>
<i class="fas fa-times"></i>
<% end %>
<% end %>
</div>
</div>
"""
end
@impl true
def update(%{param: param, query: query} = assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign(
class: Map.get(assigns, :class, @default_class),
clear_btn: get_clear_btn(assigns),
icon: Map.get(assigns, :icon, @default_icon),
placeholder: Map.get(assigns, :placeholder, @default_placeholder),
value: Query.get(query, param, "")
)}
end
@impl true
def handle_event("filter", %{"filter" => %{"text" => value}}, socket) do
apply_filter(String.trim(value), socket)
end
defp apply_filter(value, %Socket{assigns: %{param: param, query: query}} = socket) do
link = query |> update_query(param, value) |> Query.delete("pg") |> Query.to_link()
{:noreply, push_patch(socket, to: link)}
end
defp update_query(query, param, ""), do: Query.delete(query, param)
defp update_query(query, param, value), do: Query.put(query, param, value)
defp get_clear_btn(%{param: param, query: query}) do
link = query |> Query.delete([param, "pg"]) |> Query.to_link()
%{link: link, show?: Query.has_param?(query, param)}
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment