PhoenixKitCatalogue.Web.ViewConfig (PhoenixKitCatalogue v0.25.0)

Copy Markdown View Source

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

defaults(scope)

global_sort?(scope)

load(user, scope)

@spec load(map() | nil, PhoenixKitCatalogue.Web.TableConfig.scope()) :: map()

load_global_sort(scope)

@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.

load_selector(user)

@spec load_selector(map() | nil) :: %{
  view: String.t() | nil,
  hidden: [String.t()] | nil
}

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).

load_view(user)

@spec load_view(map() | nil) :: String.t()

The user's module-wide view mode: "card", "comfy" or "table". Defaults to "comfy" for anyone who has never chosen.

normalize(scope, raw)

save(user, scope, cfg)

@spec save(map() | nil, PhoenixKitCatalogue.Web.TableConfig.scope(), map()) ::
  {:ok, map()} | {:error, term()}

save_global_sort(scope, sort_by, sort_dir)

@spec save_global_sort(
  PhoenixKitCatalogue.Web.TableConfig.scope(),
  String.t(),
  :asc | :desc
) ::
  {:ok, term()} | {:error, term()}

save_selector(user, choices)

@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.

save_view(user, view)

@spec save_view(map() | nil, String.t()) :: {:ok, map()} | {:error, term()}

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_on(socket, view)

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.

scope_key(scope)