Skip to content

Instantly share code, notes, and snippets.

@mplatts
Created March 2, 2023 05:39
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mplatts/6ee65c42a28c80e75832a2b8a9657eed to your computer and use it in GitHub Desktop.
Save mplatts/6ee65c42a28c80e75832a2b8a9657eed to your computer and use it in GitHub Desktop.
data_table_streams
defmodule PetalFramework.Components.DataTable do
@moduledoc """
Render your data with ease. Uses Flop under the hood: https://github.com/woylie/flop
## Example
# In a Live View
defmodule PetalProWeb.AdminJobsLive do
use PetalProWeb, :live_view
def mount(_params, _session, socket) do
{:ok, socket}
end
def handle_params(params, _url, socket) do
{:noreply, assign_users(socket, params)}
end
def handle_event("update_filters", params, socket) do
query_params = get_query_params(socket.assigns.meta.flop, params)
{:noreply, push_patch(socket, to: ~p"/admin/users?\#{query_params}")}
end
defp assign_users(socket, params) do
# This takes the params from the URL and validates them against the Flop schema.
# It also runs the query with the params applied, fetching the data.
case Accounts.list_users_paginated(params) do
{:ok, {users, meta}} ->
assign(socket, %{
users: users,
meta: meta
})
_ ->
push_navigate(socket, to: ~p"/admin/users")
end
end
end
# In your template:
<.data_table
meta={@meta}
rows={@users}
page_size_options={[10, 20, 50]}
>
<:if_empty>No users found</:if_empty>
<:col field={:id} type={:integer} filterable={[:==]} class="w-36" />
<:col field={:name} sortable />
<:col label="Actions" let={user}>
<.button>Edit <%= user.name %></.button>
</:col>
</.data_table>
## <:col> attributes:
### Sortable (default: false)
<:col field={:name} sortable />
### Filterable
You can filter your columns by using the `filterable` property.
<:col field={:name} filterable={:==} />
Filterable options:
:== "Salicaceae" WHERE column = 'Salicaceae'
:!= "Salicaceae" WHERE column != 'Salicaceae'
:=~ "cyth" WHERE column ILIKE '%cyth%'
:empty true WHERE (column IS NULL) = true
:empty false WHERE (column IS NULL) = false
:not_empty true WHERE (column IS NOT NULL) = true
:not_empty false WHERE (column IS NOT NULL) = false
:<= 10 WHERE column <= 10
:< 10 WHERE column < 10
:>= 10 WHERE column >= 10
:> 10 WHERE column > 10
:in ["pear", "plum"] WHERE column = ANY('pear', 'plum')
:contains "pear" WHERE 'pear' = ANY(column)
:like "cyth" WHERE column LIKE '%cyth%'
:like_and "Rubi Rosa" WHERE column LIKE '%Rubi%' AND column LIKE '%Rosa%'
:like_or "Rubi Rosa" WHERE column LIKE '%Rubi%' OR column LIKE '%Rosa%'
:ilike "cyth" WHERE column ILIKE '%cyth%'
:ilike_and "Rubi Rosa" WHERE column ILIKE '%Rubi%' AND column ILIKE '%Rosa%'
:ilike_or "Rubi Rosa" WHERE column ILIKE '%Rubi%' OR column ILIKE '%Rosa%'
### Renderer
Type is the type of cell that will be rendered.
<:col field={:name} sortable renderer={:plaintext} />
<:col field={:inserted_at} renderer={:date} date_format={YYYY} />
Renderer options:
:plaintext (for strings) *default
:checkbox (for booleans)
:date paired with optional param date_format: "{YYYY}" - s<% https://hexdocs.pm/timex/Timex.Format.DateTime.Formatters.Default.html %>
:datetime paired with optional param date_format: "{YYYY}"
:money paired with optional currency: "USD" (for money)
## Query options for Flop
You can pass query options for Flop with the query_opts keyword, https://hexdocs.pm/flop/Flop.html#types, e.g.
Flop.validate_and_run(User, query_params, max_limit: 100)
## Compound & join fields
For these you will need to use https://hexdocs.pm/flop/Flop.Schema.html.
Follow the instructions on setting up the `@derive` bit in your schema file. For join fields,
make sure your `ecto_query` has a join in it. You have to name the join field too. Eg:
# In your model
@derive {
Flop.Schema,
filterable: [:field_from_joined_table],
sortable: [:field_from_joined_table],
join_fields: [field_from_joined_table: {:some_other_table, :field_name}]
}
# The ecto_query called by your Live View:
query = from(m in __MODULE__,
join: u in assoc(m, :some_other_table),
as: :some_other_table,
preload: [:some_other_table])
Flop.validate_and_run(query, params, for: __MODULE__)
# Now you can do a col with that field
<:col field={:field_from_joined_table} let={something}>
<%= something.some_other_table.field_name %>
</:col>
### TODO
- Can order_by joined table fields (e.g. customer.user.name)
- Can filter by a select list of values ... eg Status: ["Active", "Inactive"]
"""
use Phoenix.Component
import PetalComponents.{Table, Pagination}
use PetalProWeb, :verified_routes
alias PetalFramework.Components.DataTable.{Cell, Header, FilterSet, Filter}
@defaults [
page_size_options: [10, 20, 50]
]
attr :meta, Flop.Meta, required: true
attr :items, :list, required: true
slot :col, required: true do
attr :label, :string
attr :class, :string
attr :field, :atom
attr :sortable, :boolean
attr :filterable, :list
attr :type, :atom
attr :renderer, :atom
end
slot :if_empty, required: false
def data_table(assigns) do
filter_changeset = build_filter_changeset(assigns.col, assigns.meta.flop)
assigns = assign(assigns, :filter_changeset, filter_changeset)
# Account for streams
assigns =
if match?(%{items: %Phoenix.LiveView.LiveStream{}}, assigns) do
assigns
else
assign(assigns, :items, Enum.map(assigns.items, &{"item-#{&1.id}", &1}))
end
assigns =
assigns
|> assign(:filtered?, Enum.any?(assigns.meta.flop.filters, fn x -> x.value end))
|> assign_new(:filter_changeset, fn ->
filter_set = %FilterSet{}
FilterSet.changeset(filter_set)
end)
|> assign_new(:page_sizes, fn ->
assigns[:page_size_options] || Keyword.get(@defaults, :page_size_options)
end)
~H"""
<div>
<.form
:let={filter_form}
id="data-table-filter-form"
for={@filter_changeset}
as={:filters}
phx-change="update_filters"
phx-submit="update_filters"
>
<.table class="overflow-visible">
<thead>
<.tr>
<%= for col <- @col do %>
<Header.render
column={col}
meta={@meta}
filter_form={filter_form}
no_results?={@items == []}
filtered?={@filtered?}
/>
<% end %>
</.tr>
</thead>
<tbody>
<%= if @items == [] do %>
<.tr>
<.td colspan={length(@col)}>
<%= if @if_empty, do: render_slot(@if_empty), else: "No results" %>
</.td>
</.tr>
<% end %>
<.tr
:for={{item_id, item} <- @items}
id={item_id}
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @items) && "stream"}
>
<.td :for={col <- @col}>
<%= if col[:inner_block] do %>
<%= render_slot(col, Function.identity(item)) %>
<% else %>
<Cell.render column={col} item={Function.identity(item)} />
<% end %>
</.td>
</.tr>
</tbody>
</.table>
</.form>
<div :if={@items != []} class="flex justify-between mt-5 rows-center">
<div class="text-sm text-gray-600 dark:text-gray-400">
<div class="">
Showing <%= get_first_row_index(@meta) %>-<%= get_last_row_index(@meta) %> of <%= @meta.total_count %> rows
</div>
<div class="flex gap-2">
<div>Rows per page:</div>
<%= for page_size <- @page_sizes do %>
<%= if @meta.page_size == page_size do %>
<div class="font-semibold"><%= page_size %></div>
<% else %>
<.link
patch={build_url_query(Map.put(@meta.flop, :page_size, page_size))}
class="block text-blue-500 dark:text-blue-400"
>
<%= page_size %>
</.link>
<% end %>
<% end %>
</div>
</div>
<%= if @meta.total_pages > 1 do %>
<.pagination
link_type="live_patch"
class="my-5"
path={
build_url_query(Map.put(@meta.flop, :page, ":page"))
|> String.replace("%3Apage", ":page")
}
current_page={@meta.current_page}
total_pages={@meta.total_pages}
/>
<% end %>
</div>
</div>
"""
end
def build_url_query(flop) do
"?" <> (flop |> to_query |> Plug.Conn.Query.encode())
end
defp get_first_row_index(meta) do
if meta.current_page == 1 do
1
else
(meta.current_page - 1) * meta.page_size + 1
end
end
defp get_last_row_index(meta) do
if meta.current_page == meta.total_pages do
meta.total_count
else
meta.current_page * meta.page_size
end
end
defp to_query(%Flop{filters: filters} = flop, opts \\ []) do
filter_map =
filters
|> Enum.filter(fn filter -> filter.value != nil end)
|> Stream.with_index()
|> Enum.into(%{}, fn {filter, index} ->
{index, Map.from_struct(filter)}
end)
default_limit = Flop.get_option(:default_limit, opts)
default_order = Flop.get_option(:default_order, opts)
[]
|> maybe_put(:offset, flop.offset, 0)
|> maybe_put(:page, flop.page, 1)
|> maybe_put(:after, flop.after)
|> maybe_put(:before, flop.before)
|> maybe_put(:page_size, flop.page_size, default_limit)
|> maybe_put(:limit, flop.limit, default_limit)
|> maybe_put(:first, flop.first, default_limit)
|> maybe_put(:last, flop.last, default_limit)
|> maybe_put_order_params(flop, default_order)
|> maybe_put(:filters, filter_map)
end
@spec maybe_put(keyword, atom, any, any) :: keyword
defp maybe_put(params, key, value, default \\ nil)
defp maybe_put(keywords, _, nil, _), do: keywords
defp maybe_put(keywords, _, [], _), do: keywords
defp maybe_put(keywords, _, map, _) when map == %{}, do: keywords
# It's not enough to avoid setting (initially), we need to remove any existing value
defp maybe_put(keywords, key, val, val), do: Keyword.delete(keywords, key)
defp maybe_put(keywords, key, value, _), do: Keyword.put(keywords, key, value)
# Puts the order params of a into a keyword list only if they don't match the
# defaults passed as the last argument.
defp maybe_put_order_params(
params,
%{order_by: order_by, order_directions: order_directions},
%{order_by: order_by, order_directions: order_directions}
),
do: params
defp maybe_put_order_params(
params,
%{order_by: order_by, order_directions: order_directions},
_
) do
params
|> maybe_put(:order_by, order_by)
|> maybe_put(:order_directions, order_directions)
end
defp build_filter_changeset(columns, flop) do
filters =
columns
|> Enum.reduce([], fn col, acc ->
if col[:filterable] do
default_op = List.first(col.filterable)
flop_filter = Enum.find(flop.filters, &(&1.field == col.field))
filter = %Filter{
field: col.field,
op: (flop_filter && flop_filter.op) || default_op,
value: (flop_filter && flop_filter.value) || nil
}
[filter | acc]
else
acc
end
end)
filter_set = %FilterSet{filters: filters}
FilterSet.changeset(filter_set)
end
@doc """
Use this to build a query when the filters have changed. Pass a flop and the params from the "update_filters" event.
Usage:
def handle_event("update_filters", params, socket) do
query_params = get_query_params(socket.assigns.flop, params)
{:noreply, push_patch(socket, to: ~p"/admin/users?\#{query_params}")}
end
"""
def get_query_params(flop, %{"filters" => filter_params}) do
to_query(flop)
|> Keyword.put(:filters, filter_params["filters"])
|> Keyword.put(:page, "1")
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment