Skip to content

Commit 030fe33

Browse files
committed
Address PR #1749 review — docs fixes and small hygiene
Bundles: B6, M8, M9, m11, m12 (docs); m2, m3, m7, m8, m9, m10 (code hygiene)
1 parent 19a669e commit 030fe33

12 files changed

Lines changed: 138 additions & 66 deletions

File tree

guides/get_started/installation.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ To get you started quickly, we provide a layout component you can copy & paste i
174174
</Backpex.HTML.Layout.app_shell>
175175
```
176176

177+
These assigns (`@current_theme`, `@sidebar_open`, `@sidebar_section_states`) are populated by `Backpex.InitAssigns` — see [Add resource routes](#add-resource-routes) below for setup.
178+
177179
In addition we recommend to add a bodyless function definition and to configure declarative assigns for your layout component.
178180

179181
```elixir

guides/live_resource/user-preferences.md

Lines changed: 18 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -290,13 +290,21 @@ end
290290

291291
defmodule MyApp.Preferences.UserPreference do
292292
use Ecto.Schema
293+
import Ecto.Changeset
293294

294295
schema "backpex_user_preferences" do
295296
field :user_id, :integer
296297
field :key, :string
297298
field :value, :map, default: %{}
298299
timestamps(type: :utc_datetime_usec)
299300
end
301+
302+
def changeset(user_preference, attrs) do
303+
user_preference
304+
|> cast(attrs, [:user_id, :key, :value])
305+
|> validate_required([:user_id, :key, :value])
306+
|> unique_constraint([:user_id, :key])
307+
end
300308
end
301309

302310
defmodule MyApp.Preferences.EctoAdapter do
@@ -339,11 +347,10 @@ defmodule MyApp.Preferences.EctoAdapter do
339347
def put(%{identity: user_id}, key, value, opts) do
340348
repo = Keyword.fetch!(opts, :repo)
341349

350+
attrs = %{user_id: user_id, key: key, value: wrap_value(value)}
351+
342352
%UserPreference{}
343-
|> UserPreference.__struct__()
344-
|> Map.put(:user_id, user_id)
345-
|> Map.put(:key, key)
346-
|> Map.put(:value, wrap_value(value))
353+
|> UserPreference.changeset(attrs)
347354
|> repo.insert!(on_conflict: {:replace, [:value, :updated_at]}, conflict_target: [:user_id, :key])
348355

349356
{:ok, [:noop]}
@@ -393,53 +400,11 @@ Use when you already have a user settings table (one row per user) with
393400
typed JSON columns. Lets each Backpex prefix write into a named column
394401
rather than a generic rows table.
395402

