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} />
<.table_header table_id={@id} columns={@col} actions={@action} ordered_by={@ordered_by} /> <.table_row :for={row <- @rows} columns={@col} actions={@action} row={row} id={@row_id && @row_id.(row)} click={@row_click} mapper={@row_item} />
{render_slot(@empty)}
There are no results matching your filters. .
<.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"""
"[#{@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}
""" 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} />
<.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
<.filter :for={filter <- @filters} live_table_id={@live_table_id} form={@form} filter={filter} />
""" end defp filter(%{filter: %{type: {:range, :datetime}}} = assigns) do ~H"""
<.datetime_input field={@form[@filter.name]} filter={@filter} from_or_to={:from} max={Date.utc_today()} />
to
<.datetime_input field={@form[@filter.name]} filter={@filter} from_or_to={:to} max={Date.utc_today()} />
""" end defp filter(%{filter: %{type: {:string, :websearch}}} = assigns) do ~H"""
<.icon name="hero-magnifying-glass" class="w-5 h-5 text-neutral-500" />
@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}
""" end defp filter(%{filter: %{type: {:string, :email}}} = assigns) do ~H"""
<.icon name="hero-magnifying-glass" class="w-5 h-5 text-neutral-500" />
@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}
""" end defp filter(%{filter: %{type: {:string, :uuid}}} = assigns) do ~H"""
<.input type="group_select" field={@form[@filter.name]} options={ [ {nil, [{"For any " <> @filter.title, nil}]} ] ++ @filter.values } />
""" end defp filter(%{filter: %{type: :string, values: values}} = assigns) when 0 < length(values) and length(values) < 5 do ~H"""
<.intersperse_blocks> <:item> <:item :let={position} :for={{label, value} <- @filter.values}>
""" end defp filter(%{filter: %{type: {:list, :string}, values: values}} = assigns) when 0 < length(values) and length(values) < 5 do ~H"""
<.intersperse_blocks> <:item> <:item :let={position} :for={{label, value} <- @filter.values}>
""" end defp filter(%{filter: %{type: :string, values: values}} = assigns) when length(values) > 0 do ~H"""
<.input type="group_select" field={@form[@filter.name]} options={[ {nil, [{"For any " <> @filter.title, nil}]}, {@filter.title, @filter.values} ]} />
""" end def paginator(assigns) do ~H""" """ 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