Packages

phoenix_kit

2.60.2
2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web table_columns.ex
Raw

lib/phoenix_kit_web/table_columns.ex

defmodule PhoenixKitWeb.TableColumns do
  @moduledoc """
  Which columns a table shows, and in what order, for each user — stored as
  the `"columns"` field of the user's `PhoenixKit.Users.ViewPrefs` for the
  table's key, edited through core's live `column_settings_modal/1`.

  A table describes itself with a spec:

      %{
        key: "catalogue.detail_items",           # the ViewPrefs key
        columns: [%{id: "sku", label: fn -> gettext("SKU") end}, …],
        defaults: ["sku", "price"],              # optional; all columns when absent
        site_default: &MyApp.site_columns/0,     # optional; -> [id] | nil
        min: 0                                   # optional; fewest a user may keep
      }

  `columns` is exactly what the modal can show or hide. A column that is
  always on (a Name, an Actions cell) is not in it: the table draws it
  around the resolved list.

  ## The rules

    * A user who has not chosen sees the site's default when there is one
      (`site_default`), else `defaults`. Reset takes the user's choice back
      out, so they follow that default again — it does not save a copy of it.
    * An empty list is a choice: every optional column hidden — unless the
      spec keeps a `:min`: a choice or site default with fewer columns than
      that is not used, and the next default in line shows instead.
    * Ids no longer in `columns` (a custom field deleted, an extension
      switched off) are skipped when reading and never rewritten by a read;
      when none of a non-empty choice is left, the default shows until the
      user changes something.
    * A change saves the list the user now sees — ids already skipped are
      not carried along.

  ## In a LiveView

      def handle_event(event, params, socket)
          when event in ~w(add_column remove_column reorder_columns reset_columns) do
        {:noreply, TableColumns.handle_event(event, params, socket, spec(), :columns)}
      end

  `handle_event/5` updates the assign and saves for the signed-in user
  (`PhoenixKitWeb.Actor`); with nobody signed in the change lasts for the
  page. A LiveComponent has only the assigns its parent passed, so it needs
  `phoenix_kit_current_scope` (or `phoenix_kit_current_user`) handed in, or
  its choices are never saved.

  A modal with several tables (`sections`) sends a `"section"` param with
  add, remove and reorder — pick the spec by it and pass that. Reset carries
  no section: it resets every table the modal shows, so handle it apart.
  """

  require Logger

  alias PhoenixKit.Users.ViewPrefs
  alias PhoenixKitWeb.Actor

  @field "columns"
  @events ~w(add_column remove_column reorder_columns reset_columns)

  @type column :: %{required(:id) => String.t(), required(:label) => String.t() | (-> String.t())}
  @type spec :: %{
          required(:key) => String.t(),
          required(:columns) => [column()],
          optional(:defaults) => [String.t()],
          optional(:site_default) => (-> [String.t()] | nil),
          optional(:min) => non_neg_integer()
        }

  @doc "The events `handle_event/5` answers."
  @spec events() :: [String.t()]
  def events, do: @events

  @doc "The columns `user` sees for the table `spec` describes."
  @spec load(ViewPrefs.user(), spec()) :: [String.t()]
  def load(user, spec) do
    user
    |> ViewPrefs.get(spec.key)
    |> Map.get(@field)
    |> resolve(spec)
  end

  @doc """
  The columns a stored choice shows (see the rules in the moduledoc):
  `nil` for no choice, else the stored list.
  """
  @spec resolve(term(), spec()) :: [String.t()]
  def resolve(stored, spec) when is_list(stored) do
    shown = known(stored, spec)

    # A choice with nothing left that the table offers, or fewer columns
    # than the spec keeps, is no usable choice.
    if (shown == [] and stored != []) or below_min?(shown, spec),
      do: default(spec),
      else: shown
  end

  def resolve(_stored, spec), do: default(spec)

  @doc """
  What a user who has not chosen sees: the site's default, read by the
  same rules as a user's choice (an empty one is a choice), else the
  spec's `defaults` (all columns when there are none).
  """
  @spec default(spec()) :: [String.t()]
  def default(spec) do
    builtin = builtin_default(spec)

    case site_default(spec) do
      site when is_list(site) ->
        shown = known(site, spec)
        if (shown == [] and site != []) or below_min?(shown, spec), do: builtin, else: shown

      _ ->
        builtin
    end
  end

  # A spec whose own `defaults` are below its minimum has no usable
  # default — the table would draw fewer columns than it promises — so
  # every column stands in, as it does when there are no defaults at all.
  defp builtin_default(spec) do
    shown = known(Map.get(spec, :defaults) || ids(spec), spec)
    if below_min?(shown, spec), do: known(ids(spec), spec), else: shown
  end

  defp below_min?(shown, spec), do: length(shown) < Map.get(spec, :min, 0)

  defp site_default(spec) do
    case Map.get(spec, :site_default) do
      fun when is_function(fun, 0) -> fun.()
      _ -> nil
    end
  end

  @doc "`current` with `id` added at the end — when it is offered and not shown yet."
  @spec add([String.t()], term(), spec()) :: [String.t()]
  def add(current, id, spec) do
    if is_binary(id) and id in ids(spec) and id not in current, do: current ++ [id], else: current
  end

  @doc "`current` without `id` — unless that would leave fewer than the spec's `:min`."
  @spec remove([String.t()], term(), spec()) :: [String.t()]
  def remove(current, id, spec) do
    if id in current and length(current) > Map.get(spec, :min, 0),
      do: List.delete(current, id),
      else: current
  end

  @doc """
  `current` in the order `ordered` gives. Ids `ordered` names that are not
  shown are ignored; shown ones it leaves out keep their place at the end,
  so a partial or crafted payload can reorder but never drop or add.
  """
  @spec reorder([String.t()], term(), spec()) :: [String.t()]
  def reorder(current, ordered, _spec) when is_list(ordered) do
    moved = ordered |> Enum.filter(&(&1 in current)) |> Enum.uniq()
    moved ++ (current -- moved)
  end

  def reorder(current, _ordered, _spec), do: current

  @doc """
  Applies one of the modal's events (`events/0`) to the list in assign
  `name`, saves it for the signed-in user, and answers the socket.
  Anything else — an unknown event, a malformed param — leaves it alone.
  """
  @spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t(), spec(), atom()) ::
          Phoenix.LiveView.Socket.t()
  def handle_event("reset_columns", _params, socket, spec, name) do
    case ViewPrefs.delete_fields(Actor.uuid(socket), spec.key, [@field]) do
      {:ok, _prefs} -> :ok
      {:error, :no_user} -> :ok
      {:error, reason} -> log_failure(spec, reason)
    end

    Phoenix.Component.assign(socket, name, default(spec))
  end

  def handle_event(event, params, socket, spec, name) when event in @events do
    current = Map.get(socket.assigns, name) || []

    case apply_event(event, params, current, spec) do
      ^current -> socket
      changed -> save(socket, spec, name, changed)
    end
  end

  def handle_event(_event, _params, socket, _spec, _name), do: socket

  defp apply_event("add_column", %{"column_id" => id}, current, spec), do: add(current, id, spec)

  defp apply_event("remove_column", %{"column_id" => id}, current, spec),
    do: remove(current, id, spec)

  defp apply_event("reorder_columns", %{"ordered_ids" => ids}, current, spec),
    do: reorder(current, ids, spec)

  defp apply_event(_event, _params, current, _spec), do: current

  defp save(socket, spec, name, columns) do
    case ViewPrefs.put(Actor.uuid(socket), spec.key, %{@field => columns}) do
      {:ok, _prefs} -> :ok
      {:error, :no_user} -> :ok
      {:error, reason} -> log_failure(spec, reason)
    end

    Phoenix.Component.assign(socket, name, columns)
  end

  # The reason's shape only: a database error can carry the query's values.
  defp log_failure(spec, reason) do
    Logger.warning("[TableColumns] could not save the columns of #{spec.key}: #{shape(reason)}")
  end

  defp shape(%{__struct__: mod}), do: inspect(mod)
  defp shape(reason) when is_atom(reason), do: inspect(reason)
  defp shape(_reason), do: "an error"

  defp ids(spec), do: Enum.map(spec.columns, & &1.id)

  defp known(list, spec) do
    offered = MapSet.new(ids(spec))
    list |> Enum.filter(&(is_binary(&1) and MapSet.member?(offered, &1))) |> Enum.uniq()
  end
end