396-
```elixir
397-
# Migration: adds JSON columns to an existing user_settings table
398-
alter table(:user_settings) do
399-
add :ordering_preferences, :map, null: false, default: %{}
400-
add :filter_preferences, :map, null: false, default: %{}
401-
add :column_visibility, :map, null: false, default: %{}
402-
end
403-
404-
defmodule MyApp.Preferences.EctoAdapter do
405-
@behaviour Backpex.Preferences.Adapter
406-
407-
import Ecto.Query
408-
alias MyApp.Settings.UserSettings
409-
410-
@column_map %{
411-
["resource", _mod, "order"] => :ordering_preferences,
412-
["resource", _mod, "filters"] => :filter_preferences,
413-
["resource", _mod, "columns"] => :column_visibility
414-
}
415-
416-
@impl true
417-
def get(%{identity: :unidentified}, _key, _opts), do: {:ok, :not_found}
418-
def get(%{identity: user_id}, key, opts) do
419-
repo = Keyword.fetch!(opts, :repo)
420-
[_, module_name, _] = segments = Backpex.Preferences.Key.parse(key)
421-
422-
column = segments_to_column(segments)
423-
424-
case repo.one(from s in UserSettings, where: s.user_id == ^user_id, select: field(s, ^column)) do
425-
nil -> {:ok, :not_found}
426-
map -> {:ok, Map.get(map, module_name, :not_found) |> to_ok_not_found()}
427-
end
428-
end
429-
430-
# get_map/3, put/4 follow the same "which column?" lookup.
431-
432-
defp segments_to_column(["resource", _mod, "order"]), do: :ordering_preferences
433-
defp segments_to_column(["resource", _mod, "filters"]), do: :filter_preferences
434-
defp segments_to_column(["resource", _mod, "columns"]), do: :column_visibility
435-
436-
defp to_ok_not_found(:not_found), do: :not_found
437-
defp to_ok_not_found(value), do: value
438-
end
439-
```
440-
441-
This pattern matches apps that already manage preferences through a
442-
purpose-shaped settings table; Backpex just becomes another writer.
403+
When you already have a typed settings table, adapt Recipe A by replacing
404+
the k/v schema: route each prefix to its own column and dispatch reads and
405+
writes based on the key's segments. See the
406+
[ash_backpex](https://github.com/enoonan/ash_backpex) community example for
407+
a working implementation.
443408

444409
## Opt-in persistence for ordering, filters, columns
445410

@@ -521,7 +486,7 @@ BackpexPreferences.set('custom.dashboard.view_mode', 'list')
521486

522487
### Writing from the server
523488

524-
From a LiveView `handle_event`, use `Backpex.Preferences.put_async/3`:
489+
From a LiveView `handle_event`, use `Backpex.Preferences.put_async/4`:
525490

526491
```elixir
527492
def handle_event("toggle_view_mode", _params, socket) do
@@ -533,7 +498,7 @@ def handle_event("toggle_view_mode", _params, socket) do
533498
end
534499
```
535500

536-
Under the hood `put_async/3` tries the configured adapter first. When the
501+
Under the hood `put_async/4` tries the configured adapter first. When the
537502
adapter is session-backed (no HTTP request in a LiveView event), it falls
538503
back to a `push_event/3` round-trip so the browser persists via the
539504
preferences controller on its next paint. DB-backed adapters just write

guides/upgrading/v0.19.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ The `BackpexSidebarSections` hook has been replaced by `BackpexSidebar`, which n
8585

8686
## User Preferences System Overhaul
8787

88+
> #### Warning {: .warning}
89+
>
90+
> All assigns in this migration fail at render time, not compile time — `@current_theme`, `@sidebar_open`, and `@sidebar_section_states` silently fall back to defaults if missing. After each step below, verify in the browser that the feature works (toggle theme, toggle sidebar, reload page).
91+
8892
This version introduces a unified preference system that eliminates UI flickering. User preferences (theme, sidebar state, column visibility, etc.) are now server-rendered from a configurable storage backend on every request.
8993

9094
Backpex ships with a Phoenix-session adapter out of the box (matches the legacy behavior — no action needed). Route individual prefixes to a per-user database adapter when you outgrow the ~4KB cookie ceiling or need preferences to follow a user across devices. See the [User Preferences guide](../live_resource/user-preferences.md) for adapter recipes and the opt-in persistence flag for ordering / filters / columns.

lib/backpex/controllers/preferences_controller.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ defmodule Backpex.PreferencesController do
2222
]}
2323
2424
The batch form is **all-or-nothing**: if any adapter refuses a write, no
25-
side effects are applied, the response is `200 {ok: false, errors: [...]}`,
25+
side effects are applied, the response is `422 {ok: false, errors: [...]}`,
2626
and the session cookie is left unchanged. A partial-success state for
2727
preferences is more confusing than a clean failure.
2828
"""
@@ -53,7 +53,7 @@ defmodule Backpex.PreferencesController do
5353

5454
{:error, errors} ->
5555
conn
56-
|> put_status(200)
56+
|> put_status(422)
5757
|> json(%{ok: false, errors: Enum.map(errors, &format_error/1)})
5858
end
5959
end

lib/backpex/html/layout.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,12 +502,14 @@ defmodule Backpex.HTML.Layout do
502502

503503
attr :sidebar_section_states, :map,
504504
default: %{},
505-
doc: "map of section states (populated automatically from assigns if not provided)"
505+
doc:
506+
"map of section states. Read from the parent's `@sidebar_section_states` assign when not passed explicitly; falls back to `%{}` (all sections open by default)."
506507

507508
slot :inner_block
508509
slot :label, required: true, doc: "label to be displayed on the section."
509510

510511
def sidebar_section(assigns) do
512+
assigns = assign_new(assigns, :sidebar_section_states, fn -> %{} end)
511513
open = Map.get(assigns.sidebar_section_states, assigns.id, true)
512514

513515
assigns =

lib/backpex/html/resource.ex

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ defmodule Backpex.HTML.Resource do
561561
</span>
562562
</:trigger>
563563
<:menu class="min-w-52 max-w-72 p-4">
564-
<.toggle_columns_inputs active_fields={@active_fields} live_resource={@live_resource} />
564+
<.toggle_columns_inputs active_fields={@active_fields} />
565565
</:menu>
566566
</.dropdown>
567567
"""
@@ -573,7 +573,6 @@ defmodule Backpex.HTML.Resource do
573573
@doc type: :component
574574

575575
attr :active_fields, :list, required: true, doc: "list of active fields to be displayed"
576-
attr :live_resource, :atom, required: true, doc: "the live resource"
577576

578577
def toggle_columns_inputs(assigns) do
579578
~H"""

lib/backpex/preferences.ex

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ defmodule Backpex.Preferences do
5959
* `:default` — returned when nothing is stored for `key` (default: `nil`).
6060
6161
Extra options are forwarded to the adapter.
62+
63+
## Examples
64+
65+
iex> session = %{"backpex_preferences" => %{"global" => %{"theme" => "dark"}}}
66+
iex> Backpex.Preferences.get(session, "global.theme")
67+
"dark"
68+
69+
iex> Backpex.Preferences.get(%{}, "global.theme", default: "light")
70+
"light"
6271
"""
6372
@spec get(Context.t() | map(), String.t(), keyword()) :: term()
6473
def get(ctx_or_session, key, opts \\ []) do
@@ -81,6 +90,19 @@ defmodule Backpex.Preferences do
8190
8291
Returns `%{}` when nothing is stored, the adapter cannot identify the user,
8392
or the adapter fails for any other reason.
93+
94+
## Examples
95+
96+
iex> session = %{
97+
...> "backpex_preferences" => %{
98+
...> "global" => %{"sidebar_section" => %{"blog" => true, "users" => false}}
99+
...> }
100+
...> }
101+
iex> Backpex.Preferences.get_map(session, "global.sidebar_section")
102+
%{"blog" => true, "users" => false}
103+
104+
iex> Backpex.Preferences.get_map(%{}, "global.sidebar_section")
105+
%{}
84106
"""
85107
@spec get_map(Context.t() | map(), String.t(), keyword()) :: map()
86108
def get_map(ctx_or_session, prefix, opts \\ []) do
@@ -110,6 +132,19 @@ defmodule Backpex.Preferences do
110132
* `{:error, reason}` — the adapter refused the write for a non-transport
111133
reason. Callers typically ignore the failure (preferences are best
112134
effort) but can surface it if needed.
135+
136+
## Examples
137+
138+
From a Plug controller (session is updated in-place):
139+
140+
Backpex.Preferences.put_async(conn, "global.theme", "dark")
141+
#=> {:ok, %Plug.Conn{}}
142+
143+
From a LiveView `handle_event` (session adapter returns `:requires_http`,
144+
so the dispatcher falls back to a `push_event` for the browser to retry):
145+
146+
Backpex.Preferences.put_async(socket, "global.theme", "dark")
147+
#=> {:ok, %Phoenix.LiveView.Socket{}}
113148
"""
114149
@spec put_async(Plug.Conn.t() | Phoenix.LiveView.Socket.t(), String.t(), term(), keyword()) ::
115150
{:ok, Plug.Conn.t() | Phoenix.LiveView.Socket.t()} | {:error, term()}
@@ -148,6 +183,16 @@ defmodule Backpex.Preferences do
148183
writes under the same session key compose correctly. The caller applies
149184
the returned effects in order; for `:put_session` effects targeting the
150185
same key, the last effect holds the fully-merged value.
186+
187+
## Examples
188+
189+
ctx = Backpex.Preferences.Context.from_conn(conn)
190+
191+
Backpex.Preferences.put_batch(ctx, [
192+
{"global.theme", "dark"},
193+
{"global.sidebar_open", false}
194+
])
195+
#=> {:ok, [{:put_session, "backpex_preferences", %{...}}]}
151196
"""
152197
@spec put_batch(Context.t(), [{String.t(), term()}], keyword()) ::
153198
{:ok, [Backpex.Preferences.Adapter.side_effect()]} | {:error, [term()]}

lib/backpex/preferences/key.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ defmodule Backpex.Preferences.Key do
2121
`"resource.Elixir.DemoWeb.PostLive.columns"` splits into five path segments,
2222
making stored preferences hard to reason about. Switching the whole key to
2323
colons lets the module live as a single atomic segment.
24+
25+
## Separator precedence (read carefully)
26+
27+
A single `":"` anywhere in the key flips the whole key to colon-split
28+
parsing. There is no "mixed" mode. Concretely:
29+
30+
- `"global.theme"` — no colon → dot-split → `["global", "theme"]`
31+
- `"resource:Backpex.Users:columns"` — colon present → colon-split →
32+
`["resource", "Backpex.Users", "columns"]`
33+
- `"custom.bad:key"` — stray colon wins → colon-split →
34+
`["custom.bad", "key"]` (the `.` inside `"custom.bad"` is *not* split)
35+
36+
The last example is almost certainly not what the caller intended. Prefer
37+
`Backpex.Preferences.Key.resource_key/2` when building keys that embed a
38+
module name so the colon form is applied deliberately.
2439
"""
2540

