# `PhoenixKitCatalogue.Web.ViewConfig`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/v0.28.3/lib/phoenix_kit_catalogue/web/view_config.ex#L1)

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.

# `defaults`

```elixir
@spec defaults(PhoenixKitCatalogue.Web.TableConfig.scope()) :: map()
```

# `global_sort?`

```elixir
@spec global_sort?(PhoenixKitCatalogue.Web.TableConfig.scope()) :: boolean()
```

# `load`

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

# `load_global_sort`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@spec normalize(PhoenixKitCatalogue.Web.TableConfig.scope(), map()) :: map()
```

# `save`

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

# `save_global_sort`

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

# `save_selector`

```elixir
@spec save_selector(map() | nil, %{
  optional(:view) =&gt; String.t(),
  optional(:hidden) =&gt; [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`

```elixir
@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`

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

# `scope_key`

```elixir
@spec scope_key(PhoenixKitCatalogue.Web.TableConfig.scope()) :: String.t()
```

---

*Consult [api-reference.md](api-reference.md) for complete listing*
