mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-28 02:18:50 +00:00
Fixes layout of filters controls. A more permanent fix will be introduced as part of #8255
940 lines
30 KiB
Elixir
940 lines
30 KiB
Elixir
defmodule Web.LiveTable do
|
|
@moduledoc """
|
|
This module implements a live table component and it's helper function that are built
|
|
on top of `Domain.Repo.list/3` and allows to render a table with sorting, filtering and pagination.
|
|
"""
|
|
use Phoenix.LiveView
|
|
import Web.TableComponents
|
|
import Web.CoreComponents
|
|
import Web.FormComponents
|
|
|
|
@doc """
|
|
A drop-in replacement of `Web.TableComponents.table/1` component that adds sorting, filtering and pagination.
|
|
"""
|
|
attr :id, :string, required: true, doc: "the id of the table"
|
|
attr :ordered_by, :any, required: true, doc: "the current order for the table"
|
|
attr :filters, :list, required: true, doc: "the query filters enabled for the table"
|
|
attr :filter, :map, required: true, doc: "the filter form for the table"
|
|
attr :stale, :boolean, default: false, doc: "hint to the UI that the table data is stale"
|
|
|
|
attr :metadata, :map,
|
|
required: true,
|
|
doc: "the metadata for the table pagination as returned by Repo.list/3"
|
|
|
|
attr :rows, :list, required: true
|
|
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
|
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
|
attr :class, :string, default: nil, doc: "the class for the table"
|
|
|
|
attr :row_item, :any,
|
|
default: &Function.identity/1,
|
|
doc: "the function for mapping each row before calling the :col and :action slots"
|
|
|
|
slot :col, required: true do
|
|
attr :label, :string
|
|
attr :field, :any, doc: "the cursor field that to be used for ordering for this column"
|
|
attr :class, :string
|
|
end
|
|
|
|
slot :action, doc: "the slot for showing user actions in the last table column"
|
|
slot :empty, doc: "the slot for showing a message or content when there are no rows"
|
|
|
|
def live_table(assigns) do
|
|
~H"""
|
|
<.resource_filter stale={@stale} live_table_id={@id} form={@filter} filters={@filters} />
|
|
<div class="overflow-x-auto">
|
|
<table class={["w-full text-sm text-left text-neutral-500 table-fixed"]} id={@id}>
|
|
<.table_header table_id={@id} columns={@col} actions={@action} ordered_by={@ordered_by} />
|
|
<tbody
|
|
id={"#{@id}-rows"}
|
|
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
|
|
>
|
|
<.table_row
|
|
:for={row <- @rows}
|
|
columns={@col}
|
|
actions={@action}
|
|
row={row}
|
|
id={@row_id && @row_id.(row)}
|
|
click={@row_click}
|
|
mapper={@row_item}
|
|
/>
|
|
</tbody>
|
|
</table>
|
|
<div :if={Enum.empty?(@rows) and not has_filter?(@filter, @filters)} id={"#{@id}-empty"}>
|
|
{render_slot(@empty)}
|
|
</div>
|
|
<div :if={Enum.empty?(@rows) and has_filter?(@filter, @filters)} id={"#{@id}-empty"}>
|
|
<div class="flex justify-center text-center text-neutral-500 p-4">
|
|
<div class="w-auto pb-4">
|
|
There are no results matching your filters. <button
|
|
phx-click="filter"
|
|
phx-value-table_id={@id}
|
|
phx-value-filter={nil}
|
|
class={link_style()}
|
|
>Clear filters</button>.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<.paginator id={@id} metadata={@metadata} rows_count={Enum.count(@rows)} />
|
|
"""
|
|
end
|
|
|
|
defp has_filter?(filter, filters) do
|
|
keys =
|
|
Enum.map(filters, fn filter ->
|
|
to_string(filter.name)
|
|
end)
|
|
|
|
Map.take(filter.params, keys) != %{}
|
|
end
|
|
|
|
def datetime_input(assigns) do
|
|
~H"""
|
|
<div class={["flex items-center"]}>
|
|
<input
|
|
placeholder={"#{@filter.title} Started At"}
|
|
type="date"
|
|
name={"#{@field.name}[#{@from_or_to}][date]"}
|
|
id={"#{@field.id}[#{@from_or_to}][date]"}
|
|
value={normalize_value("date", Map.get(@field.value || %{}, @from_or_to))}
|
|
max={@max}
|
|
min="2023-01-01"
|
|
autocomplete="off"
|
|
class={[
|
|
"bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded",
|
|
"block w-1/2 mr-1",
|
|
"disabled:bg-neutral-50 disabled:text-neutral-500 disabled:border-neutral-300 disabled:shadow-none",
|
|
"focus:outline-none focus:border-1 focus:ring-0",
|
|
@field.errors != [] && "border-rose-400"
|
|
]}
|
|
/>
|
|
<input
|
|
type="time"
|
|
step="1"
|
|
placeholder={"#{@filter.title} Started At"}
|
|
name={@field.name <> "[#{@from_or_to}][time]"}
|
|
id={@field.id <> "[#{@from_or_to}][time]"}
|
|
value={normalize_value("time", Map.get(@field.value || %{}, @from_or_to)) || "00:00:00"}
|
|
class={[
|
|
"bg-neutral-50 border text-neutral-900 text-sm rounded",
|
|
"block w-1/2",
|
|
"border-neutral-300",
|
|
"disabled:bg-neutral-50 disabled:text-neutral-500 disabled:border-neutral-300 disabled:shadow-none",
|
|
"focus:outline-none focus:border-1 focus:ring-0",
|
|
@field.errors != [] && "border-rose-400"
|
|
]}
|
|
/>
|
|
<.error :for={msg <- @field.errors} data-validation-error-for={@field.name}>
|
|
{msg}
|
|
</.error>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp normalize_value("date", %DateTime{} = datetime),
|
|
do: DateTime.to_date(datetime) |> Date.to_iso8601()
|
|
|
|
defp normalize_value("time", %DateTime{} = datetime),
|
|
do: DateTime.to_time(datetime) |> Time.to_iso8601()
|
|
|
|
defp normalize_value(_, nil),
|
|
do: nil
|
|
|
|
defp resource_filter(assigns) do
|
|
~H"""
|
|
<.form
|
|
:if={@filters != []}
|
|
id={"#{@live_table_id}-filters"}
|
|
for={@form}
|
|
phx-change="filter"
|
|
phx-debounce="100"
|
|
onkeydown="return event.key != 'Enter';"
|
|
>
|
|
<.input type="hidden" name="table_id" value={@live_table_id} />
|
|
|
|
<div class="mb-2 space-y-2 md:space-y-0 md:gap-2 md:flex md:justify-between">
|
|
<div>
|
|
<.button
|
|
:if={@stale}
|
|
id={"#{@live_table_id}-reload-btn"}
|
|
type="button"
|
|
style="info"
|
|
title="The table data has changed."
|
|
phx-click="reload"
|
|
phx-value-table_id={@live_table_id}
|
|
>
|
|
<.icon name="hero-arrow-path" class="mr-1 w-3.5 h-3.5" /> Reload
|
|
</.button>
|
|
</div>
|
|
|
|
<div class="space-y-2 md:space-y-0 md:gap-1 md:flex">
|
|
<.filter
|
|
:for={filter <- @filters}
|
|
live_table_id={@live_table_id}
|
|
form={@form}
|
|
filter={filter}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</.form>
|
|
"""
|
|
end
|
|
|
|
defp filter(%{filter: %{type: {:range, :datetime}}} = assigns) do
|
|
~H"""
|
|
<div class="flex items-center">
|
|
<.datetime_input
|
|
field={@form[@filter.name]}
|
|
filter={@filter}
|
|
from_or_to={:from}
|
|
max={Date.utc_today()}
|
|
/>
|
|
<div class="mx-2 text-neutral-500">to</div>
|
|
<.datetime_input
|
|
field={@form[@filter.name]}
|
|
filter={@filter}
|
|
from_or_to={:to}
|
|
max={Date.utc_today()}
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp filter(%{filter: %{type: {:string, :websearch}}} = assigns) do
|
|
~H"""
|
|
<div class="flex items-center order-last">
|
|
<div class="relative w-full" phx-feedback-for={@form[@filter.name].name}>
|
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
<.icon name="hero-magnifying-glass" class="w-5 h-5 text-neutral-500" />
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
name={@form[@filter.name].name}
|
|
id={@form[@filter.name].id}
|
|
value={Phoenix.HTML.Form.normalize_value("text", @form[@filter.name].value)}
|
|
placeholder={"Search by " <> @filter.title}
|
|
class={[
|
|
"bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded",
|
|
"block w-full md:w-72 pl-10 p-2",
|
|
"disabled:bg-neutral-50 disabled:text-neutral-500 disabled:border-neutral-300 disabled:shadow-none",
|
|
"focus:outline-none focus:border-1 focus:ring-0",
|
|
@form[@filter.name].errors != [] && "border-rose-400"
|
|
]}
|
|
/>
|
|
<.error
|
|
:for={msg <- @form[@filter.name].errors}
|
|
data-validation-error-for={@form[@filter.name].name}
|
|
>
|
|
{msg}
|
|
</.error>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp filter(%{filter: %{type: {:string, :email}}} = assigns) do
|
|
~H"""
|
|
<div class="flex items-center order-last">
|
|
<div class="relative w-full" phx-feedback-for={@form[@filter.name].name}>
|
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
<.icon name="hero-magnifying-glass" class="w-5 h-5 text-neutral-500" />
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
name={@form[@filter.name].name}
|
|
id={@form[@filter.name].id}
|
|
value={Phoenix.HTML.Form.normalize_value("text", @form[@filter.name].value)}
|
|
placeholder={"Search by " <> @filter.title}
|
|
class={[
|
|
"bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded",
|
|
"block w-full md:w-72 pl-10 p-2",
|
|
"disabled:bg-neutral-50 disabled:text-neutral-500 disabled:border-neutral-300 disabled:shadow-none",
|
|
"focus:outline-none focus:border-1 focus:ring-0",
|
|
@form[@filter.name].errors != [] && "border-rose-400"
|
|
]}
|
|
/>
|
|
<.error
|
|
:for={msg <- @form[@filter.name].errors}
|
|
data-validation-error-for={@form[@filter.name].name}
|
|
>
|
|
{msg}
|
|
</.error>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp filter(%{filter: %{type: {:string, :uuid}}} = assigns) do
|
|
~H"""
|
|
<div class="flex items-center order-4">
|
|
<div class="w-full">
|
|
<.input
|
|
type="group_select"
|
|
field={@form[@filter.name]}
|
|
options={
|
|
[
|
|
{nil, [{"For any " <> @filter.title, nil}]}
|
|
] ++ @filter.values
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp filter(%{filter: %{type: :string, values: values}} = assigns)
|
|
when 0 < length(values) and length(values) < 5 do
|
|
~H"""
|
|
<div class="flex items-center order-first">
|
|
<div class="flex rounded" role="group">
|
|
<.intersperse_blocks>
|
|
<:item>
|
|
<label
|
|
for={"#{@live_table_id}-#{@filter.name}-__all__"}
|
|
class={[
|
|
"px-4 py-2 text-sm border-neutral-300 text-neutral-900",
|
|
"hover:bg-neutral-200 hover:text-neutral-700",
|
|
"cursor-pointer",
|
|
"border-y border-l rounded-l",
|
|
is_nil(@form[@filter.name].value) && "bg-neutral-100"
|
|
]}
|
|
>
|
|
<.input
|
|
id={"#{@live_table_id}-#{@filter.name}-__all__"}
|
|
type="radio"
|
|
field={@form[@filter.name]}
|
|
name={"_reset:" <> @form[@filter.name].name}
|
|
value="true"
|
|
checked={is_nil(@form[@filter.name].value)}
|
|
class="hidden"
|
|
/> All
|
|
</label>
|
|
</:item>
|
|
|
|
<:item :let={position} :for={{label, value} <- @filter.values}>
|
|
<label
|
|
for={"#{@live_table_id}-#{@filter.name}-#{value}"}
|
|
class={[
|
|
"px-4 py-2 text-sm border-neutral-300 text-neutral-900",
|
|
"hover:bg-neutral-200 hover:text-neutral-700",
|
|
"cursor-pointer",
|
|
@form[@filter.name].value == value && "bg-neutral-100",
|
|
position == :first && "border-y border-l rounded-l",
|
|
position == :last && "border-y border-r rounded-r",
|
|
position != :first && position != :last && "border"
|
|
]}
|
|
>
|
|
<.input
|
|
id={"#{@live_table_id}-#{@filter.name}-#{value}"}
|
|
type="radio"
|
|
field={@form[@filter.name]}
|
|
value={value}
|
|
checked={@form[@filter.name].value == value}
|
|
class="hidden"
|
|
/>
|
|
{label}
|
|
</label>
|
|
</:item>
|
|
</.intersperse_blocks>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp filter(%{filter: %{type: {:list, :string}, values: values}} = assigns)
|
|
when 0 < length(values) and length(values) < 5 do
|
|
~H"""
|
|
<div class="flex items-center order-first">
|
|
<div class="flex rounded w-full" role="group">
|
|
<.intersperse_blocks>
|
|
<:item>
|
|
<label
|
|
for={"#{@live_table_id}-#{@filter.name}-__all__"}
|
|
class={[
|
|
"px-4 py-2 text-sm border-neutral-300 text-neutral-900",
|
|
"hover:bg-neutral-200 hover:text-neutral-700",
|
|
"cursor-pointer",
|
|
"border-y border-l rounded-l",
|
|
is_nil(@form[@filter.name].value) && "bg-neutral-100"
|
|
]}
|
|
>
|
|
<.input
|
|
id={"#{@live_table_id}-#{@filter.name}-__all__"}
|
|
type="checkbox"
|
|
field={@form[@filter.name]}
|
|
name={"_reset:" <> @form[@filter.name].name}
|
|
value={true}
|
|
checked={is_nil(@form[@filter.name].value)}
|
|
class="hidden"
|
|
/> All
|
|
</label>
|
|
</:item>
|
|
|
|
<:item :let={position} :for={{label, value} <- @filter.values}>
|
|
<label
|
|
for={"#{@live_table_id}-#{@filter.name}-#{value}"}
|
|
class={[
|
|
"px-4 py-2 text-sm border-neutral-300 text-neutral-900",
|
|
"hover:bg-neutral-200 hover:text-neutral-700",
|
|
"cursor-pointer",
|
|
@form[@filter.name].value && value in @form[@filter.name].value && "bg-neutral-100",
|
|
position == :first && "border-y border-l rounded-l",
|
|
position == :last && "border-y border-r rounded-r",
|
|
position != :first && position != :last && "border"
|
|
]}
|
|
>
|
|
<.input
|
|
id={"#{@live_table_id}-#{@filter.name}-#{value}"}
|
|
type="checkbox"
|
|
field={@form[@filter.name]}
|
|
name={@form[@filter.name].name <> "[]"}
|
|
value={value}
|
|
checked={@form[@filter.name].value && value in @form[@filter.name].value}
|
|
class="hidden"
|
|
/>
|
|
{label}
|
|
</label>
|
|
</:item>
|
|
</.intersperse_blocks>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp filter(%{filter: %{type: :string, values: values}} = assigns) when length(values) > 0 do
|
|
~H"""
|
|
<div class="flex items-center order-4">
|
|
<div class="w-full">
|
|
<.input
|
|
type="group_select"
|
|
field={@form[@filter.name]}
|
|
options={[
|
|
{nil, [{"For any " <> @filter.title, nil}]},
|
|
{@filter.title, @filter.values}
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def paginator(assigns) do
|
|
~H"""
|
|
<nav
|
|
class="flex flex-col md:flex-row justify-between items-start md:items-center space-y-3 md:space-y-0 p-4"
|
|
aria-label="Table navigation"
|
|
>
|
|
<span class="text-sm text-neutral-500">
|
|
Showing <span class="font-medium text-neutral-900">{@rows_count}</span>
|
|
of <span class="font-medium text-neutral-900">{@metadata.count}</span>
|
|
</span>
|
|
<ul class="inline-flex items-stretch -space-x-px">
|
|
<li>
|
|
<button
|
|
disabled={is_nil(@metadata.previous_page_cursor)}
|
|
class={[pagination_button_class(), "rounded-l"]}
|
|
phx-click="paginate"
|
|
phx-value-cursor={@metadata.previous_page_cursor}
|
|
phx-value-table_id={@id}
|
|
>
|
|
<span class="sr-only">Previous</span>
|
|
<.icon name="hero-chevron-left" class="w-5 h-5" />
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button
|
|
disabled={is_nil(@metadata.next_page_cursor)}
|
|
class={[pagination_button_class(), "rounded-r"]}
|
|
phx-click="paginate"
|
|
phx-value-cursor={@metadata.next_page_cursor}
|
|
phx-value-table_id={@id}
|
|
>
|
|
<span class="sr-only">Next</span>
|
|
<.icon name="hero-chevron-right" class="w-5 h-5" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
"""
|
|
end
|
|
|
|
defp pagination_button_class do
|
|
~w[
|
|
flex items-center justify-center h-full py-1.5 px-3 ml-0 text-neutral-500 bg-white
|
|
border border-neutral-300 hover:bg-neutral-100 hover:text-neutral-700
|
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-neutral-100
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Loads the initial state for a live table and persists it to the socket assigns.
|
|
"""
|
|
def assign_live_table(socket, id, opts) do
|
|
query_module = Keyword.fetch!(opts, :query_module)
|
|
sortable_fields = Keyword.fetch!(opts, :sortable_fields)
|
|
callback = Keyword.fetch!(opts, :callback)
|
|
enforce_filters = Keyword.get(opts, :enforce_filters, [])
|
|
hide_filters = Keyword.get(opts, :hide_filters, [])
|
|
limit = Keyword.get(opts, :limit, 10)
|
|
|
|
# Note: we don't support nesting, :and or :where on the UI yet
|
|
hidden_filters = Enum.map(enforce_filters, &elem(&1, 0)) ++ hide_filters
|
|
|
|
assign(socket,
|
|
live_table_ids: [id] ++ (socket.assigns[:live_table_ids] || []),
|
|
query_module_by_table_id:
|
|
put_table_state(
|
|
socket,
|
|
id,
|
|
:query_module_by_table_id,
|
|
query_module
|
|
),
|
|
callback_by_table_id:
|
|
put_table_state(
|
|
socket,
|
|
id,
|
|
:callback_by_table_id,
|
|
callback
|
|
),
|
|
sortable_fields_by_table_id:
|
|
put_table_state(
|
|
socket,
|
|
id,
|
|
:sortable_fields_by_table_id,
|
|
sortable_fields
|
|
),
|
|
filters_by_table_id:
|
|
put_table_state(
|
|
socket,
|
|
id,
|
|
:filters_by_table_id,
|
|
preload_filters(query_module, hidden_filters, socket.assigns.subject)
|
|
),
|
|
enforced_filters_by_table_id:
|
|
put_table_state(
|
|
socket,
|
|
id,
|
|
:enforced_filters_by_table_id,
|
|
enforce_filters
|
|
),
|
|
order_by_table_id:
|
|
put_table_state(
|
|
socket,
|
|
id,
|
|
:order_by_table_id,
|
|
maybe_use_default_order_by(query_module)
|
|
),
|
|
limit_by_table_id: put_table_state(socket, id, :limit_by_table_id, limit)
|
|
)
|
|
end
|
|
|
|
defp preload_filters(query_module, hidden_filters, subject) do
|
|
query_module
|
|
|> Domain.Repo.Query.get_filters()
|
|
|> Enum.reject(fn filter -> is_nil(filter.title) or filter.name in hidden_filters end)
|
|
|> Enum.map(&preload_values(&1, query_module, subject))
|
|
end
|
|
|
|
def presence_updates_any_id?(
|
|
%Phoenix.Socket.Broadcast{
|
|
event: "presence_diff",
|
|
payload: %{joins: joins, leaves: leaves}
|
|
},
|
|
rendered_ids
|
|
) do
|
|
updated_ids = Map.keys(joins) ++ Map.keys(leaves)
|
|
Enum.any?(updated_ids, &(&1 in rendered_ids))
|
|
end
|
|
|
|
def reload_live_table!(socket, id) do
|
|
callback = Map.fetch!(socket.assigns.callback_by_table_id, id)
|
|
list_opts = Map.get(socket.assigns[:list_opts_by_table_id] || %{}, id, [])
|
|
|
|
socket = assign(socket, stale: false)
|
|
|
|
case callback.(socket, list_opts) do
|
|
{:error, _reason} ->
|
|
uri = URI.parse(socket.assigns.uri)
|
|
push_navigate(socket, to: uri.path)
|
|
|
|
{:ok, socket} ->
|
|
:ok = maybe_notify_test_pid(id)
|
|
socket
|
|
end
|
|
end
|
|
|
|
if Mix.env() == :test do
|
|
defp maybe_notify_test_pid(id) do
|
|
if test_pid = Domain.Config.get_env(:domain, :test_pid) do
|
|
send(test_pid, {:live_table_reloaded, id})
|
|
end
|
|
|
|
:ok
|
|
end
|
|
else
|
|
defp maybe_notify_test_pid(_id), do: :ok
|
|
end
|
|
|
|
@doc """
|
|
This function should be called on each `c:LiveView.handle_params/3` call to
|
|
re-query the list of items using query parameters and update the socket assigns
|
|
with the new state.
|
|
"""
|
|
def handle_live_tables_params(socket, params, uri) do
|
|
socket = assign(socket, uri: uri)
|
|
|
|
Enum.reduce(socket.assigns.live_table_ids, socket, fn id, socket ->
|
|
handle_live_table_params(socket, params, id)
|
|
end)
|
|
end
|
|
|
|
defp handle_live_table_params(socket, params, id) do
|
|
query_module = Map.fetch!(socket.assigns.query_module_by_table_id, id)
|
|
enforced_filters = Map.fetch!(socket.assigns.enforced_filters_by_table_id, id)
|
|
sortable_fields = Map.fetch!(socket.assigns.sortable_fields_by_table_id, id)
|
|
limit = Map.fetch!(socket.assigns.limit_by_table_id, id)
|
|
|
|
with {:ok, filter} <- params_to_filter(id, params),
|
|
filter = enforced_filters ++ filter,
|
|
{:ok, page} <- params_to_page(id, limit, params),
|
|
{:ok, order_by} <- params_to_order_by(sortable_fields, id, params) do
|
|
list_opts = [
|
|
page: page,
|
|
filter: filter,
|
|
order_by: List.wrap(order_by)
|
|
]
|
|
|
|
case maybe_apply_callback(socket, id, list_opts) do
|
|
{:ok, socket} ->
|
|
socket
|
|
|> assign(
|
|
filter_form_by_table_id:
|
|
put_table_state(
|
|
socket,
|
|
id,
|
|
:filter_form_by_table_id,
|
|
filter_to_form(filter, id)
|
|
),
|
|
order_by_table_id:
|
|
put_table_state(
|
|
socket,
|
|
id,
|
|
:order_by_table_id,
|
|
maybe_use_default_order_by(query_module, order_by)
|
|
),
|
|
list_opts_by_table_id:
|
|
put_table_state(
|
|
socket,
|
|
id,
|
|
:list_opts_by_table_id,
|
|
list_opts
|
|
)
|
|
)
|
|
|
|
{:error, :invalid_cursor} ->
|
|
message = "The page was reset due to invalid pagination cursor."
|
|
reset_live_table_params(socket, id, message)
|
|
|
|
{:error, {:unknown_filter, _metadata}} ->
|
|
message = "The page was reset due to use of undefined pagination filter."
|
|
reset_live_table_params(socket, id, message)
|
|
|
|
{:error, {:invalid_type, _metadata}} ->
|
|
message = "The page was reset due to invalid value of a pagination filter."
|
|
reset_live_table_params(socket, id, message)
|
|
|
|
{:error, {:invalid_value, _metadata}} ->
|
|
message = "The page was reset due to invalid value of a pagination filter."
|
|
reset_live_table_params(socket, id, message)
|
|
|
|
{:error, _reason} ->
|
|
raise Web.LiveErrors.NotFoundError
|
|
end
|
|
else
|
|
{:error, :invalid_filter} ->
|
|
message = "The page was reset due to invalid pagination filter."
|
|
reset_live_table_params(socket, id, message)
|
|
end
|
|
end
|
|
|
|
defp maybe_use_default_order_by(query_module, order_by \\ nil)
|
|
|
|
defp maybe_use_default_order_by(query_module, nil) do
|
|
if function_exported?(query_module, :cursor_fields, 0) do
|
|
query_module.cursor_fields() |> List.first()
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
|
|
defp maybe_use_default_order_by(_query_module, order_by) do
|
|
order_by
|
|
end
|
|
|
|
defp reset_live_table_params(socket, id, message) do
|
|
{:noreply, socket} =
|
|
socket
|
|
|> put_flash(:error, message)
|
|
|> update_query_params(fn query_params ->
|
|
Map.reject(query_params, fn {key, _} -> String.starts_with?(key, "#{id}_") end)
|
|
end)
|
|
|
|
socket
|
|
end
|
|
|
|
defp maybe_apply_callback(socket, id, list_opts) do
|
|
previous_list_opts = Map.get(socket.assigns[:list_opts_by_table_id] || %{}, id, [])
|
|
|
|
if list_opts != previous_list_opts do
|
|
callback = Map.fetch!(socket.assigns.callback_by_table_id, id)
|
|
callback.(socket, list_opts)
|
|
else
|
|
{:ok, socket}
|
|
end
|
|
end
|
|
|
|
defp put_table_state(socket, id, key, value) do
|
|
Map.put(socket.assigns[key] || %{}, id, value)
|
|
end
|
|
|
|
defp preload_values(%{values: fun} = filter, _query_module, subject) when is_function(fun, 1) do
|
|
options = fun.(subject) |> Enum.map(&{&1.name, &1.id})
|
|
%{filter | values: [{nil, options}]}
|
|
end
|
|
|
|
defp preload_values(filter, _query_module, _subject),
|
|
do: filter
|
|
|
|
defp params_to_page(id, limit, params) do
|
|
if cursor = Map.get(params, "#{id}_cursor") do
|
|
{:ok, [cursor: cursor, limit: limit]}
|
|
else
|
|
{:ok, [limit: limit]}
|
|
end
|
|
end
|
|
|
|
defp params_to_filter(id, params) do
|
|
params
|
|
|> Map.get("#{id}_filter", [])
|
|
|> Enum.reduce_while({:ok, []}, fn {key, value}, {:ok, acc} ->
|
|
case cast_filter(value) do
|
|
{:ok, nil} -> {:cont, acc}
|
|
{:ok, value} -> {:cont, {:ok, [{String.to_existing_atom(key), value}] ++ acc}}
|
|
{:error, reason} -> {:halt, {:error, reason}}
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp cast_filter(%{"from" => from, "to" => to}) do
|
|
with {:ok, from, 0} <- DateTime.from_iso8601(from),
|
|
{:ok, to, 0} <- DateTime.from_iso8601(to) do
|
|
{:ok, %Domain.Repo.Filter.Range{from: from, to: to}}
|
|
else
|
|
_other -> {:error, :invalid_filter}
|
|
end
|
|
end
|
|
|
|
defp cast_filter(%{"to" => to}) do
|
|
with {:ok, to, 0} <- DateTime.from_iso8601(to) do
|
|
{:ok, %Domain.Repo.Filter.Range{to: to}}
|
|
else
|
|
_other -> {:error, :invalid_filter}
|
|
end
|
|
end
|
|
|
|
defp cast_filter(%{"from" => from}) do
|
|
with {:ok, from, 0} <- DateTime.from_iso8601(from) do
|
|
{:ok, %Domain.Repo.Filter.Range{from: from}}
|
|
else
|
|
_other -> {:error, :invalid_filter}
|
|
end
|
|
end
|
|
|
|
defp cast_filter("") do
|
|
{:ok, nil}
|
|
end
|
|
|
|
defp cast_filter(binary) when is_binary(binary) do
|
|
{:ok, binary}
|
|
end
|
|
|
|
defp cast_filter(_other) do
|
|
{:error, :invalid_filter}
|
|
end
|
|
|
|
@doc false
|
|
def filter_to_form(filter, as) do
|
|
# Note: we don't support nesting, :and or :where on the UI yet
|
|
for {key, value} <- filter, into: %{} do
|
|
{Atom.to_string(key), value}
|
|
end
|
|
|> to_form(as: as)
|
|
end
|
|
|
|
defp params_to_order_by(sortable_fields, id, params) do
|
|
order_by =
|
|
Map.get(params, "#{id}_order_by", "")
|
|
|> parse_order_by(sortable_fields)
|
|
|
|
{:ok, order_by}
|
|
end
|
|
|
|
defp parse_order_by(order_by, sortable_fields) do
|
|
with [field_assoc, field_direction, field_field] <- String.split(order_by, ":", parts: 3),
|
|
{assoc, field} <-
|
|
Enum.find(sortable_fields, fn {assoc, field} ->
|
|
to_string(assoc) == field_assoc && to_string(field) == field_field
|
|
end),
|
|
field_direction when field_direction in ["asc", "desc"] <- field_direction do
|
|
{assoc, String.to_existing_atom(field_direction), field}
|
|
else
|
|
_other -> nil
|
|
end
|
|
end
|
|
|
|
def handle_live_table_event("reload", %{"table_id" => id}, socket) do
|
|
{:noreply, reload_live_table!(socket, id)}
|
|
end
|
|
|
|
def handle_live_table_event("paginate", %{"table_id" => id, "cursor" => cursor}, socket) do
|
|
update_query_params(socket, fn query_params ->
|
|
put_cursor_to_params(query_params, id, cursor)
|
|
end)
|
|
end
|
|
|
|
def handle_live_table_event("order_by", %{"table_id" => id, "order_by" => order_by}, socket) do
|
|
sortable_fields = Map.fetch!(socket.assigns.sortable_fields_by_table_id, id)
|
|
|
|
order_by =
|
|
order_by
|
|
|> parse_order_by(sortable_fields)
|
|
|> reverse_order_by()
|
|
|
|
update_query_params(socket, fn query_params ->
|
|
query_params
|
|
|> delete_cursor_from_params(id)
|
|
|> put_order_by_to_params(id, order_by)
|
|
end)
|
|
end
|
|
|
|
def handle_live_table_event(
|
|
"filter",
|
|
%{"_target" => ["_reset:" <> id, field], "table_id" => id},
|
|
socket
|
|
) do
|
|
update_query_params(socket, fn query_params ->
|
|
query_params
|
|
|> delete_cursor_from_params(id)
|
|
|> Map.reject(fn {key, _} -> String.starts_with?(key, "#{id}_filter[#{field}]") end)
|
|
end)
|
|
end
|
|
|
|
def handle_live_table_event("filter", %{"table_id" => id} = params, socket) do
|
|
filter = Map.get(params, id, %{})
|
|
|
|
update_query_params(socket, fn query_params ->
|
|
query_params
|
|
|> delete_cursor_from_params(id)
|
|
|> put_filter_to_params(id, filter)
|
|
end)
|
|
end
|
|
|
|
defp reverse_order_by({assoc, :asc, field}), do: {assoc, :desc, field}
|
|
defp reverse_order_by({assoc, :desc, field}), do: {assoc, :asc, field}
|
|
defp reverse_order_by(nil), do: nil
|
|
|
|
def update_query_params(socket, update_fun) when is_function(update_fun, 1) do
|
|
uri = URI.parse(socket.assigns.uri)
|
|
|
|
query =
|
|
URI.decode_query(uri.query || "")
|
|
|> update_fun.()
|
|
|> Enum.flat_map(fn
|
|
{key, values} when is_list(values) -> Enum.map(values, &{"#{key}[]", &1})
|
|
{key, values} when is_map(values) -> values |> Map.values() |> Enum.map(&{"#{key}[]", &1})
|
|
{key, value} -> [{key, value}]
|
|
end)
|
|
|> URI.encode_query(:rfc3986)
|
|
|
|
{:noreply, push_patch(socket, to: String.trim_trailing("#{uri.path}?#{query}", "?"))}
|
|
end
|
|
|
|
defp put_cursor_to_params(params, id, cursor) do
|
|
Map.put(params, "#{id}_cursor", cursor)
|
|
end
|
|
|
|
defp delete_cursor_from_params(params, id) do
|
|
Map.delete(params, "#{id}_cursor")
|
|
end
|
|
|
|
defp put_order_by_to_params(params, id, {assoc, direction, field}) do
|
|
Map.put(params, "#{id}_order_by", "#{assoc}:#{direction}:#{field}")
|
|
end
|
|
|
|
defp put_order_by_to_params(params, id, nil) do
|
|
Map.delete(params, "#{id}_order_by")
|
|
end
|
|
|
|
defp put_filter_to_params(params, id, filter) do
|
|
filter_params = flatten_filter(filter, "#{id}_filter", %{})
|
|
|
|
params
|
|
|> Map.reject(fn {key, _} -> String.starts_with?(key, "#{id}_filter") end)
|
|
|> Map.merge(filter_params)
|
|
end
|
|
|
|
defp flatten_filter([], _key_prefix, acc) do
|
|
acc
|
|
end
|
|
|
|
defp flatten_filter(map, key_prefix, acc) when is_map(map) do
|
|
flatten_filter(Map.to_list(map), key_prefix, acc)
|
|
end
|
|
|
|
defp flatten_filter([{_key, ""} | rest], key_prefix, acc) do
|
|
flatten_filter(rest, key_prefix, acc)
|
|
end
|
|
|
|
defp flatten_filter([{_key, "__all__"} | rest], key_prefix, acc) do
|
|
flatten_filter(rest, key_prefix, acc)
|
|
end
|
|
|
|
defp flatten_filter([{key, %{"date" => _} = datetime_range_filter} | rest], key_prefix, acc) do
|
|
if value = normalize_datetime_filter(datetime_range_filter) do
|
|
flatten_filter(rest, key_prefix, Map.put(acc, "#{key_prefix}[#{key}]", value))
|
|
else
|
|
flatten_filter(rest, key_prefix, acc)
|
|
end
|
|
end
|
|
|
|
defp flatten_filter([{key, value} | rest], key_prefix, acc)
|
|
when is_list(value) or is_map(value) do
|
|
acc = Map.merge(acc, flatten_filter(value, "#{key_prefix}[#{key}]", %{}))
|
|
flatten_filter(rest, key_prefix, acc)
|
|
end
|
|
|
|
defp flatten_filter([{key, value} | rest], key_prefix, acc) do
|
|
flatten_filter(rest, key_prefix, Map.put(acc, "#{key_prefix}[#{key}]", value))
|
|
end
|
|
|
|
defp normalize_datetime_filter(params) do
|
|
with {:ok, date} <- Date.from_iso8601(params["date"]),
|
|
{:ok, time} <- normalize_time_filter(params["time"] || "00:00:00") do
|
|
DateTime.new!(date, time) |> DateTime.to_iso8601()
|
|
else
|
|
_other -> nil
|
|
end
|
|
end
|
|
|
|
defp normalize_time_filter(time) when byte_size(time) == 5 do
|
|
Time.from_iso8601(time <> ":00")
|
|
end
|
|
|
|
defp normalize_time_filter(time) do
|
|
Time.from_iso8601(time)
|
|
end
|
|
end
|