Per-user table view config (columns / sort / filters / view mode) for the
catalogue admin tables. Stored in phoenix_kit_users.custom_fields under
the "catalogue_view_configs" key — no dedicated table. Precedent:
PhoenixKit.Notifications.Prefs.
Global sort
For scopes in @global_sort_scopes the SORT half of the config is not
per-user: it lives in a module setting (catalogue_sort_<scope>), so every
admin sees the same ordering — when one of them switches the catalogues
index to "Manual order" and drags rows, everyone else is looking at that
same order (the live half rides Catalogue.PubSub; see
broadcast_view_sort_changed/4 and CataloguesLive.put_cfg/3).
load/2 overlays the global value over whatever the user row stored, so
the per-user copy is inert for these scopes. Columns and filters stay
per-user, per-scope.
Shared view mode
The VIEW (card / comfy / table) is per-user but not per-scope: it is
one choice for the whole module, stored under @view_key. Picking cards
on the catalogues index and then opening a catalogue used to land on
whatever that page happened to remember — every surface kept its own
preference, and two of them (the detail page, the attributes tab) kept
theirs in the browser's localStorage instead, so the two halves could not
agree even in principle (boss's ask via Max, 2026-08-28: the view should
stay when you switch pages). load/2 overlays it exactly like the sort,
so every cfg.view in the module returns the same answer.
Summary
Functions
The shared sort for a global-sort scope: the catalogue_sort_<scope>
setting ("<column>:<asc|desc>"), falling back to the scope's default
when unset or when it names a column that is no longer sortable.
The user's saved item-selector choices: %{view: "table" | "card" | nil, hidden: [String.t()] | nil}. nil halves mean "never chosen" —
the selector then uses its host attrs/defaults. Hidden entries come
back as the raw stored strings; the selector validates them against
its granted columns (a stale column name is simply ignored).
The user's module-wide view mode: "card", "comfy" or "table".
Defaults to "comfy" for anyone who has never chosen.
Stores the selector choices (merge — a nil half keeps what is
saved). Best-effort like save_view/2: no user, no crash, the choice
just lives for the session.
Stores the module-wide view mode. Best-effort like save/3: a test
harness user (or none) keeps the choice in memory for the session
rather than crashing the LiveView on a toggle click.
save_view/2 for a LiveView: stores the choice and puts the REFRESHED
user back on the socket.
Functions
@spec defaults(PhoenixKitCatalogue.Web.TableConfig.scope()) :: map()
@spec global_sort?(PhoenixKitCatalogue.Web.TableConfig.scope()) :: boolean()
@spec load(map() | nil, PhoenixKitCatalogue.Web.TableConfig.scope()) :: map()
@spec load_global_sort(PhoenixKitCatalogue.Web.TableConfig.scope()) :: {String.t(), :asc | :desc}
The shared sort for a global-sort scope: the catalogue_sort_<scope>
setting ("<column>:<asc|desc>"), falling back to the scope's default
when unset or when it names a column that is no longer sortable.
The user's saved item-selector choices: %{view: "table" | "card" | nil, hidden: [String.t()] | nil}. nil halves mean "never chosen" —
the selector then uses its host attrs/defaults. Hidden entries come
back as the raw stored strings; the selector validates them against
its granted columns (a stale column name is simply ignored).
The user's module-wide view mode: "card", "comfy" or "table".
Defaults to "comfy" for anyone who has never chosen.
@spec normalize(PhoenixKitCatalogue.Web.TableConfig.scope(), map()) :: map()
@spec save(map() | nil, PhoenixKitCatalogue.Web.TableConfig.scope(), map()) :: {:ok, map()} | {:error, term()}
@spec save_global_sort( PhoenixKitCatalogue.Web.TableConfig.scope(), String.t(), :asc | :desc ) :: {:ok, term()} | {:error, term()}
@spec save_selector(map() | nil, %{ optional(:view) => String.t(), optional(:hidden) => [String.t()] }) :: {:ok, map()} | {:error, term()}
Stores the selector choices (merge — a nil half keeps what is
saved). Best-effort like save_view/2: no user, no crash, the choice
just lives for the session.
Stores the module-wide view mode. Best-effort like save/3: a test
harness user (or none) keeps the choice in memory for the session
rather than crashing the LiveView on a toggle click.
@spec save_view_on(Phoenix.LiveView.Socket.t(), String.t()) :: Phoenix.LiveView.Socket.t()
save_view/2 for a LiveView: stores the choice and puts the REFRESHED
user back on the socket.
Keeping the refreshed user is the whole point. Every write here merges
one subtree into the user's entire custom_fields map and saves the
result, so a socket still holding the pre-save user carries a snapshot
that predates the view. The next column or filter save then merges
into that snapshot and writes it back — deleting the view the user
just chose, without an error anywhere. It surfaces one page later, as
"my view didn't stick", which is the thing this feature exists to fix.
@spec scope_key(PhoenixKitCatalogue.Web.TableConfig.scope()) :: String.t()