Skip to content

Add user preferences system with pluggable storage adapters#1749

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

Add user preferences system with pluggable storage adapters#1749
Flo0807 wants to merge 13 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 a commit that referenced this pull request Apr 17, 2026
Bundles: B6, M8, M9, m11, m12 (docs); m2, m3, m7, m8, m9, m10 (code hygiene)
Flo0807 added a commit that referenced this pull request Apr 17, 2026
Flo0807 added a commit that referenced this pull request Apr 17, 2026
- 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
Flo0807 added a commit that referenced this pull request Apr 17, 2026
…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)
Flo0807 added a commit that referenced this pull request Apr 17, 2026
…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
Flo0807 added a commit that referenced this pull request Apr 17, 2026
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.
Flo0807 added a commit that referenced this pull request Apr 17, 2026
- 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
Flo0807 added a commit that referenced this pull request Apr 17, 2026
…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.
Flo0807 added a commit that referenced this pull request Apr 17, 2026
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 `%{}`.
Flo0807 added a commit that referenced this pull request Apr 17, 2026
…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.
Flo0807 added a commit that referenced this pull request Apr 17, 2026
- 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
Flo0807 added a commit that referenced this pull request Apr 21, 2026
Strip finding IDs (B1, M6, m2, ...), hypothetical-rename stories, and
forward/backward "we used to X, now we Y" narrative from comments and
docs. Replace with descriptions of what the code is and what it does.
@Flo0807 Flo0807 force-pushed the feature/user-preference-system branch from 4fdf46c to 67bf334 Compare April 21, 2026 06:54
Flo0807 added 5 commits April 21, 2026 10:59
Move the `persist: [:filters]` write from the reactive `apply_index`
path into the three filter-mutating event handlers via a shared
`apply_filter_change/2` helper. Mirrors the `:columns` pattern.

An empty filters map encodes to no URL param, so a reactive
`handle_params`-based write couldn't distinguish a cleared filter from
a fresh visit and the fallback would restore the persisted value,
silently undoing the clear. Handler-owned writes know the exact target
state — including `%{}` — so the ambiguity never reaches storage.
The filter UI renders two buttons that fire the same
`clear-filter` event for the same field — an inline "clear" link and
the indicator badge's × icon. The old selector matched both, so
`render_click/1` raised with "selector returned 2 elements".

Target the indicator explicitly via its `aria-label`.
LiveView freezes the session at websocket-connect time, so re-mounts
after a live_redirect within the same live_session re-render sections
from a stale cookie snapshot. The sidebar hook also re-mounts on
live_redirect between LiveViews, so hook-instance state alone is not
enough to survive. Persist section open/closed state in sessionStorage
and re-apply it to the DOM after initialize/update so the user's
toggles stick until the next fresh connect re-seeds from the cookie.
Mirrors the sessionStorage pattern already used for sidebar section
state: LiveView re-mounts the sidebar hook on live_redirect between
LiveViews and hands it a data-sidebar-open rendered from a stale
session snapshot, so seeding this.desktopOpen from that attribute
alone makes the shell snap back to the pre-toggle state. Seed from
sessionStorage first; fall back to the data attribute on fresh
connects so cookie round-trip still works.
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