Skip to content

Add user preferences system with pluggable storage adapters#1749

Draft
Flo0807 wants to merge 23 commits intofeature/collapsible-sidebarfrom
feature/user-preference-system
Draft

Add user preferences system with pluggable storage adapters#1749
Flo0807 wants to merge 23 commits intofeature/collapsible-sidebarfrom
feature/user-preference-system

Conversation

@Flo0807
Copy link
Copy Markdown
Collaborator

@Flo0807 Flo0807 commented Jan 8, 2026

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:

config :backpex, Backpex.Preferences,
  adapters: [
    {"global.*",   Backpex.Preferences.Adapters.Session, []},
    {"resource.*", MyApp.Preferences.EctoAdapter, repo: MyApp.Repo},
    {:default,     Backpex.Preferences.Adapters.Session, []}
  ],
  identity: {MyAppWeb.PreferencesIdentity, :resolve, []}

Architecture

Core modules

Module Responsibility
Backpex.Preferences.Adapter Behavior. get/3, get_map/3, put/4. put/4 returns side effects, doesn't touch Plug.Conn.
Backpex.Preferences.Router Longest-prefix match over configured routes (multi-segment wildcards supported). :default fallback. Config-time validation raises on conflicting subtree routes.
Backpex.Preferences.Context Read/write context (source, session, assigns, identity).
Backpex.Preferences.Key Parse/build keys. Supports : as a secondary separator so module-name dots stay as one segment.
Backpex.Preferences.Keys Canonical names for built-in preference keys (theme/0, sidebar_open/0, columns/1, order/1, filters/1, metrics_visible/1).
Backpex.Preferences.LiveView push_write/3 emits the backpex:set_preference event. Event name is event_name/0.
Backpex.Preferences.Adapters.Session Default adapter; also the reference implementation for custom adapters.

Dispatcher 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 — like get/3 but 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 to push_event/3 when 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 on ctx.identity for 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. via on_mount assign). Keep the resolver cheap.

Opt-in persistence for index state

New persist: option on use Backpex.LiveResource:

use Backpex.LiveResource,
  adapter_config: [...],
  persist: [:order, :filters, :columns]
  • :order — reads resource:<Mod>:order on mount; writes on handle_params when order changes.
  • :filters — reads resource:<Mod>:filters on mount; writes on handle_params when filters change.
  • :columns — reads resource:<Mod>:columns on mount; writes on the toggle_column event.

Default is []: the URL is the source of truth for order and filters, and column state lives in-memory.

Built-in key reference

Key Type Where it's read Where it's written Opt-in?
global.theme string InitAssigns JS theme selector always on
global.sidebar_open boolean InitAssigns JS sidebar toggle always on
global.sidebar_section.<id> boolean InitAssigns (get_map) JS sidebar section toggle always on
resource:<Mod>:columns map Index view mount toggle_column event persist: [:columns]
resource:<Mod>:metrics_visible boolean Index view mount toggle_metrics event always on
resource:<Mod>:order map Index view mount (fallback) handle_params (on change) persist: [:order]
resource:<Mod>:filters map Index view mount (fallback) handle_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:columns parses 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.ThemeSelectorPlug removed — theme is populated by Backpex.InitAssigns.
  • Root layout uses @current_theme instead of @theme.
  • theme_selector component takes current_theme.
  • app_shell component takes sidebar_open.
  • BackpexSidebar hook replaces BackpexSidebarSections.

With no :backpex, Backpex.Preferences config, 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

  1. Implement Backpex.Preferences.Adapter against your table (two complete recipes in the guide).
  2. Add an identity resolver MFA.
  3. Add one config block:
    config :backpex, Backpex.Preferences,
      adapters: [
        {"resource.*", MyApp.Preferences.EctoAdapter, repo: MyApp.Repo},
        {:default, Backpex.Preferences.Adapters.Session, []}
      ],
      identity: {MyAppWeb.PreferencesIdentity, :resolve, []}
  4. Opt in per resource:
    persist: [:order, :filters, :columns]

JavaScript API