2641
@doc """

lib/backpex/preferences/router.ex

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ defmodule Backpex.Preferences.Router do
6262
6363
Exposed as a public function so `Backpex.Preferences` and test helpers can
6464
reuse it without re-implementing the match logic.
65+
66+
## Examples
67+
68+
iex> routes = [
69+
...> {"global.*", Backpex.Preferences.Adapters.Session, []},
70+
...> {:default, Backpex.Preferences.Adapters.Session, []}
71+
...> ]
72+
iex> Backpex.Preferences.Router.resolve("global.theme", routes)
73+
{Backpex.Preferences.Adapters.Session, []}
6574
"""
6675
@spec resolve(String.t()) :: {module(), keyword()}
6776
@spec resolve(String.t(), [route()]) :: {module(), keyword()}
@@ -99,12 +108,39 @@ defmodule Backpex.Preferences.Router do
99108
defp matches?({:default, _module, _opts}, _key), do: true
100109
defp matches?({pattern, _module, _opts}, key) when is_binary(pattern), do: Key.match?(pattern, key)
101110

102-
defp specificity({:default, _module, _opts}), do: -1
111+
@doc """
112+
Returns a tuple representing the specificity of a route pattern.
113+
114+
The tuple is designed so that `Enum.sort_by/2` (and `Enum.max_by/2`) sort
115+
in the correct precedence order: exact-match patterns beat wildcards of
116+
the same length, longer patterns beat shorter ones, and `:default` always
117+
loses to any named pattern.
103118
104-
defp specificity({pattern, _module, _opts}) when is_binary(pattern) do
105-
case String.split(pattern, ".") do
106-
[_single] -> 100
107-
segments -> length(segments) * 10 - if(List.last(segments) == "*", do: 1, else: 0)
108-
end
119+
The exact tuple shape is an implementation detail; rely only on ordering.
120+
121+
## Examples
122+
123+
iex> Backpex.Preferences.Router.specificity({"global.theme", Foo, []}) >
124+
...> Backpex.Preferences.Router.specificity({"global.*", Foo, []})
125+
true
126+
127+
iex> Backpex.Preferences.Router.specificity({"resource.*", Foo, []}) >
128+
...> Backpex.Preferences.Router.specificity({:default, Foo, []})
129+
true
130+
"""
131+
@spec specificity(route()) :: tuple()
132+
def specificity({:default, _module, _opts}), do: {0, 0, 0}
133+
134+
def specificity({pattern, _module, _opts}) when is_binary(pattern) do
135+
segments = String.split(pattern, ".")
136+
length = length(segments)
137+
exact? = List.last(segments) != "*"
138+
139+
# Sort order: named patterns beat :default, longer patterns beat shorter,
140+
# exact matches beat wildcards at the same depth.
141+
{1, length, bool_to_int(exact?)}
109142
end
143+
144+
defp bool_to_int(true), do: 1
145+
defp bool_to_int(false), do: 0
110146
end

test/controllers/preferences_controller_test.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ defmodule Backpex.PreferencesControllerTest do
130130
:ok
131131
end
132132

133-
test "returns {ok: false, errors: [...]} and leaves the session untouched", %{conn: conn} do
133+
test "returns 422 with {ok: false, errors: [...]} and leaves the session untouched", %{conn: conn} do
134134
params = %{
135135
"preferences" => [
136136
%{"key" => "global.theme", "value" => "dark"},
@@ -140,7 +140,7 @@ defmodule Backpex.PreferencesControllerTest do
140140

141141
conn = PreferencesController.update(conn, params)
142142

143-
assert conn.status == 200
143+
assert conn.status == 422
144144
body = Jason.decode!(conn.resp_body)
145145
assert body["ok"] == false
146146
assert [%{"key" => "test.thing", "reason" => _reason}] = body["errors"]

0 commit comments

Comments
 (0)