Add user preferences system with pluggable storage adapters#1749
Draft
Flo0807 wants to merge 23 commits intofeature/collapsible-sidebarfrom
Draft
Add user preferences system with pluggable storage adapters#1749Flo0807 wants to merge 23 commits intofeature/collapsible-sidebarfrom
Flo0807 wants to merge 23 commits intofeature/collapsible-sidebarfrom
Conversation
…ce-system Resolve conflicts combining the unified collapsible sidebar with the cookie-based user preference system: - Sidebar state (open/closed, section expansion) is server-rendered from cookie preferences and persisted via BackpexPreferences. - Adopt the collapsible sidebar's accessibility improvements (focus trap, aria-expanded/aria-controls, motion-safe transitions, CSS breakpoint variable) on top of the preference-driven initial state. - Rebuild priv/static/js bundles from merged sources. - Move the user preference upgrade notes from v0.18 (already released) to the v0.19 upgrade guide.
Preferences previously went straight to the Phoenix session via a single path (`Backpex.Preferences` + `PreferencesController`). That works for a 4KB cookie but falls over for per-user, cross-device, or bulky state (column visibility across dozens of resources, filter saving, ordering). Introduce `Backpex.Preferences.Adapter` with a side-effect-returning `put/4` callback, a longest-prefix `Backpex.Preferences.Router`, a `Backpex.Preferences.Context` struct, and a `Backpex.Preferences.Key` helper that encodes module names as single segments via a secondary `:` separator so `resource.Elixir.MyApp.MyLive.columns` no longer splits into six nested maps. The legacy cookie path becomes `Backpex.Preferences.Adapters.Session`, the default when no adapters are configured — zero-config upgrade for existing apps. `Preferences.put_batch/3` threads the accumulated session through each adapter call so `put_session` effects over the same session key compose correctly; the controller applies them all-or-nothing. A new `persist: [:order, :filters, :columns]` option on `use Backpex.LiveResource` opts the index view into round-tripping ordering, filters, and column visibility through whichever adapter handles `resource.*`. Also rename `BackpexPreferences.cookiePath` to `endpointPath` in the JS hook — no user-facing change unless you call the hook directly.
Drop the "cookie-based" framing, document the adapter/router/identity layer, and add two Ecto recipes — a generic key/value table and a prefix-to-column mapping for apps that already have a user_settings-style table. Also document the `persist:` opt-in for ordering, filters, and columns.
`Backpex.LiveResource.Index` is `@moduledoc false` and ExDoc with `--warnings-as-errors` treats backticked references to hidden modules as errors. The key-reference table doesn't need to name the module — "Index view mount" / "`toggle_column` event" describe the call sites functionally.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a unified user-preferences system with pluggable storage adapters, routing preferences by key-prefix, and persisting UI state (theme/sidebar/resource index state) via a single /backpex_preferences endpoint.
Changes:
- Introduces
Backpex.Preferencesdispatcher + adapter behavior, routing, key parsing, and session adapter implementation. - Replaces cookie/localStorage-based UI persistence (theme/sidebar/metrics/columns) with server-backed preferences and a JS persistence hook.
- Adds/updates tests and guides (user preferences guide + v0.19 upgrade notes) and updates demo integration.
Reviewed changes
Copilot reviewed 41 out of 43 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/support/in_memory_preferences_adapter.ex | Adds ETS-backed adapter for tests to exercise non-session routing. |
| test/router_test.exs | Updates route assertion from cookies endpoint to preferences endpoint. |
| test/preferences_test.exs | Adds unit tests for dispatcher API and session-map compatibility. |
| test/preferences/router_test.exs | Adds router matching tests (specific vs wildcard vs default). |
| test/preferences/key_test.exs | Adds key parsing/matching tests (dot vs colon forms). |
| test/preferences/dispatcher_integration_test.exs | Adds integration coverage for cross-adapter routing and batch behavior. |
| test/preferences/adapters/session_test.exs | Adds session adapter contract tests (get/get_map/put). |
| test/plugs/theme_selector_plug_test.exs | Removes tests for deleted ThemeSelectorPlug. |
| test/controllers/preferences_controller_test.exs | Adds controller tests for single/batch writes and error handling. |
| test/controllers/cookie_controller_test.exs | Removes tests for deleted CookieController. |
| priv/static/js/backpex.esm.js | Compiled JS update: adds preferences hook, moves persistence off localStorage/cookies. |
| priv/static/js/backpex.cjs.js | Compiled JS update mirroring ESM changes. |
| mix.exs | Adds new user-preferences guide to documentation extras. |
| lib/backpex/router.ex | Renames backpex route to /backpex_preferences and adds preferences_path/1. |
| lib/backpex/preferences/router.ex | Adds prefix-router for selecting adapters via longest-prefix strategy. |
| lib/backpex/preferences/key.ex | Adds key parsing/matching helpers (colon separator to avoid module-dot collisions). |
| lib/backpex/preferences/context.ex | Adds context struct/builders for adapter calls + identity memoization. |
| lib/backpex/preferences/adapters/session.ex | Implements session-backed adapter emitting :put_session side effects. |
| lib/backpex/preferences/adapter.ex | Defines adapter behavior + side-effect protocol. |
| lib/backpex/preferences.ex | Adds dispatcher API (get/get_map/put_async/put_batch + identity resolution). |
| lib/backpex/plugs/theme_selector.ex | Deletes ThemeSelectorPlug (theme now handled via preferences/init assigns). |
| lib/backpex/live_resource/index.ex | Adds opt-in persisted index state (persist:) and pushes preference writes via events. |
| lib/backpex/live_resource.ex | Adds persist: option to LiveResource configuration schema. |
| lib/backpex/init_assigns.ex | Populates @current_theme, @sidebar_open, @sidebar_section_states from preferences. |
| lib/backpex/html/resource/resource_index_main.html.heex | Removes old toggle-columns form dependencies (socket/current_url). |
| lib/backpex/html/resource.ex | Converts toggle-columns + metrics toggles to LiveView events instead of controller forms. |
| lib/backpex/html/layout.ex | Adds preferences hook mount point, theme selector uses current_theme, sidebar SSR state attrs. |
| lib/backpex/controllers/preferences_controller.ex | Adds JSON endpoint for preference writes (single and batch). |
| lib/backpex/controllers/cookie_controller.ex | Deletes CookieController. |
| guides/upgrading/v0.19.md | Documents new preferences system + breaking changes/migrations. |
| guides/live_resource/user-preferences.md | New guide describing architecture, keys, adapters, identity resolver, and persist flags. |
| guides/get_started/installation.md | Updates installation examples for new assigns and updated layout usage. |
| demo/lib/demo_web/router.ex | Updates demo pipeline away from removed Backpex.ThemeSelectorPlug. |
| demo/lib/demo_web/plugs/theme_plug.ex | Adds demo plug for assigning theme from preferences. |
| demo/lib/demo_web/components/layouts/admin.html.heex | Updates demo admin layout to pass socket/sidebar_open/current_theme/sidebar_section_states. |
| demo/lib/demo_web/components/layouts.ex | Adds new assigns needed by updated admin layout. |
| demo/assets/js/app.js | Removes now-unneeded setStoredTheme() call. |
| assets/js/hooks/index.js | Exports new BackpexPreferencesHook. |
| assets/js/hooks/_theme_selector.js | Persists theme via preferences (no localStorage + no direct fetch). |
| assets/js/hooks/_sidebar.js | Persists sidebar open/section state via preferences (no localStorage). |
| assets/js/hooks/_preferences.js | Adds preferences persistence module + LiveView hook to receive push_events and POST JSON. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Bundles: B6, M8, M9, m11, m12 (docs); m2, m3, m7, m8, m9, m10 (code hygiene)
… docstring tone
- B2: add Router.resolve_prefix/1 for correct get_map/3 adapter selection - B2: raise ArgumentError at config time for subtree-conflicting patterns - M10: cover tie-break, zero-config, malformed entries, deeper exact wins
…ircuit - Switch put_batch/3 loop to reduce_while (B1 Path A) - Prepend + reverse effects accumulator (m1, removes O(n²)) - Walk back "atomic" claim in moduledoc, docstring, controller, guide - Replace toothless atomicity test with short-circuit proof (B8)
…t seam - Backpex.Preferences.Keys exposes canonical key names - Replace string literals in InitAssigns, Index, and tests - Extract push_event seam to Backpex.Preferences.LiveView.push_write/3
D3 resolved to Option P (hard rename, no deprecation). Since the function runs synchronously, "async" was a misnomer. New name mirrors Map.put/3 and Plug.Conn.put_session/3. Call sites in library, tests, and guides updated.
- B3: route {:put_session, _} from socket through push_event fallback; warn
- B9: add put/4 test coverage (Plug.Conn, Socket, adapter raise, unidentified)
- M1: walk back per-session memoization claim in docstring
- M4: add Logger.warning on resolver rescue and error swallow paths
- m4: drop dead conn field from Context struct
- m5: strict coerce/1 guard; raise on non-session shapes
- m6: add fetch/3 distinguishing :error from {:error, reason}; log in get/3
…og attribution
Two review-driven follow-ups on the preference dispatcher.
Fix 1: fetch/3 now collapses {:error, :unidentified} to :error — matching
the adapter behaviour's "treat as not found" semantics. No warning is
logged for that case because it is the expected path for anonymous
visitors / background jobs. Other {:error, reason} tuples still surface
unchanged, so a genuine adapter failure remains distinguishable.
Fix 2: Every dispatcher-originated Logger.warning now carries the adapter
module in the message — "adapter \#{inspect(module)} returned error on
get/3 ..." — so operators running multi-adapter routing (global.* →
Session, resource.* → EctoAdapter) can tell which backend failed. The
dispatch helpers now return {module, result} tuples so callers can
attribute; identity-resolver warnings say "resolving identity via
\#{resolver}" instead, since no adapter is involved.
Align guide with bucket 5b changes: - m4: drop ctx.conn field from resolver example (field removed from Context) - M1: walk back per-session memoization claim
Restores test coverage for the on_mount hook that replaced the deleted
ThemeSelectorPlug. Covers the happy path, malformed-session fallbacks,
and adapter-driven overrides for `global.theme`.
Also fixes a latent bug in `Backpex.Preferences.Adapters.Session.root/1`
uncovered while writing the malformed-session tests: a host app that
stomps on the session key with a non-map (binary/nil/other) caused
`get_in/2` to crash. `root/1` now coerces any non-map value to `%{}`.
…option Configure DemoWeb.PostLive with persist: [:order, :filters, :columns] and add preferences_persistence_test.exs covering all three persistence kinds. Each test mounts the LiveResource, triggers the relevant interaction (sort, filter change, column toggle), and asserts the matching push_event is emitted with the canonical key built via Backpex.Preferences.Keys. The wire event name comes from Backpex.Preferences.LiveView.event_name/0, so a rename of either breaks the suite. Regression verified: removing the PreferenceLiveView.push_write call from maybe_persist_order/2 makes the order test fail.
- B4: export BackpexPreferences as named export in JS bundle so the custom.* recipe in the guide works when copy-pasted - B5: remove DemoWeb.ThemePlug (replaced by Backpex.InitAssigns); demo now mirrors the upgrade guide's instructions
Credo Software Design flags the two identical RejectingAdapter definitions in the controller and dispatcher tests as duplicate code (mass 41). Move the adapter to `test/support/preferences/` and alias it from both call sites.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces a unified preference system that persists UI state (theme, sidebar, column visibility, ordering, filters, custom keys) through a pluggable adapter layer. Preferences are server-rendered from the adapter on every page load — no flicker on first paint, no round-trip before the UI reflects saved state.
Out of the box everything lives in the Phoenix session (zero config). Route any prefix to a per-user database in config:
Architecture
Core modules
Backpex.Preferences.Adapterget/3,get_map/3,put/4.put/4returns side effects, doesn't touchPlug.Conn.Backpex.Preferences.Router:defaultfallback. Config-time validation raises on conflicting subtree routes.Backpex.Preferences.Contextsource,session,assigns,identity).Backpex.Preferences.Key:as a secondary separator so module-name dots stay as one segment.Backpex.Preferences.Keystheme/0,sidebar_open/0,columns/1,order/1,filters/1,metrics_visible/1).Backpex.Preferences.LiveViewpush_write/3emits thebackpex:set_preferenceevent. Event name isevent_name/0.Backpex.Preferences.Adapters.SessionDispatcher API (
Backpex.Preferences)get/3— read, fall back to:default. Accepts a%Context{}or a bare session map. Logs a warning when an adapter returns an unexpected error.fetch/3— likeget/3but returns{:ok, value} | :error | {:error, reason}. Distinguishes "not found" (including:unidentified) from adapter failure.get_map/3— read a prefix as a nested map.put/4— write from a socket or conn; falls back topush_event/3when the adapter can't write from a LiveView context.put_batch/3— cross-adapter batch writes, best-effort, first-error-wins. On the first adapter error the batch short-circuits and returns{:error, {key, reason}}. Earlier writes may have already committed — callers should treat partial success as possible.Identity resolution
One MFA in config (
identity: {Mod, :fun, []}); the dispatcher calls the resolver on every dispatch and caches the result onctx.identityfor the duration of that single call, so each adapter invocation sees a consistent value. If memoization across calls matters, the application should cache externally (e.g. viaon_mountassign). Keep the resolver cheap.Opt-in persistence for index state
New
persist:option onuse Backpex.LiveResource::order— readsresource:<Mod>:orderon mount; writes onhandle_paramswhen order changes.:filters— readsresource:<Mod>:filterson mount; writes onhandle_paramswhen filters change.:columns— readsresource:<Mod>:columnson mount; writes on thetoggle_columnevent.Default is
[]: the URL is the source of truth for order and filters, and column state lives in-memory.Built-in key reference
global.themeInitAssignsglobal.sidebar_openInitAssignsglobal.sidebar_section.<id>InitAssigns(get_map)resource:<Mod>:columnstoggle_columneventpersist: [:columns]resource:<Mod>:metrics_visibletoggle_metricseventresource:<Mod>:orderhandle_params(on change)persist: [:order]resource:<Mod>:filtershandle_params(on change)persist: [:filters]Key encoding
Keys whose segments contain dots (typically because a segment embeds a module name like
MyApp.MyLive) use:as the separator:resource:MyApp.MyLive:columnsparses into three clean segments. Keys without embedded module names use the usual dot form:global.theme.Breaking changes (v0.19 overhaul)
Spelled out in full in guides/upgrading/v0.19.md:
Backpex.ThemeSelectorPlugremoved — theme is populated byBackpex.InitAssigns.@current_themeinstead of@theme.theme_selectorcomponent takescurrent_theme.app_shellcomponent takessidebar_open.BackpexSidebarhook replacesBackpexSidebarSections.With no
:backpex, Backpex.Preferencesconfig, every key routes to the Session adapter — this matches the zero-config default and keeps behavior stable for apps that don't opt into a custom adapter.Migration — opting into a DB-backed adapter
Backpex.Preferences.Adapteragainst your table (two complete recipes in the guide).JavaScript API
The compiled bundle exports
BackpexPreferencesas a named export alongsideHooks. Application JS can write custom preferences:Writes that can't be persisted from a LiveView context (for example
put/4called on a socket when the adapter needs to set a session cookie) fall back to apush_event/3with thebackpex:set_preferencename; the hook inassets/js/hooks/_preferences.jsforwards them toPOST /backpex_preferences.Observability
Every error path in the dispatcher logs a
Logger.warningwith the adapter module, key, and reason. Rescue points in the identity resolver log the resolver MFA. No telemetry events yet — surface can be added in a follow-up if operators need structured metrics.Documentation
persist:migration example, troubleshooting.Testing
MIX_ENV=test mix test: 122 doctests + 264 tests, 0 failures.mix lint: credo clean,mix format --check-formattedclean,mix compile --warnings-as-errorsclean.End-to-end LiveView integration coverage for
persist: [:order, :filters, :columns]lives atdemo/test/demo_web/live/preferences_persistence_test.exs. MountsDemoWeb.PostLive, triggers sort / filter / column-toggle interactions, and asserts the matchingpush_eventfires with the canonical key — the emitter and the test pull from the sameBackpex.Preferences.KeysandBackpex.Preferences.LiveView.event_name/0.Test plan
Session-adapter preferences (default config):
<html data-theme>reflects the choice.POST /backpex_preferencesreturning{ok: true}.Opt-in
persist:on a demo LiveResource (e.g.DemoWeb.PostLiveat/admin/posts):DemoWeb.PostLiveis already configured withpersist: [:order, :filters, :columns].persist:, the same actions reset on reload (proves the flag gates persistence).Cross-adapter routing:
resource.*to an ETS-backed test adapter; re-run the opt-in checks; writes land in ETS and the session cookie stays empty for resource keys.Error paths:
200 {ok: false, error: %{key: _, reason: :unidentified}}, no exception.{ok: false, error: %{key, reason}}; subsequent entries in the batch are short-circuited (not dispatched). Earlier successful entries may already be committed.Logger.warningcaptured; response is{ok: false, error: %{reason: {:exception, _}}}.Stacked on
Stacked on top of PR #1748 (
feature/collapsible-sidebar). Retargets todeveloponce that merges.