The compiled bundle exports BackpexPreferences as a named export alongside Hooks. Application JS can write custom preferences:

import { BackpexPreferences } from "backpex"

BackpexPreferences.set("custom.my_setting", { foo: "bar" })

Writes that can't be persisted from a LiveView context (for example put/4 called on a socket when the adapter needs to set a session cookie) fall back to a push_event/3 with the backpex:set_preference name; the hook in assets/js/hooks/_preferences.js forwards them to POST /backpex_preferences.

Observability

Every error path in the dispatcher logs a Logger.warning with 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

  • guides/live_resource/user-preferences.md: architecture diagram, key reference table, Ecto adapter recipe (generic k/v table), identity resolver walkthrough, persist: migration example, troubleshooting.
  • guides/upgrading/v0.19.md: "Preferences: adapter architecture" section, including a callout that migration assigns fail at render time (not compile time) so each step should be verified in the browser.

Testing

MIX_ENV=test mix test: 122 doctests + 264 tests, 0 failures.
mix lint: credo clean, mix format --check-formatted clean, mix compile --warnings-as-errors clean.

End-to-end LiveView integration coverage for persist: [:order, :filters, :columns] lives at demo/test/demo_web/live/preferences_persistence_test.exs. Mounts DemoWeb.PostLive, triggers sort / filter / column-toggle interactions, and asserts the matching push_event fires with the canonical key — the emitter and the test pull from the same Backpex.Preferences.Keys and Backpex.Preferences.LiveView.event_name/0.

Test plan

Session-adapter preferences (default config):

  • Toggle the sidebar open/closed → reload → state persists.
  • Switch the theme via the theme selector → reload → <html data-theme> reflects the choice.
  • Expand/collapse a sidebar section → reload → section state persists.
  • Each write produces a POST /backpex_preferences returning {ok: true}.

Opt-in persist: on a demo LiveResource (e.g. DemoWeb.PostLive at /admin/posts):

  • DemoWeb.PostLive is already configured with persist: [:order, :filters, :columns].
  • Sort by a column → reload at the base URL (no query params) → sort is restored.
  • Apply a filter → reload at the base URL → filter is restored.
  • Toggle column visibility → reload → hidden columns remain hidden.
  • On a resource without persist:, the same actions reset on reload (proves the flag gates persistence).

Cross-adapter routing:

  • Configure 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:

  • Unauthenticated user hits a write routed to a DB adapter → response is 200 {ok: false, error: %{key: _, reason: :unidentified}}, no exception.
  • Batch write where one adapter errors → HTTP 422 with {ok: false, error: %{key, reason}}; subsequent entries in the batch are short-circuited (not dispatched). Earlier successful entries may already be committed.
  • Adapter stubbed to raise on put → Logger.warning captured; response is {ok: false, error: %{reason: {:exception, _}}}.

Stacked on

Stacked on top of PR #1748 (feature/collapsible-sidebar). Retargets to develop once that merges.

@Flo0807 Flo0807 self-assigned this Jan 8, 2026
@Flo0807 Flo0807 marked this pull request as draft January 8, 2026 16:07
@Flo0807 Flo0807 changed the title Add a new cookie-based user preference system to persist UI state Add cookie-based user preference system to persist UI state Jan 8, 2026
@Flo0807 Flo0807 added the breaking-change A breaking change label Apr 17, 2026
Flo0807 added 4 commits April 17, 2026 11:35
…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.
@Flo0807 Flo0807 changed the title Add cookie-based user preference system to persist UI state Add user preferences system with pluggable storage adapters Apr 17, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.Preferences dispatcher + 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.

Comment thread guides/live_resource/user-preferences.md
Comment thread lib/backpex/html/resource.ex
Comment thread lib/backpex/preferences.ex Outdated
Comment thread lib/backpex/preferences.ex Outdated
Comment thread lib/backpex/html/layout.ex Outdated
Flo0807 added 15 commits April 17, 2026 14:04
Bundles: B6, M8, M9, m11, m12 (docs); m2, m3, m7, m8, m9, m10 (code hygiene)
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change A breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants