Packages
phoenix_kit
2.41.4
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
Current section
Files
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