Created
March 2, 2023 05:39
-
-
Save mplatts/6ee65c42a28c80e75832a2b8a9657eed to your computer and use it in GitHub Desktop.
data_table_streams
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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